| @@ -1,5 +1,5 @@ |
| 1 | 1 | // Ensure binaries next to the executor (e.g. secure-exec-v8) are on $PATH |
| 2 | | −import { dirname, join } from "node:path"; |
| 2 | +import { dirname, join, resolve } from "node:path"; |
| 3 | 3 | const execDir = dirname(process.execPath); |
| 4 | 4 | if (process.env.PATH && !process.env.PATH.includes(execDir)) { |
| 5 | 5 | process.env.PATH = `${execDir}:${process.env.PATH}`; |
| @@ -31,7 +31,6 @@ if (typeof Bun !== "undefined" && (await Bun.file(wasmOnDisk).exists())) { · 25 unmodified lines |
| 31 | 31 | setQuickJSModule(mod); |
| 32 | 32 | } |
| 33 | 33 | |
| 34 | | −import { resolve } from "node:path"; |
| 35 | 34 | import { Command, Options, Args } from "@effect/cli"; |
| 36 | 35 | import { BunRuntime } from "@effect/platform-bun"; |
| 37 | 36 | import { FetchHttpClient, HttpApiClient } from "@effect/platform"; |
| @@ -42,6 +41,22 @@ import * as Cause from "effect/Cause"; · 4 unmodified lines |
| 42 | 41 | import { ExecutorApi } from "@executor/api"; |
| 43 | 42 | import { startServer, runMcpStdioServer, getExecutor } from "@executor/local"; |
| 44 | 43 | import { makeQuickJsExecutor } from "@executor/runtime-quickjs"; |
| 44 | +import { |
| 45 | + buildDaemonSpawnSpec, |
| 46 | + canAutoStartLocalDaemonForHost, |
| 47 | + parseDaemonBaseUrl, |
| 48 | + spawnDetached, |
| 49 | + waitForReachable, |
| 50 | + waitForUnreachable, |
| 51 | +} from "./daemon"; |
| 52 | +import { |
| 53 | + canonicalDaemonHost, |
| 54 | + isPidAlive, |
| 55 | + readDaemonRecord, |
| 56 | + removeDaemonRecord, |
| 57 | + terminatePid, |
| 58 | + writeDaemonRecord, |
| 59 | +} from "./daemon-state"; |
| 45 | 60 | |
| 46 | 61 | // Embedded web UI — baked into compiled binaries via `with { type: "file" }` |
| 47 | 62 | import embeddedWebUI from "./embedded-web-ui.gen"; |
| @@ -54,6 +69,9 @@ const CLI_NAME = "executor"; · 6 unmodified lines |
| 54 | 69 | const { version: CLI_VERSION } = await import("../package.json"); |
| 55 | 70 | const DEFAULT_PORT = 4788; |
| 56 | 71 | const DEFAULT_BASE_URL = `http://localhost:${DEFAULT_PORT}`; |
| 72 | +const DAEMON_BOOT_TIMEOUT_MS = 15_000; |
| 73 | +const DAEMON_BOOT_POLL_MS = 150; |
| 74 | +const DAEMON_STOP_TIMEOUT_MS = 10_000; |
| 57 | 75 | |
| 58 | 76 | // --------------------------------------------------------------------------- |
| 59 | 77 | // Helpers |
| @@ -87,16 +105,143 @@ const script = process.argv[1]; · 27 unmodified lines |
| 87 | 105 | const isDevMode = script?.endsWith(".ts") || script?.endsWith(".js"); |
| 88 | 106 | const cliPrefix = isDevMode ? `bun run ${script}` : "executor"; |
| 89 | 107 | |
| 90 | | −const ensureServer = (baseUrl: string) => |
| 108 | +const ensureDaemon = (baseUrl: string) => |
| 91 | 109 | Effect.gen(function* () { |
| 92 | 110 | if (yield* Effect.promise(() => isServerReachable(baseUrl))) return; |
| 93 | 111 | |
| 94 | | − // Start server in-process instead of spawning a background child. |
| 95 | | − // This is more reliable across environments (containers, sandboxes, etc.) |
| 96 | | − const url = new URL(baseUrl); |
| 97 | | − const port = Number(url.port) || DEFAULT_PORT; |
| 98 | | − console.error(`Starting server on port ${port}...`); |
| 99 | | − yield* Effect.promise(() => startServer({ port, embeddedWebUI })); |
| 112 | + const parsed = yield* Effect.try({ |
| 113 | + try: () => parseDaemonBaseUrl(baseUrl, DEFAULT_PORT), |
| 114 | + catch: (cause) => |
| 115 | + cause instanceof Error ? cause : new Error(`Invalid base URL: ${String(cause)}`), |
| 116 | + }); |
| 117 | + |
| 118 | + if (!canAutoStartLocalDaemonForHost(parsed.hostname)) { |
| 119 | + return yield* Effect.fail( |
| 120 | + new Error( |
| 121 | + [ |
| 122 | + `Executor daemon is not reachable at ${baseUrl}.`, |
| 123 | + "Auto-start is only supported for local hosts.", |
| 124 | + `Start it manually: ${cliPrefix} daemon run --port ${parsed.port} --hostname ${parsed.hostname}`, |
| 125 | + ].join("\n"), |
| 126 | + ), |
| 127 | + ); |
| 128 | + } |
| 129 | + |
| 130 | + const spec = yield* Effect.try({ |
| 131 | + try: () => |
| 132 | + buildDaemonSpawnSpec({ |
| 133 | + port: parsed.port, |
| 134 | + hostname: parsed.hostname, |
| 135 | + isDevMode, |
| 136 | + scriptPath: script, |
| 137 | + executablePath: process.execPath, |
| 138 | + }), |
| 139 | + catch: (cause) => |
| 140 | + cause instanceof Error ? cause : new Error(`Failed to build daemon command: ${String(cause)}`), |
| 141 | + }); |
| 142 | + |
| 143 | + console.error(`Starting daemon on ${parsed.hostname}:${parsed.port}...`); |
| 144 | + spawnDetached({ |
| 145 | + command: spec.command, |
| 146 | + args: spec.args, |
| 147 | + env: process.env, |
| 148 | + }); |
| 149 | + |
| 150 | + const ready = yield* Effect.promise(() => |
| 151 | + waitForReachable({ |
| 152 | + check: () => isServerReachable(baseUrl), |
| 153 | + timeoutMs: DAEMON_BOOT_TIMEOUT_MS, |
| 154 | + intervalMs: DAEMON_BOOT_POLL_MS, |
| 155 | + }), |
| 156 | + ); |
| 157 | + |
| 158 | + if (!ready) { |
| 159 | + return yield* Effect.fail( |
| 160 | + new Error( |
| 161 | + [ |
| 162 | + `Daemon did not become reachable at ${baseUrl} within ${DAEMON_BOOT_TIMEOUT_MS}ms.`, |
| 163 | + `Run in foreground to inspect logs: ${cliPrefix} daemon run --port ${parsed.port} --hostname ${parsed.hostname}`, |
| 164 | + ].join("\n"), |
| 165 | + ), |
| 166 | + ); |
| 167 | + } |
| 168 | + }); |
| 169 | + |
| 170 | +const stopDaemon = (baseUrl: string) => |
| 171 | + Effect.gen(function* () { |
| 172 | + const parsed = yield* Effect.try({ |
| 173 | + try: () => parseDaemonBaseUrl(baseUrl, DEFAULT_PORT), |
| 174 | + catch: (cause) => |
| 175 | + cause instanceof Error ? cause : new Error(`Invalid base URL: ${String(cause)}`), |
| 176 | + }); |
| 177 | + |
| 178 | + const host = canonicalDaemonHost(parsed.hostname); |
| 179 | + const record = yield* Effect.promise(() => readDaemonRecord({ hostname: host, port: parsed.port })); |
| 180 | + const reachable = yield* Effect.promise(() => isServerReachable(baseUrl)); |
| 181 | + |
| 182 | + if (!record) { |
| 183 | + if (reachable) { |
| 184 | + return yield* Effect.fail( |
| 185 | + new Error( |
| 186 | + [ |
| 187 | + `Executor is reachable at ${baseUrl} but no daemon record exists.`, |
| 188 | + "It may not be managed by this CLI process.", |
| 189 | + "Stop it from the terminal/session where it was started.", |
| 190 | + ].join("\n"), |
| 191 | + ), |
| 192 | + ); |
| 193 | + } |
| 194 | + console.log(`No daemon running at ${baseUrl}.`); |
| 195 | + return; |
| 196 | + } |
| 197 | + |
| 198 | + if (!isPidAlive(record.pid)) { |
| 199 | + yield* Effect.promise(() => removeDaemonRecord({ hostname: host, port: parsed.port })); |
| 200 | + if (reachable) { |
| 201 | + return yield* Effect.fail( |
| 202 | + new Error( |
| 203 | + [ |
| 204 | + `Daemon record for ${baseUrl} points to dead pid ${record.pid}, but endpoint is still reachable.`, |
| 205 | + "Refusing to stop an unknown process without ownership metadata.", |
| 206 | + ].join("\n"), |
| 207 | + ), |
| 208 | + ); |
| 209 | + } |
| 210 | + console.log(`No daemon running at ${baseUrl} (removed stale record for pid ${record.pid}).`); |
| 211 | + return; |
| 212 | + } |
| 213 | + |
| 214 | + console.log(`Stopping daemon at ${baseUrl} (pid ${record.pid})...`); |
| 215 | + |
| 216 | + yield* Effect.try({ |
| 217 | + try: () => terminatePid(record.pid), |
| 218 | + catch: (cause) => |
| 219 | + cause instanceof Error |
| 220 | + ? cause |
| 221 | + : new Error(`Failed sending SIGTERM to pid ${record.pid}: ${String(cause)}`), |
| 222 | + }); |
| 223 | + |
| 224 | + const stopped = yield* Effect.promise(() => |
| 225 | + waitForUnreachable({ |
| 226 | + check: () => isServerReachable(baseUrl), |
| 227 | + timeoutMs: DAEMON_STOP_TIMEOUT_MS, |
| 228 | + intervalMs: DAEMON_BOOT_POLL_MS, |
| 229 | + }), |
| 230 | + ); |
| 231 | + |
| 232 | + if (!stopped) { |
| 233 | + return yield* Effect.fail( |
| 234 | + new Error( |
| 235 | + [ |
| 236 | + `Daemon at ${baseUrl} did not stop within ${DAEMON_STOP_TIMEOUT_MS}ms.`, |
| 237 | + "Try terminating the process manually.", |
| 238 | + ].join("\n"), |
| 239 | + ), |
| 240 | + ); |
| 241 | + } |
| 242 | + |
| 243 | + yield* Effect.promise(() => removeDaemonRecord({ hostname: host, port: parsed.port })); |
| 244 | + console.log(`Daemon stopped at ${baseUrl}.`); |
| 100 | 245 | }); |
| 101 | 246 | |
| 102 | 247 | // --------------------------------------------------------------------------- |
| @@ -148,6 +293,43 @@ const runForegroundSession = (input: { · 45 unmodified lines |
| 148 | 293 | yield* Effect.promise(() => server.stop()); |
| 149 | 294 | }); |
| 150 | 295 | |
| 296 | +const runDaemonSession = (input: { |
| 297 | + port: number; |
| 298 | + hostname: string; |
| 299 | + allowedHosts: ReadonlyArray<string>; |
| 300 | +}) => |
| 301 | + Effect.gen(function* () { |
| 302 | + const server = yield* Effect.promise(() => |
| 303 | + startServer({ |
| 304 | + port: input.port, |
| 305 | + hostname: input.hostname, |
| 306 | + allowedHosts: input.allowedHosts, |
| 307 | + embeddedWebUI, |
| 308 | + }), |
| 309 | + ); |
| 310 | + |
| 311 | + const daemonHost = canonicalDaemonHost(input.hostname); |
| 312 | + const daemonPort = server.port; |
| 313 | + |
| 314 | + yield* Effect.promise(() => |
| 315 | + writeDaemonRecord({ |
| 316 | + hostname: daemonHost, |
| 317 | + port: daemonPort, |
| 318 | + pid: process.pid, |
| 319 | + scopeDir: process.env.EXECUTOR_SCOPE_DIR ?? null, |
| 320 | + }), |
| 321 | + ); |
| 322 | + |
| 323 | + console.log(`Daemon ready on http://${daemonHost}:${daemonPort}`); |
| 324 | + |
| 325 | + try { |
| 326 | + yield* waitForShutdownSignal(); |
| 327 | + } finally { |
| 328 | + yield* Effect.promise(() => server.stop()); |
| 329 | + yield* Effect.promise(() => removeDaemonRecord({ hostname: daemonHost, port: daemonPort })); |
| 330 | + } |
| 331 | + }); |
| 332 | + |
| 151 | 333 | // --------------------------------------------------------------------------- |
| 152 | 334 | // Stdio MCP session |
| 153 | 335 | // --------------------------------------------------------------------------- |
| @@ -200,6 +382,16 @@ const readCode = (input: { · 46 unmodified lines |
| 200 | 382 | ); |
| 201 | 383 | }); |
| 202 | 384 | |
| 385 | +const scope = Options.text("scope").pipe( |
| 386 | + Options.optional, |
| 387 | + Options.withDescription("Path to workspace directory containing executor.jsonc"), |
| 388 | +); |
| 389 | + |
| 390 | +const applyScope = (s: Option.Option<string>) => { |
| 391 | + const dir = Option.getOrUndefined(s); |
| 392 | + if (dir) process.env.EXECUTOR_SCOPE_DIR = resolve(dir); |
| 393 | +}; |
| 394 | + |
| 203 | 395 | // --------------------------------------------------------------------------- |
| 204 | 396 | // Commands |
| 205 | 397 | // --------------------------------------------------------------------------- |
| @@ -211,11 +403,13 @@ const callCommand = Command.make( · 5 unmodified lines |
| 211 | 403 | file: Options.text("file").pipe(Options.optional), |
| 212 | 404 | stdin: Options.boolean("stdin").pipe(Options.withDefault(false)), |
| 213 | 405 | baseUrl: Options.text("base-url").pipe(Options.withDefault(DEFAULT_BASE_URL)), |
| 406 | + scope, |
| 214 | 407 | }, |
| 215 | | − ({ code, file, stdin, baseUrl }) => |
| 408 | + ({ code, file, stdin, baseUrl, scope }) => |
| 216 | 409 | Effect.gen(function* () { |
| 410 | + applyScope(scope); |
| 217 | 411 | const resolvedCode = yield* readCode({ code, file, stdin }); |
| 218 | | − yield* ensureServer(baseUrl); |
| 412 | + yield* ensureDaemon(baseUrl); |
| 219 | 413 | |
| 220 | 414 | const client = yield* makeApiClient(baseUrl); |
| 221 | 415 | const result = yield* client.executions.execute({ payload: { code: resolvedCode } }); |
| @@ -248,10 +442,12 @@ const resumeCommand = Command.make( · 26 unmodified lines |
| 248 | 442 | action: Options.text("action").pipe(Options.withDefault("accept")), |
| 249 | 443 | content: Options.text("content").pipe(Options.optional), |
| 250 | 444 | baseUrl: Options.text("base-url").pipe(Options.withDefault(DEFAULT_BASE_URL)), |
| 445 | + scope, |
| 251 | 446 | }, |
| 252 | | − ({ executionId, action, content, baseUrl }) => |
| 447 | + ({ executionId, action, content, baseUrl, scope }) => |
| 253 | 448 | Effect.gen(function* () { |
| 254 | | − yield* ensureServer(baseUrl); |
| 449 | + applyScope(scope); |
| 450 | + yield* ensureDaemon(baseUrl); |
| 255 | 451 | |
| 256 | 452 | const parsedContent = Option.getOrUndefined(content); |
| 257 | 453 | const contentObj = parsedContent ? JSON.parse(parsedContent) : undefined; |
| @@ -272,16 +468,6 @@ const resumeCommand = Command.make( · 14 unmodified lines |
| 272 | 468 | }), |
| 273 | 469 | ).pipe(Command.withDescription("Resume a paused execution")); |
| 274 | 470 | |
| 275 | | −const scope = Options.text("scope").pipe( |
| 276 | | − Options.optional, |
| 277 | | − Options.withDescription("Path to workspace directory containing executor.jsonc"), |
| 278 | | −); |
| 279 | | − |
| 280 | | −const applyScope = (s: Option.Option<string>) => { |
| 281 | | − const dir = Option.getOrUndefined(s); |
| 282 | | − if (dir) process.env.EXECUTOR_SCOPE_DIR = resolve(dir); |
| 283 | | −}; |
| 284 | | − |
| 285 | 471 | const webCommand = Command.make( |
| 286 | 472 | "web", |
| 287 | 473 | { |
| @@ -305,6 +491,107 @@ const webCommand = Command.make( · 17 unmodified lines |
| 305 | 491 | }), |
| 306 | 492 | ).pipe(Command.withDescription("Start a foreground web session")); |
| 307 | 493 | |
| 494 | +const daemonRunCommand = Command.make( |
| 495 | + "run", |
| 496 | + { |
| 497 | + port: Options.integer("port").pipe(Options.withDefault(DEFAULT_PORT)), |
| 498 | + hostname: Options.text("hostname") |
| 499 | + .pipe(Options.withDefault("127.0.0.1")) |
| 500 | + .pipe(Options.withDescription("Bind address. Keep this local unless you trust the network.")), |
| 501 | + allowedHost: Options.text("allowed-host") |
| 502 | + .pipe(Options.repeated) |
| 503 | + .pipe( |
| 504 | + Options.withDescription( |
| 505 | + "Additional hostname permitted in the Host header (repeatable). localhost/127.0.0.1 are always allowed.", |
| 506 | + ), |
| 507 | + ), |
| 508 | + scope, |
| 509 | + }, |
| 510 | + ({ port, scope, hostname, allowedHost }) => |
| 511 | + Effect.gen(function* () { |
| 512 | + applyScope(scope); |
| 513 | + yield* runDaemonSession({ port, hostname, allowedHosts: allowedHost }); |
| 514 | + }), |
| 515 | +).pipe(Command.withDescription("Run the local executor daemon")); |
| 516 | + |
| 517 | +const daemonStatusCommand = Command.make( |
| 518 | + "status", |
| 519 | + { |
| 520 | + baseUrl: Options.text("base-url").pipe(Options.withDefault(DEFAULT_BASE_URL)), |
| 521 | + }, |
| 522 | + ({ baseUrl }) => |
| 523 | + Effect.gen(function* () { |
| 524 | + const parsed = yield* Effect.try({ |
| 525 | + try: () => parseDaemonBaseUrl(baseUrl, DEFAULT_PORT), |
| 526 | + catch: (cause) => |
| 527 | + cause instanceof Error ? cause : new Error(`Invalid base URL: ${String(cause)}`), |
| 528 | + }); |
| 529 | + const host = canonicalDaemonHost(parsed.hostname); |
| 530 | + |
| 531 | + const [record, reachable] = yield* Effect.all([ |
| 532 | + Effect.promise(() => readDaemonRecord({ hostname: host, port: parsed.port })), |
| 533 | + Effect.promise(() => isServerReachable(baseUrl)), |
| 534 | + ]); |
| 535 | + |
| 536 | + if (!record) { |
| 537 | + if (reachable) { |
| 538 | + console.log(`Daemon reachable at ${baseUrl} (no local ownership record).`); |
| 539 | + } else { |
| 540 | + console.log(`Daemon not running at ${baseUrl}.`); |
| 541 | + } |
| 542 | + return; |
| 543 | + } |
| 544 | + |
| 545 | + if (!isPidAlive(record.pid)) { |
| 546 | + if (!reachable) { |
| 547 | + yield* Effect.promise(() => removeDaemonRecord({ hostname: host, port: parsed.port })); |
| 548 | + console.log(`Daemon not running at ${baseUrl} (removed stale record for pid ${record.pid}).`); |
| 549 | + return; |
| 550 | + } |
| 551 | + console.log( |
| 552 | + `Daemon reachable at ${baseUrl}, but recorded pid ${record.pid} is not alive (ownership mismatch).`, |
| 553 | + ); |
| 554 | + return; |
| 555 | + } |
| 556 | + |
| 557 | + const state = reachable ? "running" : "unreachable"; |
| 558 | + console.log(`Daemon ${state} at ${baseUrl} (pid ${record.pid}).`); |
| 559 | + if (record.scopeDir) { |
| 560 | + console.log(`Scope: ${record.scopeDir}`); |
| 561 | + } |
| 562 | + }), |
| 563 | +).pipe(Command.withDescription("Show daemon status")); |
| 564 | + |
| 565 | +const daemonStopCommand = Command.make( |
| 566 | + "stop", |
| 567 | + { |
| 568 | + baseUrl: Options.text("base-url").pipe(Options.withDefault(DEFAULT_BASE_URL)), |
| 569 | + }, |
| 570 | + ({ baseUrl }) => stopDaemon(baseUrl), |
| 571 | +).pipe(Command.withDescription("Stop the local daemon")); |
| 572 | + |
| 573 | +const daemonRestartCommand = Command.make( |
| 574 | + "restart", |
| 575 | + { |
| 576 | + baseUrl: Options.text("base-url").pipe(Options.withDefault(DEFAULT_BASE_URL)), |
| 577 | + scope, |
| 578 | + }, |
| 579 | + ({ baseUrl, scope }) => |
| 580 | + Effect.gen(function* () { |
| 581 | + applyScope(scope); |
| 582 | + yield* stopDaemon(baseUrl); |
| 583 | + yield* ensureDaemon(baseUrl); |
| 584 | + console.log(`Daemon restarted at ${baseUrl}.`); |
| 585 | + }), |
| 586 | +).pipe(Command.withDescription("Restart the local daemon")); |
| 587 | + |
| 588 | +const daemonCommand = Command.make("daemon").pipe( |
| 589 | + Command.withSubcommands( |
| 590 | + [daemonRunCommand, daemonStatusCommand, daemonStopCommand, daemonRestartCommand] as const, |
| 591 | + ), |
| 592 | + Command.withDescription("Manage the local daemon"), |
| 593 | +); |
| 594 | + |
| 308 | 595 | const mcpCommand = Command.make("mcp", { scope }, ({ scope }) => |
| 309 | 596 | Effect.gen(function* () { |
| 310 | 597 | applyScope(scope); |
| @@ -317,7 +604,7 @@ const mcpCommand = Command.make("mcp", { scope }, ({ scope }) => · 6 unmodified lines |
| 317 | 604 | // --------------------------------------------------------------------------- |
| 318 | 605 | |
| 319 | 606 | const root = Command.make("executor").pipe( |
| 320 | | − Command.withSubcommands([callCommand, resumeCommand, webCommand, mcpCommand] as const), |
| 607 | + Command.withSubcommands([callCommand, resumeCommand, webCommand, daemonCommand, mcpCommand] as const), |
| 321 | 608 | Command.withDescription("Executor local CLI"), |
| 322 | 609 | ); |
| 323 | 610 | |