diff --git a/apps/local/src/executor.test.ts b/apps/local/src/executor.test.ts index 8156bf6776..2ae8f90172 100644 --- a/apps/local/src/executor.test.ts +++ b/apps/local/src/executor.test.ts @@ -4,8 +4,15 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; -import { disposeExecutor, getExecutor, reloadExecutor } from "./executor"; +import { + createExecutorHandle, + disposeExecutor, + getExecutor, + getExecutorBundle, + reloadExecutor, +} from "./executor"; const withIsolatedExecutorDataDir = async (body: () => Promise): Promise => { const previousDataDir = process.env.EXECUTOR_DATA_DIR; @@ -52,3 +59,47 @@ describe("reloadExecutor", () => { }); }); }); + +describe("toolkit-scoped executors", () => { + it("derives a toolkit-scoped executor while the shared bundle holds the data dir", async () => { + await withIsolatedExecutorDataDir(async () => { + const bundle = await getExecutorBundle(); + + // The bundle holds the data dir's ownership lock (a `BEGIN EXCLUSIVE` on + // `data.db.owner-lock`, per-connection, `busy_timeout = 0`) for its whole + // lifetime. Building this executor without `borrowedDb` opens a second + // owned database, which hits SQLITE_BUSY against that lock and rejects — + // that is what made every `/mcp/toolkits/` request 500. + const scoped = await createExecutorHandle({ + activeToolkitSlug: "scoped-slug", + borrowedDb: bundle.db, + }); + + expect(scoped.executor).toBeDefined(); + await scoped.dispose(); + }); + }); + + it("leaves the shared database open when a scoped executor is disposed", async () => { + await withIsolatedExecutorDataDir(async () => { + const bundle = await getExecutorBundle(); + const scoped = await createExecutorHandle({ + activeToolkitSlug: "scoped-slug", + borrowedDb: bundle.db, + }); + + await scoped.dispose(); + + // A scoped executor borrows the bundle's open handle, so disposing one + // must close its own plugins and nothing else. `createExecutor` closes the + // database only when it was handed the owning `{ db, close }` wrapper, so + // the layer passes the inner handle (`sqlite.db`) instead. Hand it the + // wrapper and the daemon loses `/mcp` and `/api` the moment any toolkit + // session ends — the type system does not catch the swap, because + // `SqliteFumaDb` structurally satisfies `ExecutorDb`. This read is what + // catches it. + const integrations = await Effect.runPromise(bundle.executor.integrations.list()); + expect(Array.isArray(integrations)).toBe(true); + }); + }); +});