History

add daemon status stop restart commands

Rhys Sullivan committed 6 months ago

Commit 6cde6cc1 parent 611c3d56 files · +724 −24Browse files

Unified · Split

6 files
  1. README.md +6 −0
  2. apps/cli/src/daemon-state.ts +140 −0
  3. apps/cli/src/daemon.ts +118 −0
  4. apps/cli/src/main.ts +311 −24
  5. tests/daemon-bootstrap.test.ts +86 −0
  6. tests/daemon-state.test.ts +63 −0
README.md+6−0
@@ -85,6 +85,8 @@ executor call --file script.ts · 84 unmodified lines
85executor call 'return await tools.discover({ query: "send email" })'85executor call 'return await tools.discover({ query: "send email" })'
86```86```
87 87
88`executor call` and `executor resume` auto-start a local daemon if needed.
89
88If an execution pauses for auth or approval, resume it:90If an execution pauses for auth or approval, resume it:
89 91
90```bash92```bash
@@ -95,6 +97,10 @@ executor resume --execution-id exec_123 · 4 unmodified lines
95 97
96```bash98```bash
97executor web # start runtime + web UI99executor web # start runtime + web UI
100executor daemon run # run persistent local daemon
101executor daemon status # show daemon status
102executor daemon stop # stop daemon
103executor daemon restart # restart daemon
98executor mcp # start MCP endpoint104executor mcp # start MCP endpoint
99executor call --file script.ts # execute a file105executor call --file script.ts # execute a file
100executor call '<code>' # execute inline code106executor call '<code>' # execute inline code
apps/cli/src/daemon-state.ts+140−0
@@ -0,0 +1,140 @@
1import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2import { homedir } from "node:os";
3import { join } from "node:path";
4
5// ---------------------------------------------------------------------------
6// Types
7// ---------------------------------------------------------------------------
8
9export interface DaemonRecord {
10 readonly version: 1;
11 readonly hostname: string;
12 readonly port: number;
13 readonly pid: number;
14 readonly startedAt: string;
15 readonly scopeDir: string | null;
16}
17
18// ---------------------------------------------------------------------------
19// Host normalization
20// ---------------------------------------------------------------------------
21
22const LOCAL_HOST_ALIASES = new Set(["localhost", "127.0.0.1", "::1", "0.0.0.0"]);
23
24export const canonicalDaemonHost = (hostname: string): string => {
25 const normalized = hostname.trim().toLowerCase();
26 return LOCAL_HOST_ALIASES.has(normalized) ? "localhost" : normalized;
27};
28
29// ---------------------------------------------------------------------------
30// Paths
31// ---------------------------------------------------------------------------
32
33const resolveDaemonDataDir = (): string => process.env.EXECUTOR_DATA_DIR ?? join(homedir(), ".executor");
34
35const sanitizeHostForPath = (hostname: string): string => hostname.replaceAll(/[^a-z0-9.-]+/gi, "_");
36
37const daemonRecordPath = (input: { hostname: string; port: number }): string => {
38 const host = sanitizeHostForPath(canonicalDaemonHost(input.hostname));
39 return join(resolveDaemonDataDir(), `daemon-${host}-${input.port}.json`);
40};
41
42// ---------------------------------------------------------------------------
43// Persistence
44// ---------------------------------------------------------------------------
45
46export const writeDaemonRecord = async (input: {
47 hostname: string;
48 port: number;
49 pid: number;
50 scopeDir: string | null;
51}): Promise<void> => {
52 const path = daemonRecordPath({ hostname: input.hostname, port: input.port });
53 const dir = resolveDaemonDataDir();
54 await mkdir(dir, { recursive: true });
55
56 const payload: DaemonRecord = {
57 version: 1,
58 hostname: canonicalDaemonHost(input.hostname),
59 port: input.port,
60 pid: input.pid,
61 startedAt: new Date().toISOString(),
62 scopeDir: input.scopeDir,
63 };
64
65 await writeFile(path, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
66};
67
68const parseRecord = (raw: string): DaemonRecord | null => {
69 let parsed: unknown;
70 try {
71 parsed = JSON.parse(raw);
72 } catch {
73 return null;
74 }
75
76 if (
77 typeof parsed !== "object" ||
78 parsed === null ||
79 !("version" in parsed) ||
80 (parsed as { version?: unknown }).version !== 1
81 ) {
82 return null;
83 }
84
85 const r = parsed as Record<string, unknown>;
86 if (
87 typeof r.hostname !== "string" ||
88 typeof r.port !== "number" ||
89 typeof r.pid !== "number" ||
90 typeof r.startedAt !== "string" ||
91 !(typeof r.scopeDir === "string" || r.scopeDir === null)
92 ) {
93 return null;
94 }
95
96 return {
97 version: 1,
98 hostname: canonicalDaemonHost(r.hostname),
99 port: r.port,
100 pid: r.pid,
101 startedAt: r.startedAt,
102 scopeDir: r.scopeDir,
103 };
104};
105
106export const readDaemonRecord = async (input: {
107 hostname: string;
108 port: number;
109}): Promise<DaemonRecord | null> => {
110 const path = daemonRecordPath({ hostname: input.hostname, port: input.port });
111 try {
112 const raw = await readFile(path, "utf8");
113 return parseRecord(raw);
114 } catch {
115 return null;
116 }
117};
118
119export const removeDaemonRecord = async (input: { hostname: string; port: number }): Promise<void> => {
120 const path = daemonRecordPath({ hostname: input.hostname, port: input.port });
121 await rm(path, { force: true });
122};
123
124// ---------------------------------------------------------------------------
125// Process helpers
126// ---------------------------------------------------------------------------
127
128export const isPidAlive = (pid: number): boolean => {
129 if (!Number.isInteger(pid) || pid <= 0) return false;
130 try {
131 process.kill(pid, 0);
132 return true;
133 } catch {
134 return false;
135 }
136};
137
138export const terminatePid = (pid: number): void => {
139 process.kill(pid, "SIGTERM");
140};
apps/cli/src/daemon.ts+118−0
@@ -0,0 +1,118 @@
1import { spawn } from "node:child_process";
2
3// ---------------------------------------------------------------------------
4// Types
5// ---------------------------------------------------------------------------
6
7export interface ParsedDaemonBaseUrl {
8 readonly hostname: string;
9 readonly port: number;
10}
11
12export interface DaemonSpawnSpec {
13 readonly command: string;
14 readonly args: ReadonlyArray<string>;
15}
16
17// ---------------------------------------------------------------------------
18// Base URL parsing
19// ---------------------------------------------------------------------------
20
21export const parseDaemonBaseUrl = (baseUrl: string, defaultPort: number): ParsedDaemonBaseUrl => {
22 const parsed = new URL(baseUrl);
23
24 if (parsed.protocol !== "http:") {
25 throw new Error(`Only http:// base URLs are supported for daemon auto-start: ${baseUrl}`);
26 }
27
28 const port = Number(parsed.port) || defaultPort;
29 if (!Number.isFinite(port) || port <= 0 || port > 65535) {
30 throw new Error(`Invalid daemon port in base URL: ${baseUrl}`);
31 }
32
33 return {
34 hostname: parsed.hostname || "localhost",
35 port,
36 };
37};
38
39// ---------------------------------------------------------------------------
40// Local-host checks
41// ---------------------------------------------------------------------------
42
43const LOCAL_DAEMON_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "0.0.0.0"]);
44
45export const canAutoStartLocalDaemonForHost = (hostname: string): boolean =>
46 LOCAL_DAEMON_HOSTNAMES.has(hostname.toLowerCase());
47
48// ---------------------------------------------------------------------------
49// Process spec
50// ---------------------------------------------------------------------------
51
52export const buildDaemonSpawnSpec = (input: {
53 readonly port: number;
54 readonly hostname: string;
55 readonly isDevMode: boolean;
56 readonly scriptPath: string | undefined;
57 readonly executablePath: string;
58}): DaemonSpawnSpec => {
59 const daemonArgs = ["daemon", "run", "--port", String(input.port), "--hostname", input.hostname];
60
61 if (input.isDevMode) {
62 if (!input.scriptPath) {
63 throw new Error("Cannot auto-start daemon in dev mode without a CLI script path");
64 }
65 return {
66 command: "bun",
67 args: ["run", input.scriptPath, ...daemonArgs],
68 };
69 }
70
71 return {
72 command: input.executablePath,
73 args: daemonArgs,
74 };
75};
76
77// ---------------------------------------------------------------------------
78// Spawn + wait
79// ---------------------------------------------------------------------------
80
81export const spawnDetached = (input: {
82 readonly command: string;
83 readonly args: ReadonlyArray<string>;
84 readonly env: Record<string, string | undefined>;
85}): void => {
86 const child = spawn(input.command, [...input.args], {
87 detached: true,
88 stdio: "ignore",
89 env: input.env,
90 });
91 child.unref();
92};
93
94export const waitForReachable = async (input: {
95 readonly check: () => Promise<boolean>;
96 readonly timeoutMs: number;
97 readonly intervalMs: number;
98}): Promise<boolean> => {
99 const deadline = Date.now() + input.timeoutMs;
100 while (Date.now() < deadline) {
101 if (await input.check()) return true;
102 await new Promise((resolve) => setTimeout(resolve, input.intervalMs));
103 }
104 return false;
105};
106
107export const waitForUnreachable = async (input: {
108 readonly check: () => Promise<boolean>;
109 readonly timeoutMs: number;
110 readonly intervalMs: number;
111}): Promise<boolean> => {
112 const deadline = Date.now() + input.timeoutMs;
113 while (Date.now() < deadline) {
114 if (!(await input.check())) return true;
115 await new Promise((resolve) => setTimeout(resolve, input.intervalMs));
116 }
117 return false;
118};
apps/cli/src/main.ts+311−24
@@ -1,5 +1,5 @@
1// Ensure binaries next to the executor (e.g. secure-exec-v8) are on $PATH1// Ensure binaries next to the executor (e.g. secure-exec-v8) are on $PATH
2import { dirname, join } from "node:path";2import { dirname, join, resolve } from "node:path";
3const execDir = dirname(process.execPath);3const execDir = dirname(process.execPath);
4if (process.env.PATH && !process.env.PATH.includes(execDir)) {4if (process.env.PATH && !process.env.PATH.includes(execDir)) {
5 process.env.PATH = `${execDir}:${process.env.PATH}`;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 setQuickJSModule(mod);31 setQuickJSModule(mod);
32}32}
33 33
34import { resolve } from "node:path";
35import { Command, Options, Args } from "@effect/cli";34import { Command, Options, Args } from "@effect/cli";
36import { BunRuntime } from "@effect/platform-bun";35import { BunRuntime } from "@effect/platform-bun";
37import { FetchHttpClient, HttpApiClient } from "@effect/platform";36import { FetchHttpClient, HttpApiClient } from "@effect/platform";
@@ -42,6 +41,22 @@ import * as Cause from "effect/Cause"; · 4 unmodified lines
42import { ExecutorApi } from "@executor/api";41import { ExecutorApi } from "@executor/api";
43import { startServer, runMcpStdioServer, getExecutor } from "@executor/local";42import { startServer, runMcpStdioServer, getExecutor } from "@executor/local";
44import { makeQuickJsExecutor } from "@executor/runtime-quickjs";43import { makeQuickJsExecutor } from "@executor/runtime-quickjs";
44import {
45 buildDaemonSpawnSpec,
46 canAutoStartLocalDaemonForHost,
47 parseDaemonBaseUrl,
48 spawnDetached,
49 waitForReachable,
50 waitForUnreachable,
51} from "./daemon";
52import {
53 canonicalDaemonHost,
54 isPidAlive,
55 readDaemonRecord,
56 removeDaemonRecord,
57 terminatePid,
58 writeDaemonRecord,
59} from "./daemon-state";
45 60
46// Embedded web UI — baked into compiled binaries via `with { type: "file" }`61// Embedded web UI — baked into compiled binaries via `with { type: "file" }`
47import embeddedWebUI from "./embedded-web-ui.gen";62import embeddedWebUI from "./embedded-web-ui.gen";
@@ -54,6 +69,9 @@ const CLI_NAME = "executor"; · 6 unmodified lines
54const { version: CLI_VERSION } = await import("../package.json");69const { version: CLI_VERSION } = await import("../package.json");
55const DEFAULT_PORT = 4788;70const DEFAULT_PORT = 4788;
56const DEFAULT_BASE_URL = `http://localhost:${DEFAULT_PORT}`;71const DEFAULT_BASE_URL = `http://localhost:${DEFAULT_PORT}`;
72const DAEMON_BOOT_TIMEOUT_MS = 15_000;
73const DAEMON_BOOT_POLL_MS = 150;
74const DAEMON_STOP_TIMEOUT_MS = 10_000;
57 75
58// ---------------------------------------------------------------------------76// ---------------------------------------------------------------------------
59// Helpers77// Helpers
@@ -87,16 +105,143 @@ const script = process.argv[1]; · 27 unmodified lines
87const isDevMode = script?.endsWith(".ts") || script?.endsWith(".js");105const isDevMode = script?.endsWith(".ts") || script?.endsWith(".js");
88const cliPrefix = isDevMode ? `bun run ${script}` : "executor";106const cliPrefix = isDevMode ? `bun run ${script}` : "executor";
89 107
90const ensureServer = (baseUrl: string) =>108const ensureDaemon = (baseUrl: string) =>
91 Effect.gen(function* () {109 Effect.gen(function* () {
92 if (yield* Effect.promise(() => isServerReachable(baseUrl))) return;110 if (yield* Effect.promise(() => isServerReachable(baseUrl))) return;
93 111
94 // Start server in-process instead of spawning a background child.112 const parsed = yield* Effect.try({
95 // This is more reliable across environments (containers, sandboxes, etc.)113 try: () => parseDaemonBaseUrl(baseUrl, DEFAULT_PORT),
96 const url = new URL(baseUrl);114 catch: (cause) =>
97 const port = Number(url.port) || DEFAULT_PORT;115 cause instanceof Error ? cause : new Error(`Invalid base URL: ${String(cause)}`),
98 console.error(`Starting server on port ${port}...`);116 });
99 yield* Effect.promise(() => startServer({ port, embeddedWebUI }));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
170const 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 yield* Effect.promise(() => server.stop());293 yield* Effect.promise(() => server.stop());
149 });294 });
150 295
296const 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// Stdio MCP session334// Stdio MCP session
153// ---------------------------------------------------------------------------335// ---------------------------------------------------------------------------
@@ -200,6 +382,16 @@ const readCode = (input: { · 46 unmodified lines
200 );382 );
201 });383 });
202 384
385const scope = Options.text("scope").pipe(
386 Options.optional,
387 Options.withDescription("Path to workspace directory containing executor.jsonc"),
388);
389
390const 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// Commands396// Commands
205// ---------------------------------------------------------------------------397// ---------------------------------------------------------------------------
@@ -211,11 +403,13 @@ const callCommand = Command.make( · 5 unmodified lines
211 file: Options.text("file").pipe(Options.optional),403 file: Options.text("file").pipe(Options.optional),
212 stdin: Options.boolean("stdin").pipe(Options.withDefault(false)),404 stdin: Options.boolean("stdin").pipe(Options.withDefault(false)),
213 baseUrl: Options.text("base-url").pipe(Options.withDefault(DEFAULT_BASE_URL)),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 Effect.gen(function* () {409 Effect.gen(function* () {
410 applyScope(scope);
217 const resolvedCode = yield* readCode({ code, file, stdin });411 const resolvedCode = yield* readCode({ code, file, stdin });
218 yield* ensureServer(baseUrl);412 yield* ensureDaemon(baseUrl);
219 413
220 const client = yield* makeApiClient(baseUrl);414 const client = yield* makeApiClient(baseUrl);
221 const result = yield* client.executions.execute({ payload: { code: resolvedCode } });415 const result = yield* client.executions.execute({ payload: { code: resolvedCode } });
@@ -248,10 +442,12 @@ const resumeCommand = Command.make( · 26 unmodified lines
248 action: Options.text("action").pipe(Options.withDefault("accept")),442 action: Options.text("action").pipe(Options.withDefault("accept")),
249 content: Options.text("content").pipe(Options.optional),443 content: Options.text("content").pipe(Options.optional),
250 baseUrl: Options.text("base-url").pipe(Options.withDefault(DEFAULT_BASE_URL)),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 Effect.gen(function* () {448 Effect.gen(function* () {
254 yield* ensureServer(baseUrl);449 applyScope(scope);
450 yield* ensureDaemon(baseUrl);
255 451
256 const parsedContent = Option.getOrUndefined(content);452 const parsedContent = Option.getOrUndefined(content);
257 const contentObj = parsedContent ? JSON.parse(parsedContent) : undefined;453 const contentObj = parsedContent ? JSON.parse(parsedContent) : undefined;
@@ -272,16 +468,6 @@ const resumeCommand = Command.make( · 14 unmodified lines
272 }),468 }),
273).pipe(Command.withDescription("Resume a paused execution"));469).pipe(Command.withDescription("Resume a paused execution"));
274 470
275const scope = Options.text("scope").pipe(
276 Options.optional,
277 Options.withDescription("Path to workspace directory containing executor.jsonc"),
278);
279
280const applyScope = (s: Option.Option<string>) => {
281 const dir = Option.getOrUndefined(s);
282 if (dir) process.env.EXECUTOR_SCOPE_DIR = resolve(dir);
283};
284
285const webCommand = Command.make(471const webCommand = Command.make(
286 "web",472 "web",
287 {473 {
@@ -305,6 +491,107 @@ const webCommand = Command.make( · 17 unmodified lines
305 }),491 }),
306).pipe(Command.withDescription("Start a foreground web session"));492).pipe(Command.withDescription("Start a foreground web session"));
307 493
494const 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
517const 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
565const 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
573const 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
588const 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
308const mcpCommand = Command.make("mcp", { scope }, ({ scope }) =>595const mcpCommand = Command.make("mcp", { scope }, ({ scope }) =>
309 Effect.gen(function* () {596 Effect.gen(function* () {
310 applyScope(scope);597 applyScope(scope);
@@ -317,7 +604,7 @@ const mcpCommand = Command.make("mcp", { scope }, ({ scope }) => · 6 unmodified lines
317// ---------------------------------------------------------------------------604// ---------------------------------------------------------------------------
318 605
319const root = Command.make("executor").pipe(606const 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 Command.withDescription("Executor local CLI"),608 Command.withDescription("Executor local CLI"),
322);609);
323 610
tests/daemon-bootstrap.test.ts+86−0
@@ -0,0 +1,86 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 buildDaemonSpawnSpec,
5 canAutoStartLocalDaemonForHost,
6 parseDaemonBaseUrl,
7} from "../apps/cli/src/daemon";
8
9describe("daemon bootstrap helpers", () => {
10 it("parses default port when none is provided", () => {
11 const parsed = parseDaemonBaseUrl("http://localhost", 4788);
12 expect(parsed).toEqual({ hostname: "localhost", port: 4788 });
13 });
14
15 it("parses explicit port from base url", () => {
16 const parsed = parseDaemonBaseUrl("http://127.0.0.1:9001", 4788);
17 expect(parsed).toEqual({ hostname: "127.0.0.1", port: 9001 });
18 });
19
20 it("rejects non-http schemes for auto-start", () => {
21 expect(() => parseDaemonBaseUrl("https://localhost:4788", 4788)).toThrow(
22 "Only http:// base URLs are supported",
23 );
24 });
25
26 it("only auto-starts for local hosts", () => {
27 expect(canAutoStartLocalDaemonForHost("localhost")).toBe(true);
28 expect(canAutoStartLocalDaemonForHost("127.0.0.1")).toBe(true);
29 expect(canAutoStartLocalDaemonForHost("::1")).toBe(true);
30 expect(canAutoStartLocalDaemonForHost("api.example.com")).toBe(false);
31 });
32
33 it("builds bun-run spec in dev mode", () => {
34 const spec = buildDaemonSpawnSpec({
35 port: 4788,
36 hostname: "localhost",
37 isDevMode: true,
38 scriptPath: "/repo/apps/cli/src/main.ts",
39 executablePath: "/ignored",
40 });
41
42 expect(spec.command).toBe("bun");
43 expect(spec.args).toEqual([
44 "run",
45 "/repo/apps/cli/src/main.ts",
46 "daemon",
47 "run",
48 "--port",
49 "4788",
50 "--hostname",
51 "localhost",
52 ]);
53 });
54
55 it("builds executable spec outside dev mode", () => {
56 const spec = buildDaemonSpawnSpec({
57 port: 5000,
58 hostname: "127.0.0.1",
59 isDevMode: false,
60 scriptPath: undefined,
61 executablePath: "/usr/local/bin/executor",
62 });
63
64 expect(spec.command).toBe("/usr/local/bin/executor");
65 expect(spec.args).toEqual([
66 "daemon",
67 "run",
68 "--port",
69 "5000",
70 "--hostname",
71 "127.0.0.1",
72 ]);
73 });
74
75 it("fails in dev mode when script path is missing", () => {
76 expect(() =>
77 buildDaemonSpawnSpec({
78 port: 4788,
79 hostname: "localhost",
80 isDevMode: true,
81 scriptPath: undefined,
82 executablePath: "/usr/local/bin/executor",
83 }),
84 ).toThrow("Cannot auto-start daemon in dev mode");
85 });
86});
tests/daemon-state.test.ts+63−0
@@ -0,0 +1,63 @@
1import { mkdtempSync, rmSync } from "node:fs";
2import { tmpdir } from "node:os";
3import { join } from "node:path";
4import { describe, expect, it } from "vitest";
5
6import {
7 canonicalDaemonHost,
8 isPidAlive,
9 readDaemonRecord,
10 removeDaemonRecord,
11 writeDaemonRecord,
12} from "../apps/cli/src/daemon-state";
13
14describe("daemon state", () => {
15 it("normalizes local host aliases", () => {
16 expect(canonicalDaemonHost("localhost")).toBe("localhost");
17 expect(canonicalDaemonHost("127.0.0.1")).toBe("localhost");
18 expect(canonicalDaemonHost("::1")).toBe("localhost");
19 expect(canonicalDaemonHost("0.0.0.0")).toBe("localhost");
20 expect(canonicalDaemonHost("api.example.com")).toBe("api.example.com");
21 });
22
23 it("writes, reads, and removes daemon records", async () => {
24 const prev = process.env.EXECUTOR_DATA_DIR;
25 const dir = mkdtempSync(join(tmpdir(), "executor-daemon-state-test-"));
26 process.env.EXECUTOR_DATA_DIR = dir;
27
28 try {
29 await writeDaemonRecord({
30 hostname: "127.0.0.1",
31 port: 4788,
32 pid: 12345,
33 scopeDir: "/tmp/scope",
34 });
35
36 const stored = await readDaemonRecord({ hostname: "localhost", port: 4788 });
37 expect(stored).toEqual({
38 version: 1,
39 hostname: "localhost",
40 port: 4788,
41 pid: 12345,
42 startedAt: expect.any(String),
43 scopeDir: "/tmp/scope",
44 });
45
46 await removeDaemonRecord({ hostname: "localhost", port: 4788 });
47 const after = await readDaemonRecord({ hostname: "localhost", port: 4788 });
48 expect(after).toBeNull();
49 } finally {
50 if (prev === undefined) {
51 delete process.env.EXECUTOR_DATA_DIR;
52 } else {
53 process.env.EXECUTOR_DATA_DIR = prev;
54 }
55 rmSync(dir, { recursive: true, force: true });
56 }
57 });
58
59 it("detects live and invalid pids", () => {
60 expect(isPidAlive(process.pid)).toBe(true);
61 expect(isPidAlive(-1)).toBe(false);
62 });
63});