Unified · Split
| @@ -19,7 +19,7 @@ import type { McpPluginExtension } from "@executor-js/plugin-mcp"; · 18 unmodified lines | ||
| 19 | 19 | import executorConfig from "../executor.config"; |
| 20 | 20 | import { localAnalytics } from "./analytics"; |
| 21 | 21 | import { localDataMigrations } from "./db/data-migrations"; |
| 22 | −import { openOwnedLocalDatabase } from "./db/owned-database"; | |
| 22 | +import { openOwnedLocalDatabase, type OwnedLocalDatabase } from "./db/owned-database"; | |
| 23 | 23 | |
| 24 | 24 | interface ResolvedStorage { |
| 25 | 25 | readonly dataDir: string; |
| @@ -56,6 +56,16 @@ type LocalPlugins = readonly AnyPlugin[]; · 30 unmodified lines | ||
| 56 | 56 | |
| 57 | 57 | export interface LocalExecutorOptions { |
| 58 | 58 | readonly activeToolkitSlug?: string; |
| 59 | + /** | |
| 60 | + * Reuse an already-open owned database instead of opening (and locking) the | |
| 61 | + * data dir again. A toolkit-scoped MCP session differs from the default one | |
| 62 | + * only in its plugin set, so it must ride the running server's DB handle: | |
| 63 | + * `openOwnedLocalDatabase` takes an EXCLUSIVE lock, and a second open from | |
| 64 | + * inside the same process contends with the lock this process already holds. | |
| 65 | + * The borrowed handle is NOT closed when the derived executor disposes — | |
| 66 | + * whoever opened it still owns its lifetime. | |
| 67 | + */ | |
| 68 | + readonly borrowedDb?: OwnedLocalDatabase; | |
| 59 | 69 | } |
| 60 | 70 | |
| 61 | 71 | const loadLocalPlugins = (options: LocalExecutorOptions = {}) => |
| @@ -92,6 +102,10 @@ const loadLocalPlugins = (options: LocalExecutorOptions = {}) => · 30 unmodified lines | ||
| 92 | 102 | interface LocalExecutorBundle { |
| 93 | 103 | readonly executor: Executor<LocalPlugins>; |
| 94 | 104 | readonly plugins: LocalPlugins; |
| 105 | + /** The owned DB this bundle opened (or borrowed). Surfaced so a | |
| 106 | + * toolkit-scoped executor can ride the SAME handle instead of contending | |
| 107 | + * with this process's own exclusive data-dir lock. */ | |
| 108 | + readonly db: OwnedLocalDatabase; | |
| 95 | 109 | /** Where this daemon's web UI is reachable, resolved once at boot. Surfaced |
| 96 | 110 | * so callers building user-facing links (MCP artifact deep links) use the |
| 97 | 111 | * same origin the executor itself was configured with. */ |
| @@ -151,23 +165,27 @@ const createLocalExecutorLayer = (options: LocalExecutorOptions = {}) => { · 53 unmodified lines | ||
| 151 | 165 | const tenantId = makeTenantId(cwd); |
| 152 | 166 | const tables = collectTables(); |
| 153 | 167 | |
| 154 | − const owned = yield* Effect.acquireRelease( | |
| 155 | − Effect.tryPromise({ | |
| 156 | − try: () => | |
| 157 | − openOwnedLocalDatabase({ | |
| 158 | − dataDir: storage.dataDir, | |
| 159 | − tables, | |
| 160 | − namespace: localNamespace, | |
| 161 | − tenantId, | |
| 168 | + // A borrowed handle is owned by its opener, so it is used as-is and left | |
| 169 | + // open on release; only a handle opened here is closed here. | |
| 170 | + const owned = options.borrowedDb | |
| 171 | + ? options.borrowedDb | |
| 172 | + : yield* Effect.acquireRelease( | |
| 173 | + Effect.tryPromise({ | |
| 174 | + try: () => | |
| 175 | + openOwnedLocalDatabase({ | |
| 176 | + dataDir: storage.dataDir, | |
| 177 | + tables, | |
| 178 | + namespace: localNamespace, | |
| 179 | + tenantId, | |
| 180 | + }), | |
| 181 | + catch: (cause) => | |
| 182 | + new LocalExecutorCreateError({ | |
| 183 | + message: CREATE_SQLITE_ERROR_MESSAGE, | |
| 184 | + cause, | |
| 185 | + }), | |
| 162 | 186 | }), |
| 163 | − catch: (cause) => | |
| 164 | − new LocalExecutorCreateError({ | |
| 165 | − message: CREATE_SQLITE_ERROR_MESSAGE, | |
| 166 | − cause, | |
| 167 | − }), | |
| 168 | − }), | |
| 169 | − (database) => Effect.promise(() => database.close()).pipe(Effect.ignore), | |
| 170 | − ); | |
| 187 | + (database) => Effect.promise(() => database.close()).pipe(Effect.ignore), | |
| 188 | + ); | |
| 171 | 189 | const sqlite = owned.db; |
| 172 | 190 | const migration = owned.migration; |
| 173 | 191 | |
| @@ -243,7 +261,7 @@ const createLocalExecutorLayer = (options: LocalExecutorOptions = {}) => { · 69 unmodified lines | ||
| 243 | 261 | ); |
| 244 | 262 | } |
| 245 | 263 | |
| 246 | − return { executor, plugins, webBaseUrl }; | |
| 264 | + return { executor, plugins, webBaseUrl, db: owned }; | |
| 247 | 265 | }), |
| 248 | 266 | ); |
| 249 | 267 | }; |
| @@ -257,6 +275,7 @@ export const createExecutorHandle = async (options: LocalExecutorOptions = {}) = · 7 unmodified lines | ||
| 257 | 275 | executor: bundle.executor, |
| 258 | 276 | plugins: bundle.plugins, |
| 259 | 277 | webBaseUrl: bundle.webBaseUrl, |
| 278 | + db: bundle.db, | |
| 260 | 279 | dispose: async () => { |
| 261 | 280 | await Effect.runPromise(Effect.ignore(bundle.executor.close())); |
| 262 | 281 | await ignorePromiseFailure("disposeRuntime", () => runtime.dispose()); |
| @@ -136,8 +136,13 @@ export const createServerHandlers = async (token: string): Promise<ServerHandler · 135 unmodified lines | ||
| 136 | 136 | }, |
| 137 | 137 | }; |
| 138 | 138 | } |
| 139 | + // Borrow the running server's DB handle: this process already holds the | |
| 140 | + // data dir's exclusive ownership lock, so opening it a second time here | |
| 141 | + // fails against ourselves. The toolkit executor differs only in its | |
| 142 | + // plugin set, and the borrowed handle stays open when it disposes. | |
| 139 | 143 | const handle = await createExecutorHandle({ |
| 140 | 144 | activeToolkitSlug: resource.slug, |
| 145 | + borrowedDb: (await getExecutorBundle()).db, | |
| 141 | 146 | }); |
| 142 | 147 | const toolkitEngine = withExecutionAnalytics( |
| 143 | 148 | createExecutionEngine({ |
| @@ -36,8 +36,10 @@ scenario( · 35 unmodified lines | ||
| 36 | 36 | await page.goto(url, { waitUntil: "domcontentloaded" }); |
| 37 | 37 | await page.getByRole("link", { name: "Secrets" }).first().waitFor({ timeout: 30_000 }); |
| 38 | 38 | // Integrations actually LOAD (the built-in Executor integration) — proves |
| 39 | − // auth + data, not just the static shell. | |
| 40 | − await page.getByText("built-in").first().waitFor({ timeout: 30_000 }); | |
| 39 | + // auth + data, not just the static shell. Matched on the row's stable | |
| 40 | + // testid: the list renders each integration's name + slug, never the | |
| 41 | + // literal "built-in" (that string is only an internal `kind`). | |
| 42 | + await page.getByTestId("integration-entry-executor").first().waitFor({ timeout: 30_000 }); | |
| 41 | 43 | // The token is moved out of the URL and persisted to localStorage. |
| 42 | 44 | expect(new URL(page.url()).searchParams.has("_token")).toBe(false); |
| 43 | 45 | const stored = await page.evaluate(() => localStorage.getItem("executor.authToken")); |
| @@ -70,7 +72,7 @@ scenario( · 26 unmodified lines | ||
| 70 | 72 | await page.getByRole("button", { name: "Connect" }).click(); |
| 71 | 73 | await page.getByRole("link", { name: "Secrets" }).first().waitFor({ timeout: 30_000 }); |
| 72 | 74 | // The reconnect fully restores — integrations LOAD, not a stale 401. |
| 73 | − await page.getByText("built-in").first().waitFor({ timeout: 30_000 }); | |
| 75 | + await page.getByTestId("integration-entry-executor").first().waitFor({ timeout: 30_000 }); | |
| 74 | 76 | }); |
| 75 | 77 | }), |
| 76 | 78 | ); |
| @@ -21,11 +21,11 @@ import { expect } from "@effect/vitest"; · 20 unmodified lines | ||
| 21 | 21 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; |
| 22 | 22 | import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; |
| 23 | 23 | import { Effect } from "effect"; |
| 24 | +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; | |
| 24 | 25 | import { mkdtempSync, readdirSync, rmSync } from "node:fs"; |
| 25 | 26 | import { tmpdir } from "node:os"; |
| 26 | 27 | import { join } from "node:path"; |
| 27 | 28 | import { fileURLToPath } from "node:url"; |
| 28 | −import type { Subprocess } from "bun"; | |
| 29 | 29 | |
| 30 | 30 | import { scenario } from "../src/scenario"; |
| 31 | 31 | |
| @@ -34,7 +34,10 @@ const testScope = join(repoRoot, "apps/local"); · 2 unmodified lines | ||
| 34 | 34 | // Generous: a dev-mode daemon boots a Vite dev server, slow under machine load. |
| 35 | 35 | const readyTimeoutMs = 150_000; |
| 36 | 36 | |
| 37 | −type DaemonProc = Subprocess<"ignore", "pipe", "pipe">; | |
| 37 | +// vitest runs this suite under NODE, not bun, so the daemon is spawned with | |
| 38 | +// node:child_process (the rest of the e2e harness does the same). `Bun.spawn` | |
| 39 | +// here threw `ReferenceError: Bun is not defined` on every run. | |
| 40 | +type DaemonProc = ChildProcessWithoutNullStreams; | |
| 38 | 41 | |
| 39 | 42 | const waitForDaemonReady = ( |
| 40 | 43 | proc: DaemonProc, |
| @@ -44,50 +47,38 @@ const waitForDaemonReady = ( · 3 unmodified lines | ||
| 44 | 47 | let stdoutBuffer = ""; |
| 45 | 48 | let stderrBuffer = ""; |
| 46 | 49 | let settled = false; |
| 47 | − const decoder = new TextDecoder(); | |
| 48 | − const stdout = proc.stdout.getReader(); | |
| 49 | − const stderr = proc.stderr.getReader(); | |
| 50 | 50 | const deadline = setTimeout(() => { |
| 51 | 51 | if (settled) return; |
| 52 | 52 | settled = true; |
| 53 | 53 | // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: captured daemon stderr |
| 54 | 54 | rejectReady(new Error(`daemon did not announce ready: ${stderrBuffer}`)); |
| 55 | 55 | }, readyTimeoutMs); |
| 56 | − void (async () => { | |
| 57 | − while (true) { | |
| 58 | − const { value, done } = await stderr.read(); | |
| 59 | − if (done) return; | |
| 60 | − stderrBuffer += decoder.decode(value); | |
| 61 | − } | |
| 62 | − })(); | |
| 63 | − void (async () => { | |
| 64 | − while (true) { | |
| 65 | − const { value, done } = await stdout.read(); | |
| 66 | − if (done) { | |
| 67 | − if (!settled) { | |
| 68 | − settled = true; | |
| 69 | − clearTimeout(deadline); | |
| 70 | − // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: captured daemon stderr | |
| 71 | − rejectReady(new Error(`daemon stdout closed before ready: ${stderrBuffer}`)); | |
| 72 | − } | |
| 73 | − return; | |
| 74 | − } | |
| 75 | − stdoutBuffer += decoder.decode(value); | |
| 76 | − const match = /Daemon ready on http:\/\/(?:\[[^\]]+\]|[^:\s]+):(\d+)/.exec(stdoutBuffer); | |
| 77 | − if (match) { | |
| 78 | − settled = true; | |
| 79 | − clearTimeout(deadline); | |
| 80 | − resolveReady({ port: Number(match[1]), stderr: () => stderrBuffer }); | |
| 81 | − return; | |
| 82 | − } | |
| 56 | + proc.stderr.on("data", (chunk: Buffer) => { | |
| 57 | + stderrBuffer += chunk.toString(); | |
| 58 | + }); | |
| 59 | + proc.stdout.on("data", (chunk: Buffer) => { | |
| 60 | + if (settled) return; | |
| 61 | + stdoutBuffer += chunk.toString(); | |
| 62 | + const match = /Daemon ready on http:\/\/(?:\[[^\]]+\]|[^:\s]+):(\d+)/.exec(stdoutBuffer); | |
| 63 | + if (match) { | |
| 64 | + settled = true; | |
| 65 | + clearTimeout(deadline); | |
| 66 | + resolveReady({ port: Number(match[1]), stderr: () => stderrBuffer }); | |
| 83 | 67 | } |
| 84 | − })(); | |
| 68 | + }); | |
| 69 | + proc.stdout.on("close", () => { | |
| 70 | + if (settled) return; | |
| 71 | + settled = true; | |
| 72 | + clearTimeout(deadline); | |
| 73 | + // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: captured daemon stderr | |
| 74 | + rejectReady(new Error(`daemon stdout closed before ready: ${stderrBuffer}`)); | |
| 75 | + }); | |
| 85 | 76 | }); |
| 86 | 77 | |
| 87 | 78 | const spawnDaemon = (dataDir: string): DaemonProc => |
| 88 | − Bun.spawn( | |
| 79 | + spawn( | |
| 80 | + "bun", | |
| 89 | 81 | [ |
| 90 | − "bun", | |
| 91 | 82 | "run", |
| 92 | 83 | "dev:cli", |
| 93 | 84 | "daemon", |
| @@ -103,17 +94,20 @@ const spawnDaemon = (dataDir: string): DaemonProc => · 9 unmodified lines | ||
| 103 | 94 | { |
| 104 | 95 | cwd: repoRoot, |
| 105 | 96 | env: { ...process.env, EXECUTOR_DATA_DIR: dataDir }, |
| 106 | − stdin: "ignore", | |
| 107 | − stdout: "pipe", | |
| 108 | − stderr: "pipe", | |
| 97 | + stdio: ["ignore", "pipe", "pipe"], | |
| 109 | 98 | }, |
| 110 | − ); | |
| 99 | + ) as DaemonProc; | |
| 100 | + | |
| 101 | +const exited = (proc: DaemonProc): Promise<void> => | |
| 102 | + proc.exitCode !== null || proc.signalCode !== null | |
| 103 | + ? Promise.resolve() | |
| 104 | + : new Promise((resolve) => proc.once("exit", () => resolve())); | |
| 111 | 105 | |
| 112 | 106 | const stopProc = async (proc: DaemonProc): Promise<void> => { |
| 113 | − if (proc.exitCode !== null) return; | |
| 107 | + if (proc.exitCode !== null || proc.signalCode !== null) return; | |
| 114 | 108 | proc.kill("SIGTERM"); |
| 115 | − await Promise.race([proc.exited, Bun.sleep(3000)]); | |
| 116 | − if (proc.exitCode === null) proc.kill("SIGKILL"); | |
| 109 | + await Promise.race([exited(proc), new Promise<void>((resolve) => setTimeout(resolve, 3000))]); | |
| 110 | + if (proc.exitCode === null && proc.signalCode === null) proc.kill("SIGKILL"); | |
| 117 | 111 | }; |
| 118 | 112 | |
| 119 | 113 | const startForegroundDaemon = (dataDir: string) => |
| @@ -322,7 +316,12 @@ scenario( · 202 unmodified lines | ||
| 322 | 316 | ); |
| 323 | 317 | |
| 324 | 318 | daemon.proc.kill("SIGKILL"); |
| 325 | − yield* Effect.promise(() => Promise.race([daemon.proc.exited, Bun.sleep(3000)])); | |
| 319 | + yield* Effect.promise(() => | |
| 320 | + Promise.race([ | |
| 321 | + exited(daemon.proc), | |
| 322 | + new Promise<void>((resolve) => setTimeout(resolve, 3000)), | |
| 323 | + ]), | |
| 324 | + ); | |
| 326 | 325 | |
| 327 | 326 | // The next call must settle (reject) quickly — a 10s bound well under the |
| 328 | 327 | // scenario timeout catches a hang. |
| @@ -332,7 +331,7 @@ scenario( · 3 unmodified lines | ||
| 332 | 331 | .callTool({ name: "execute", arguments: { code: "return 3" } }) |
| 333 | 332 | .then(() => "resolved" as const) |
| 334 | 333 | .catch(() => "rejected" as const), |
| 335 | − Bun.sleep(10_000).then(() => "timeout" as const), | |
| 334 | + new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 10_000)), | |
| 336 | 335 | ]), |
| 337 | 336 | ); |
| 338 | 337 | // eslint-disable-next-line no-console |