Unified · Split
| @@ -19,7 +19,7 @@ import type { McpPluginExtension } from "@executor-js/plugin-mcp"; · 18 unmodified lines | |||
| 19 | import executorConfig from "../executor.config"; | 19 | import executorConfig from "../executor.config"; |
| 20 | import { localAnalytics } from "./analytics"; | 20 | import { localAnalytics } from "./analytics"; |
| 21 | import { localDataMigrations } from "./db/data-migrations"; | 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 | interface ResolvedStorage { | 24 | interface ResolvedStorage { |
| 25 | readonly dataDir: string; | 25 | readonly dataDir: string; |
| @@ -56,6 +56,16 @@ type LocalPlugins = readonly AnyPlugin[]; · 30 unmodified lines | |||
| 56 | 56 | ||
| 57 | export interface LocalExecutorOptions { | 57 | export interface LocalExecutorOptions { |
| 58 | readonly activeToolkitSlug?: string; | 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 | const loadLocalPlugins = (options: LocalExecutorOptions = {}) => | 71 | const loadLocalPlugins = (options: LocalExecutorOptions = {}) => |
| @@ -92,6 +102,10 @@ const loadLocalPlugins = (options: LocalExecutorOptions = {}) => · 30 unmodified lines | |||
| 92 | interface LocalExecutorBundle { | 102 | interface LocalExecutorBundle { |
| 93 | readonly executor: Executor<LocalPlugins>; | 103 | readonly executor: Executor<LocalPlugins>; |
| 94 | readonly plugins: LocalPlugins; | 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 | /** Where this daemon's web UI is reachable, resolved once at boot. Surfaced | 109 | /** Where this daemon's web UI is reachable, resolved once at boot. Surfaced |
| 96 | * so callers building user-facing links (MCP artifact deep links) use the | 110 | * so callers building user-facing links (MCP artifact deep links) use the |
| 97 | * same origin the executor itself was configured with. */ | 111 | * same origin the executor itself was configured with. */ |
| @@ -151,23 +165,27 @@ const createLocalExecutorLayer = (options: LocalExecutorOptions = {}) => { · 53 unmodified lines | |||
| 151 | const tenantId = makeTenantId(cwd); | 165 | const tenantId = makeTenantId(cwd); |
| 152 | const tables = collectTables(); | 166 | const tables = collectTables(); |
| 153 | 167 | ||
| 154 | const owned = yield* Effect.acquireRelease( | 168 | // A borrowed handle is owned by its opener, so it is used as-is and left |
| 155 | Effect.tryPromise({ | 169 | // open on release; only a handle opened here is closed here. |
| 156 | try: () => | 170 | const owned = options.borrowedDb |
| 157 | openOwnedLocalDatabase({ | 171 | ? options.borrowedDb |
| 158 | dataDir: storage.dataDir, | 172 | : yield* Effect.acquireRelease( |
| 159 | tables, | 173 | Effect.tryPromise({ |
| 160 | namespace: localNamespace, | 174 | try: () => |
| 161 | tenantId, | 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) => | 187 | (database) => Effect.promise(() => database.close()).pipe(Effect.ignore), |
| 164 | new LocalExecutorCreateError({ | 188 | ); |
| 165 | message: CREATE_SQLITE_ERROR_MESSAGE, | ||
| 166 | cause, | ||
| 167 | }), | ||
| 168 | }), | ||
| 169 | (database) => Effect.promise(() => database.close()).pipe(Effect.ignore), | ||
| 170 | ); | ||
| 171 | const sqlite = owned.db; | 189 | const sqlite = owned.db; |
| 172 | const migration = owned.migration; | 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 | executor: bundle.executor, | 275 | executor: bundle.executor, |
| 258 | plugins: bundle.plugins, | 276 | plugins: bundle.plugins, |
| 259 | webBaseUrl: bundle.webBaseUrl, | 277 | webBaseUrl: bundle.webBaseUrl, |
| 278 | db: bundle.db, | ||
| 260 | dispose: async () => { | 279 | dispose: async () => { |
| 261 | await Effect.runPromise(Effect.ignore(bundle.executor.close())); | 280 | await Effect.runPromise(Effect.ignore(bundle.executor.close())); |
| 262 | await ignorePromiseFailure("disposeRuntime", () => runtime.dispose()); | 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 | const handle = await createExecutorHandle({ | 143 | const handle = await createExecutorHandle({ |
| 140 | activeToolkitSlug: resource.slug, | 144 | activeToolkitSlug: resource.slug, |
| 145 | borrowedDb: (await getExecutorBundle()).db, | ||
| 141 | }); | 146 | }); |
| 142 | const toolkitEngine = withExecutionAnalytics( | 147 | const toolkitEngine = withExecutionAnalytics( |
| 143 | createExecutionEngine({ | 148 | createExecutionEngine({ |
| @@ -36,8 +36,10 @@ scenario( · 35 unmodified lines | |||
| 36 | await page.goto(url, { waitUntil: "domcontentloaded" }); | 36 | await page.goto(url, { waitUntil: "domcontentloaded" }); |
| 37 | await page.getByRole("link", { name: "Secrets" }).first().waitFor({ timeout: 30_000 }); | 37 | await page.getByRole("link", { name: "Secrets" }).first().waitFor({ timeout: 30_000 }); |
| 38 | // Integrations actually LOAD (the built-in Executor integration) — proves | 38 | // Integrations actually LOAD (the built-in Executor integration) — proves |
| 39 | // auth + data, not just the static shell. | 39 | // auth + data, not just the static shell. Matched on the row's stable |
| 40 | await page.getByText("built-in").first().waitFor({ timeout: 30_000 }); | 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 | // The token is moved out of the URL and persisted to localStorage. | 43 | // The token is moved out of the URL and persisted to localStorage. |
| 42 | expect(new URL(page.url()).searchParams.has("_token")).toBe(false); | 44 | expect(new URL(page.url()).searchParams.has("_token")).toBe(false); |
| 43 | const stored = await page.evaluate(() => localStorage.getItem("executor.authToken")); | 45 | const stored = await page.evaluate(() => localStorage.getItem("executor.authToken")); |
| @@ -70,7 +72,7 @@ scenario( · 26 unmodified lines | |||
| 70 | await page.getByRole("button", { name: "Connect" }).click(); | 72 | await page.getByRole("button", { name: "Connect" }).click(); |
| 71 | await page.getByRole("link", { name: "Secrets" }).first().waitFor({ timeout: 30_000 }); | 73 | await page.getByRole("link", { name: "Secrets" }).first().waitFor({ timeout: 30_000 }); |
| 72 | // The reconnect fully restores — integrations LOAD, not a stale 401. | 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 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; | 21 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; |
| 22 | import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; | 22 | import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; |
| 23 | import { Effect } from "effect"; | 23 | import { Effect } from "effect"; |
| 24 | import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; | ||
| 24 | import { mkdtempSync, readdirSync, rmSync } from "node:fs"; | 25 | import { mkdtempSync, readdirSync, rmSync } from "node:fs"; |
| 25 | import { tmpdir } from "node:os"; | 26 | import { tmpdir } from "node:os"; |
| 26 | import { join } from "node:path"; | 27 | import { join } from "node:path"; |
| 27 | import { fileURLToPath } from "node:url"; | 28 | import { fileURLToPath } from "node:url"; |
| 28 | import type { Subprocess } from "bun"; | ||
| 29 | 29 | ||
| 30 | import { scenario } from "../src/scenario"; | 30 | import { scenario } from "../src/scenario"; |
| 31 | 31 | ||
| @@ -34,7 +34,10 @@ const testScope = join(repoRoot, "apps/local"); · 2 unmodified lines | |||
| 34 | // Generous: a dev-mode daemon boots a Vite dev server, slow under machine load. | 34 | // Generous: a dev-mode daemon boots a Vite dev server, slow under machine load. |
| 35 | const readyTimeoutMs = 150_000; | 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 | const waitForDaemonReady = ( | 42 | const waitForDaemonReady = ( |
| 40 | proc: DaemonProc, | 43 | proc: DaemonProc, |
| @@ -44,50 +47,38 @@ const waitForDaemonReady = ( · 3 unmodified lines | |||
| 44 | let stdoutBuffer = ""; | 47 | let stdoutBuffer = ""; |
| 45 | let stderrBuffer = ""; | 48 | let stderrBuffer = ""; |
| 46 | let settled = false; | 49 | let settled = false; |
| 47 | const decoder = new TextDecoder(); | ||
| 48 | const stdout = proc.stdout.getReader(); | ||
| 49 | const stderr = proc.stderr.getReader(); | ||
| 50 | const deadline = setTimeout(() => { | 50 | const deadline = setTimeout(() => { |
| 51 | if (settled) return; | 51 | if (settled) return; |
| 52 | settled = true; | 52 | settled = true; |
| 53 | // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: captured daemon stderr | 53 | // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: captured daemon stderr |
| 54 | rejectReady(new Error(`daemon did not announce ready: ${stderrBuffer}`)); | 54 | rejectReady(new Error(`daemon did not announce ready: ${stderrBuffer}`)); |
| 55 | }, readyTimeoutMs); | 55 | }, readyTimeoutMs); |
| 56 | void (async () => { | 56 | proc.stderr.on("data", (chunk: Buffer) => { |
| 57 | while (true) { | 57 | stderrBuffer += chunk.toString(); |
| 58 | const { value, done } = await stderr.read(); | 58 | }); |
| 59 | if (done) return; | 59 | proc.stdout.on("data", (chunk: Buffer) => { |
| 60 | stderrBuffer += decoder.decode(value); | 60 | if (settled) return; |
| 61 | } | 61 | stdoutBuffer += chunk.toString(); |
| 62 | })(); | 62 | const match = /Daemon ready on http:\/\/(?:\[[^\]]+\]|[^:\s]+):(\d+)/.exec(stdoutBuffer); |
| 63 | void (async () => { | 63 | if (match) { |
| 64 | while (true) { | 64 | settled = true; |
| 65 | const { value, done } = await stdout.read(); | 65 | clearTimeout(deadline); |
| 66 | if (done) { | 66 | resolveReady({ port: Number(match[1]), stderr: () => stderrBuffer }); |
| 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 | } | ||
| 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 | const spawnDaemon = (dataDir: string): DaemonProc => | 78 | const spawnDaemon = (dataDir: string): DaemonProc => |
| 88 | Bun.spawn( | 79 | spawn( |
| 80 | "bun", | ||
| 89 | [ | 81 | [ |
| 90 | "bun", | ||
| 91 | "run", | 82 | "run", |
| 92 | "dev:cli", | 83 | "dev:cli", |
| 93 | "daemon", | 84 | "daemon", |
| @@ -103,17 +94,20 @@ const spawnDaemon = (dataDir: string): DaemonProc => · 9 unmodified lines | |||
| 103 | { | 94 | { |
| 104 | cwd: repoRoot, | 95 | cwd: repoRoot, |
| 105 | env: { ...process.env, EXECUTOR_DATA_DIR: dataDir }, | 96 | env: { ...process.env, EXECUTOR_DATA_DIR: dataDir }, |
| 106 | stdin: "ignore", | 97 | stdio: ["ignore", "pipe", "pipe"], |
| 107 | stdout: "pipe", | ||
| 108 | stderr: "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 | const stopProc = async (proc: DaemonProc): Promise<void> => { | 106 | const stopProc = async (proc: DaemonProc): Promise<void> => { |
| 113 | if (proc.exitCode !== null) return; | 107 | if (proc.exitCode !== null || proc.signalCode !== null) return; |
| 114 | proc.kill("SIGTERM"); | 108 | proc.kill("SIGTERM"); |
| 115 | await Promise.race([proc.exited, Bun.sleep(3000)]); | 109 | await Promise.race([exited(proc), new Promise<void>((resolve) => setTimeout(resolve, 3000))]); |
| 116 | if (proc.exitCode === null) proc.kill("SIGKILL"); | 110 | if (proc.exitCode === null && proc.signalCode === null) proc.kill("SIGKILL"); |
| 117 | }; | 111 | }; |
| 118 | 112 | ||
| 119 | const startForegroundDaemon = (dataDir: string) => | 113 | const startForegroundDaemon = (dataDir: string) => |
| @@ -322,7 +316,12 @@ scenario( · 202 unmodified lines | |||
| 322 | ); | 316 | ); |
| 323 | 317 | ||
| 324 | daemon.proc.kill("SIGKILL"); | 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 | // The next call must settle (reject) quickly — a 10s bound well under the | 326 | // The next call must settle (reject) quickly — a 10s bound well under the |
| 328 | // scenario timeout catches a hang. | 327 | // scenario timeout catches a hang. |
| @@ -332,7 +331,7 @@ scenario( · 3 unmodified lines | |||
| 332 | .callTool({ name: "execute", arguments: { code: "return 3" } }) | 331 | .callTool({ name: "execute", arguments: { code: "return 3" } }) |
| 333 | .then(() => "resolved" as const) | 332 | .then(() => "resolved" as const) |
| 334 | .catch(() => "rejected" as const), | 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 | // eslint-disable-next-line no-console | 337 | // eslint-disable-next-line no-console |