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
8585 executor call 'return await tools.discover({ query: "send email" })'
8686 ```
8787
88+`executor call` and `executor resume` auto-start a local daemon if needed.
89+
8890 If an execution pauses for auth or approval, resume it:
8991
9092 ```bash
@@ -95,6 +97,10 @@ executor resume --execution-id exec_123 · 4 unmodified lines
9597
9698 ```bash
9799 executor web # start runtime + web UI
100+executor daemon run # run persistent local daemon
101+executor daemon status # show daemon status
102+executor daemon stop # stop daemon
103+executor daemon restart # restart daemon
98104 executor mcp # start MCP endpoint
99105 executor call --file script.ts # execute a file
100106 executor call '<code>' # execute inline code
apps/cli/src/daemon-state.ts+140−0
@@ -0,0 +1,140 @@
1+import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2+import { homedir } from "node:os";
3+import { join } from "node:path";
4+
5+// ---------------------------------------------------------------------------
6+// Types
7+// ---------------------------------------------------------------------------
8+
9+export 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+
22+const LOCAL_HOST_ALIASES = new Set(["localhost", "127.0.0.1", "::1", "0.0.0.0"]);
23+
24+export 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+
33+const resolveDaemonDataDir = (): string => process.env.EXECUTOR_DATA_DIR ?? join(homedir(), ".executor");
34+
35+const sanitizeHostForPath = (hostname: string): string => hostname.replaceAll(/[^a-z0-9.-]+/gi, "_");
36+
37+const 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+
46+export 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+
68+const 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+
106+export 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+
119+export 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+
128+export 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+
138+export const terminatePid = (pid: number): void => {
139+ process.kill(pid, "SIGTERM");
140+};
apps/cli/src/daemon.ts+118−0
@@ -0,0 +1,118 @@
1+import { spawn } from "node:child_process";
2+
3+// ---------------------------------------------------------------------------
4+// Types
5+// ---------------------------------------------------------------------------
6+
7+export interface ParsedDaemonBaseUrl {
8+ readonly hostname: string;
9+ readonly port: number;
10+}
11+
12+export interface DaemonSpawnSpec {
13+ readonly command: string;
14+ readonly args: ReadonlyArray<string>;
15+}
16+
17+// ---------------------------------------------------------------------------
18+// Base URL parsing
19+// ---------------------------------------------------------------------------
20+
21+export 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+
43+const LOCAL_DAEMON_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "0.0.0.0"]);
44+
45+export const canAutoStartLocalDaemonForHost = (hostname: string): boolean =>
46+ LOCAL_DAEMON_HOSTNAMES.has(hostname.toLowerCase());
47+
48+// ---------------------------------------------------------------------------
49+// Process spec
50+// ---------------------------------------------------------------------------
51+
52+export 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+
81+export 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+
94+export 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+
107+export 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 @@
11 // 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";
33 const execDir = dirname(process.execPath);
44 if (process.env.PATH && !process.env.PATH.includes(execDir)) {
55 process.env.PATH = `${execDir}:${process.env.PATH}`;
@@ -31,7 +31,6 @@ if (typeof Bun !== "undefined" && (await Bun.file(wasmOnDisk).exists())) { · 25 unmodified lines
3131 setQuickJSModule(mod);
3232 }
3333
34−import { resolve } from "node:path";
3534 import { Command, Options, Args } from "@effect/cli";
3635 import { BunRuntime } from "@effect/platform-bun";
3736 import { FetchHttpClient, HttpApiClient } from "@effect/platform";
@@ -42,6 +41,22 @@ import * as Cause from "effect/Cause"; · 4 unmodified lines
4241 import { ExecutorApi } from "@executor/api";
4342 import { startServer, runMcpStdioServer, getExecutor } from "@executor/local";
4443 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";
4560
4661 // Embedded web UI — baked into compiled binaries via `with { type: "file" }`
4762 import embeddedWebUI from "./embedded-web-ui.gen";
@@ -54,6 +69,9 @@ const CLI_NAME = "executor"; · 6 unmodified lines
5469 const { version: CLI_VERSION } = await import("../package.json");
5570 const DEFAULT_PORT = 4788;
5671 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;
5775
5876 // ---------------------------------------------------------------------------
5977 // Helpers
@@ -87,16 +105,143 @@ const script = process.argv[1]; · 27 unmodified lines
87105 const isDevMode = script?.endsWith(".ts") || script?.endsWith(".js");
88106 const cliPrefix = isDevMode ? `bun run ${script}` : "executor";
89107
90−const ensureServer = (baseUrl: string) =>
108+const ensureDaemon = (baseUrl: string) =>
91109 Effect.gen(function* () {
92110 if (yield* Effect.promise(() => isServerReachable(baseUrl))) return;
93111
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}.`);
100245 });
101246
102247 // ---------------------------------------------------------------------------
@@ -148,6 +293,43 @@ const runForegroundSession = (input: { · 45 unmodified lines
148293 yield* Effect.promise(() => server.stop());
149294 });
150295
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+
151333 // ---------------------------------------------------------------------------
152334 // Stdio MCP session
153335 // ---------------------------------------------------------------------------
@@ -200,6 +382,16 @@ const readCode = (input: { · 46 unmodified lines
200382 );
201383 });
202384
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+
203395 // ---------------------------------------------------------------------------
204396 // Commands
205397 // ---------------------------------------------------------------------------
@@ -211,11 +403,13 @@ const callCommand = Command.make( · 5 unmodified lines
211403 file: Options.text("file").pipe(Options.optional),
212404 stdin: Options.boolean("stdin").pipe(Options.withDefault(false)),
213405 baseUrl: Options.text("base-url").pipe(Options.withDefault(DEFAULT_BASE_URL)),
406+ scope,
214407 },
215− ({ code, file, stdin, baseUrl }) =>
408+ ({ code, file, stdin, baseUrl, scope }) =>
216409 Effect.gen(function* () {
410+ applyScope(scope);
217411 const resolvedCode = yield* readCode({ code, file, stdin });
218− yield* ensureServer(baseUrl);
412+ yield* ensureDaemon(baseUrl);
219413
220414 const client = yield* makeApiClient(baseUrl);
221415 const result = yield* client.executions.execute({ payload: { code: resolvedCode } });
@@ -248,10 +442,12 @@ const resumeCommand = Command.make( · 26 unmodified lines
248442 action: Options.text("action").pipe(Options.withDefault("accept")),
249443 content: Options.text("content").pipe(Options.optional),
250444 baseUrl: Options.text("base-url").pipe(Options.withDefault(DEFAULT_BASE_URL)),
445+ scope,
251446 },
252− ({ executionId, action, content, baseUrl }) =>
447+ ({ executionId, action, content, baseUrl, scope }) =>
253448 Effect.gen(function* () {
254− yield* ensureServer(baseUrl);
449+ applyScope(scope);
450+ yield* ensureDaemon(baseUrl);
255451
256452 const parsedContent = Option.getOrUndefined(content);
257453 const contentObj = parsedContent ? JSON.parse(parsedContent) : undefined;
@@ -272,16 +468,6 @@ const resumeCommand = Command.make( · 14 unmodified lines
272468 }),
273469 ).pipe(Command.withDescription("Resume a paused execution"));
274470
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−
285471 const webCommand = Command.make(
286472 "web",
287473 {
@@ -305,6 +491,107 @@ const webCommand = Command.make( · 17 unmodified lines
305491 }),
306492 ).pipe(Command.withDescription("Start a foreground web session"));
307493
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+
308595 const mcpCommand = Command.make("mcp", { scope }, ({ scope }) =>
309596 Effect.gen(function* () {
310597 applyScope(scope);
@@ -317,7 +604,7 @@ const mcpCommand = Command.make("mcp", { scope }, ({ scope }) => · 6 unmodified lines
317604 // ---------------------------------------------------------------------------
318605
319606 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),
321608 Command.withDescription("Executor local CLI"),
322609 );
323610
tests/daemon-bootstrap.test.ts+86−0
@@ -0,0 +1,86 @@
1+import { describe, expect, it } from "vitest";
2+
3+import {
4+ buildDaemonSpawnSpec,
5+ canAutoStartLocalDaemonForHost,
6+ parseDaemonBaseUrl,
7+} from "../apps/cli/src/daemon";
8+
9+describe("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 @@
1+import { mkdtempSync, rmSync } from "node:fs";
2+import { tmpdir } from "node:os";
3+import { join } from "node:path";
4+import { describe, expect, it } from "vitest";
5+
6+import {
7+ canonicalDaemonHost,
8+ isPidAlive,
9+ readDaemonRecord,
10+ removeDaemonRecord,
11+ writeDaemonRecord,
12+} from "../apps/cli/src/daemon-state";
13+
14+describe("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+});