Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 52 additions & 1 deletion apps/local/src/executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>): Promise<void> => {
const previousDataDir = process.env.EXECUTOR_DATA_DIR;
Expand Down Expand Up @@ -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/<slug>` 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);
});
});
});
Loading