History

add scope-aware daemon pointer and automatic port fallback

Rhys Sullivan committed 6 months ago

Commit 0adad791 parent 25151836 files · +636 −94Browse files

Unified · Split

6 files
  1. README.md +1 −0
  2. apps/cli/src/daemon-state.ts +219 −0
  3. apps/cli/src/daemon.ts +80 −0
  4. apps/cli/src/main.ts +220 −92
  5. tests/daemon-bootstrap.test.ts +28 −0
  6. tests/daemon-state.test.ts +88 −2
README.md+1−0
@@ -89,6 +89,7 @@ executor tools invoke gmail.send --input '{"to":"alice@example.com","subject":"H · 88 unmodified lines
89```89```
90 90
91`executor call`, `executor resume`, and `executor tools ...` commands auto-start a local daemon if needed.91`executor call`, `executor resume`, and `executor tools ...` commands auto-start a local daemon if needed.
92If the default port is busy, the CLI will pick an available local port and track it automatically.
92 93
93If an execution pauses for auth or approval, resume it:94If an execution pauses for auth or approval, resume it:
94 95
apps/cli/src/daemon-state.ts+219−0
@@ -16,6 +16,23 @@ export interface DaemonRecord { · 15 unmodified lines
16 readonly scopeDir: string | null;16 readonly scopeDir: string | null;
17}17}
18 18
19export interface DaemonPointer {
20 readonly version: 1;
21 readonly hostname: string;
22 readonly port: number;
23 readonly pid: number;
24 readonly startedAt: string;
25 readonly scopeId: string;
26 readonly scopeDir: string | null;
27 readonly token: string;
28}
29
30export interface DaemonStartLock {
31 readonly path: string;
32 readonly hostname: string;
33 readonly scopeId: string;
34}
35
19// ---------------------------------------------------------------------------36// ---------------------------------------------------------------------------
20// Host normalization37// Host normalization
21// ---------------------------------------------------------------------------38// ---------------------------------------------------------------------------
@@ -27,6 +44,14 @@ export const canonicalDaemonHost = (hostname: string): string => { · 5 unmodified lines
27 return LOCAL_HOST_ALIASES.has(normalized) ? "localhost" : normalized;44 return LOCAL_HOST_ALIASES.has(normalized) ? "localhost" : normalized;
28};45};
29 46
47export const currentDaemonScopeId = (): string => {
48 const explicitScope = process.env.EXECUTOR_SCOPE_DIR?.trim();
49 if (explicitScope && explicitScope.length > 0) {
50 return `scope:${explicitScope}`;
51 }
52 return `cwd:${process.cwd()}`;
53};
54
30// ---------------------------------------------------------------------------55// ---------------------------------------------------------------------------
31// Paths56// Paths
32// ---------------------------------------------------------------------------57// ---------------------------------------------------------------------------
@@ -35,12 +60,22 @@ const resolveDaemonDataDir = (path: Path.Path): string => · 2 unmodified lines
35 process.env.EXECUTOR_DATA_DIR ?? path.join(homedir(), ".executor");60 process.env.EXECUTOR_DATA_DIR ?? path.join(homedir(), ".executor");
36 61
37const sanitizeHostForPath = (hostname: string): string => hostname.replaceAll(/[^a-z0-9.-]+/gi, "_");62const sanitizeHostForPath = (hostname: string): string => hostname.replaceAll(/[^a-z0-9.-]+/gi, "_");
63const sanitizeScopeForPath = (scopeId: string): string => scopeId.replaceAll(/[^a-z0-9.-]+/gi, "_");
38 64
39const daemonRecordPath = (path: Path.Path, input: { hostname: string; port: number }): string => {65const daemonRecordPath = (path: Path.Path, input: { hostname: string; port: number }): string => {
40 const host = sanitizeHostForPath(canonicalDaemonHost(input.hostname));66 const host = sanitizeHostForPath(canonicalDaemonHost(input.hostname));
41 return path.join(resolveDaemonDataDir(path), `daemon-${host}-${input.port}.json`);67 return path.join(resolveDaemonDataDir(path), `daemon-${host}-${input.port}.json`);
42};68};
43 69
70const daemonPointerPath = (path: Path.Path, input: { hostname: string; scopeId: string }): string => {
71 const host = sanitizeHostForPath(canonicalDaemonHost(input.hostname));
72 const scope = sanitizeScopeForPath(input.scopeId);
73 return path.join(resolveDaemonDataDir(path), `daemon-active-${host}-${scope}.json`);
74};
75
76const daemonStartLockPath = (path: Path.Path, input: { hostname: string; scopeId: string }): string =>
77 `${daemonPointerPath(path, input)}.lock`;
78
44// ---------------------------------------------------------------------------79// ---------------------------------------------------------------------------
45// Persistence80// Persistence
46// ---------------------------------------------------------------------------81// ---------------------------------------------------------------------------
@@ -111,6 +146,48 @@ const parseRecord = (raw: string): DaemonRecord | null => { · 64 unmodified lines
111 };146 };
112};147};
113 148
149const parsePointer = (raw: string): DaemonPointer | null => {
150 let parsed: unknown;
151 try {
152 parsed = JSON.parse(raw);
153 } catch {
154 return null;
155 }
156
157 if (
158 typeof parsed !== "object" ||
159 parsed === null ||
160 !("version" in parsed) ||
161 (parsed as { version?: unknown }).version !== 1
162 ) {
163 return null;
164 }
165
166 const r = parsed as Record<string, unknown>;
167 if (
168 typeof r.hostname !== "string" ||
169 typeof r.port !== "number" ||
170 typeof r.pid !== "number" ||
171 typeof r.startedAt !== "string" ||
172 typeof r.scopeId !== "string" ||
173 !(typeof r.scopeDir === "string" || r.scopeDir === null) ||
174 typeof r.token !== "string"
175 ) {
176 return null;
177 }
178
179 return {
180 version: 1,
181 hostname: canonicalDaemonHost(r.hostname),
182 port: r.port,
183 pid: r.pid,
184 startedAt: r.startedAt,
185 scopeId: r.scopeId,
186 scopeDir: r.scopeDir,
187 token: r.token,
188 };
189};
190
114export const readDaemonRecord = (input: {191export const readDaemonRecord = (input: {
115 hostname: string;192 hostname: string;
116 port: number;193 port: number;
@@ -135,6 +212,148 @@ export const removeDaemonRecord = (input: { · 18 unmodified lines
135 yield* fs.remove(daemonRecordPath(path, input), { force: true });212 yield* fs.remove(daemonRecordPath(path, input), { force: true });
136 });213 });
137 214
215export const writeDaemonPointer = (input: {
216 hostname: string;
217 port: number;
218 pid: number;
219 scopeId: string;
220 scopeDir: string | null;
221 token: string;
222}): Effect.Effect<void, PlatformError, FileSystem.FileSystem | Path.Path> =>
223 Effect.gen(function* () {
224 const fs = yield* FileSystem.FileSystem;
225 const path = yield* Path.Path;
226 const dataDir = resolveDaemonDataDir(path);
227 yield* fs.makeDirectory(dataDir, { recursive: true });
228
229 const payload: DaemonPointer = {
230 version: 1,
231 hostname: canonicalDaemonHost(input.hostname),
232 port: input.port,
233 pid: input.pid,
234 startedAt: new Date().toISOString(),
235 scopeId: input.scopeId,
236 scopeDir: input.scopeDir,
237 token: input.token,
238 };
239
240 yield* fs.writeFileString(
241 daemonPointerPath(path, { hostname: input.hostname, scopeId: input.scopeId }),
242 `${JSON.stringify(payload, null, 2)}\n`,
243 );
244 });
245
246export const readDaemonPointer = (input: {
247 hostname: string;
248 scopeId: string;
249}): Effect.Effect<DaemonPointer | null, never, FileSystem.FileSystem | Path.Path> =>
250 Effect.gen(function* () {
251 const fs = yield* FileSystem.FileSystem;
252 const path = yield* Path.Path;
253 const raw = yield* fs
254 .readFileString(daemonPointerPath(path, input))
255 .pipe(Effect.catchAll(() => Effect.succeed(null)));
256 if (raw === null) return null;
257 return parsePointer(raw);
258 });
259
260export const removeDaemonPointer = (input: {
261 hostname: string;
262 scopeId: string;
263}): Effect.Effect<void, PlatformError, FileSystem.FileSystem | Path.Path> =>
264 Effect.gen(function* () {
265 const fs = yield* FileSystem.FileSystem;
266 const path = yield* Path.Path;
267 yield* fs.remove(daemonPointerPath(path, input), { force: true });
268 });
269
270const parseLockPid = (raw: string): number | null => {
271 let parsed: unknown;
272 try {
273 parsed = JSON.parse(raw);
274 } catch {
275 return null;
276 }
277
278 if (
279 typeof parsed !== "object" ||
280 parsed === null ||
281 typeof (parsed as Record<string, unknown>).pid !== "number"
282 ) {
283 return null;
284 }
285
286 return (parsed as Record<string, number>).pid;
287};
288
289export const acquireDaemonStartLock = (input: {
290 hostname: string;
291 scopeId: string;
292}): Effect.Effect<DaemonStartLock, Error, FileSystem.FileSystem | Path.Path> =>
293 Effect.gen(function* () {
294 const fs = yield* FileSystem.FileSystem;
295 const path = yield* Path.Path;
296 const dataDir = resolveDaemonDataDir(path);
297 yield* fs.makeDirectory(dataDir, { recursive: true });
298
299 const lockPath = daemonStartLockPath(path, input);
300 const lockPayload = JSON.stringify(
301 {
302 pid: process.pid,
303 hostname: canonicalDaemonHost(input.hostname),
304 scopeId: input.scopeId,
305 startedAt: new Date().toISOString(),
306 },
307 null,
308 2,
309 );
310
311 const tryAcquire = () =>
312 fs.writeFileString(lockPath, `${lockPayload}\n`, { flag: "wx" }).pipe(
313 Effect.as(true),
314 Effect.catchAll(() => Effect.succeed(false)),
315 );
316
317 if (yield* tryAcquire()) {
318 return {
319 path: lockPath,
320 hostname: canonicalDaemonHost(input.hostname),
321 scopeId: input.scopeId,
322 };
323 }
324
325 const existingRaw = yield* fs.readFileString(lockPath).pipe(Effect.catchAll(() => Effect.succeed(null)));
326 if (existingRaw !== null) {
327 const existingPid = parseLockPid(existingRaw);
328 if (existingPid !== null && !isPidAlive(existingPid)) {
329 yield* fs.remove(lockPath, { force: true });
330 if (yield* tryAcquire()) {
331 return {
332 path: lockPath,
333 hostname: canonicalDaemonHost(input.hostname),
334 scopeId: input.scopeId,
335 };
336 }
337 }
338 }
339
340 return yield* Effect.fail(
341 new Error(
342 `Another daemon startup is already in progress for ${canonicalDaemonHost(input.hostname)} (${input.scopeId}).`,
343 ),
344 );
345 });
346
347export const releaseDaemonStartLock = (input: DaemonStartLock): Effect.Effect<
348 void,
349 PlatformError,
350 FileSystem.FileSystem | Path.Path
351> =>
352 Effect.gen(function* () {
353 const fs = yield* FileSystem.FileSystem;
354 yield* fs.remove(input.path, { force: true });
355 });
356
138// ---------------------------------------------------------------------------357// ---------------------------------------------------------------------------
139// Process helpers358// Process helpers
140// ---------------------------------------------------------------------------359// ---------------------------------------------------------------------------
apps/cli/src/daemon.ts+80−0
@@ -1,4 +1,5 @@
1import { spawn } from "node:child_process";1import { spawn } from "node:child_process";
2import { createServer } from "node:net";
2import * as Clock from "effect/Clock";3import * as Clock from "effect/Clock";
3import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
4 5
@@ -142,3 +143,82 @@ export const waitForUnreachable = <E, R>(input: { · 137 unmodified lines
142 timeoutMs: input.timeoutMs,143 timeoutMs: input.timeoutMs,
143 intervalMs: input.intervalMs,144 intervalMs: input.intervalMs,
144 });145 });
146
147const toProbeHost = (hostname: string): string => {
148 const normalized = hostname.trim().toLowerCase();
149 if (normalized === "localhost" || normalized === "0.0.0.0") {
150 return "127.0.0.1";
151 }
152 return hostname;
153};
154
155const isPortAvailable = (input: { hostname: string; port: number }): Effect.Effect<boolean, Error> =>
156 Effect.tryPromise({
157 try: () =>
158 new Promise<boolean>((resolve) => {
159 const server = createServer() as any;
160 const cleanup = () => {
161 if (typeof server.removeAllListeners === "function") {
162 server.removeAllListeners();
163 }
164 };
165
166 server.once("error", () => {
167 cleanup();
168 resolve(false);
169 });
170
171 server.once("listening", () => {
172 cleanup();
173 server.close(() => resolve(true));
174 });
175
176 server.listen({ port: input.port, host: toProbeHost(input.hostname) });
177 }),
178 catch: (cause) =>
179 cause instanceof Error
180 ? cause
181 : new Error(`Failed probing port availability: ${String(cause)}`),
182 });
183
184const pickEphemeralPort = (hostname: string): Effect.Effect<number, Error> =>
185 Effect.tryPromise({
186 try: () =>
187 new Promise<number>((resolve, reject) => {
188 const server = createServer() as any;
189
190 server.once("error", (error: unknown) => {
191 reject(error);
192 });
193
194 server.once("listening", () => {
195 const address = server.address();
196 const port = typeof address === "object" && address !== null ? address.port : 0;
197 server.close(() => resolve(port));
198 });
199
200 server.listen({ port: 0, host: toProbeHost(hostname) });
201 }),
202 catch: (cause) =>
203 cause instanceof Error ? cause : new Error(`Failed selecting ephemeral port: ${String(cause)}`),
204 });
205
206export const chooseDaemonPort = (input: {
207 preferredPort: number;
208 hostname: string;
209}): Effect.Effect<number, Error> =>
210 Effect.gen(function* () {
211 const preferredAvailable = yield* isPortAvailable({
212 hostname: input.hostname,
213 port: input.preferredPort,
214 });
215 if (preferredAvailable) return input.preferredPort;
216
217 const fallbackPort = yield* pickEphemeralPort(input.hostname);
218 if (!Number.isFinite(fallbackPort) || fallbackPort <= 0 || fallbackPort > 65535) {
219 return yield* Effect.fail(
220 new Error(`Could not find an available daemon port for host ${input.hostname}`),
221 );
222 }
223 return fallbackPort;
224 });
apps/cli/src/main.ts+220−92
@@ -1,4 +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 { randomUUID } from "node:crypto";
2import { dirname, join, resolve } from "node:path";3import { dirname, join, resolve } from "node:path";
3const execDir = dirname(process.execPath);4const execDir = dirname(process.execPath);
4if (process.env.PATH && !process.env.PATH.includes(execDir)) {5if (process.env.PATH && !process.env.PATH.includes(execDir)) {
@@ -33,7 +34,7 @@ if (typeof Bun !== "undefined" && (await Bun.file(wasmOnDisk).exists())) { · 28 unmodified lines
33 34
34import { Command, Options, Args } from "@effect/cli";35import { Command, Options, Args } from "@effect/cli";
35import { BunContext, BunRuntime } from "@effect/platform-bun";36import { BunContext, BunRuntime } from "@effect/platform-bun";
36import { FetchHttpClient, FileSystem, HttpApiClient } from "@effect/platform";37import { FetchHttpClient, FileSystem, HttpApiClient, Path as PlatformPath } from "@effect/platform";
37import * as Effect from "effect/Effect";38import * as Effect from "effect/Effect";
38import * as Option from "effect/Option";39import * as Option from "effect/Option";
39import * as Cause from "effect/Cause";40import * as Cause from "effect/Cause";
@@ -43,6 +44,7 @@ import { startServer, runMcpStdioServer, getExecutor } from "@executor/local"; · 3 unmodified lines
43import { makeQuickJsExecutor } from "@executor/runtime-quickjs";44import { makeQuickJsExecutor } from "@executor/runtime-quickjs";
44import {45import {
45 buildDaemonSpawnSpec,46 buildDaemonSpawnSpec,
47 chooseDaemonPort,
46 canAutoStartLocalDaemonForHost,48 canAutoStartLocalDaemonForHost,
47 parseDaemonBaseUrl,49 parseDaemonBaseUrl,
48 spawnDetached,50 spawnDetached,
@@ -50,11 +52,17 @@ import { · 1 unmodified line
50 waitForUnreachable,52 waitForUnreachable,
51} from "./daemon";53} from "./daemon";
52import {54import {
55 acquireDaemonStartLock,
53 canonicalDaemonHost,56 canonicalDaemonHost,
57 currentDaemonScopeId,
54 isPidAlive,58 isPidAlive,
59 readDaemonPointer,
55 readDaemonRecord,60 readDaemonRecord,
61 releaseDaemonStartLock,
62 removeDaemonPointer,
56 removeDaemonRecord,63 removeDaemonRecord,
57 terminatePid,64 terminatePid,
65 writeDaemonPointer,
58 writeDaemonRecord,66 writeDaemonRecord,
59} from "./daemon-state";67} from "./daemon-state";
60import {68import {
@@ -102,9 +110,25 @@ const waitForShutdownSignal = () => · 41 unmodified lines
102// Background server management110// Background server management
103// ---------------------------------------------------------------------------111// ---------------------------------------------------------------------------
104 112
113const isRecord = (value: unknown): value is Record<string, unknown> =>
114 typeof value === "object" && value !== null && !Array.isArray(value);
115
105const isServerReachable = (baseUrl: string): Effect.Effect<boolean> =>116const isServerReachable = (baseUrl: string): Effect.Effect<boolean> =>
106 Effect.tryPromise(() => fetch(`${baseUrl}/api/scope`, { signal: AbortSignal.timeout(2000) })).pipe(117 Effect.tryPromise(() => fetch(`${baseUrl}/api/scope`, { signal: AbortSignal.timeout(2000) })).pipe(
107 Effect.map((res) => res.ok),118 Effect.flatMap((res) => {
119 if (!res.ok) return Effect.succeed(false);
120 return Effect.tryPromise(() => res.json()).pipe(
121 Effect.map((payload) => {
122 if (!isRecord(payload)) return false;
123 return (
124 typeof payload.id === "string" &&
125 typeof payload.name === "string" &&
126 typeof payload.dir === "string"
127 );
128 }),
129 Effect.catchAll(() => Effect.succeed(false)),
130 );
131 }),
108 Effect.catchAll(() => Effect.succeed(false)),132 Effect.catchAll(() => Effect.succeed(false)),
109 );133 );
110 134
@@ -112,116 +136,191 @@ const script = process.argv[1]; · 1 unmodified line
112const isDevMode = script?.endsWith(".ts") || script?.endsWith(".js");136const isDevMode = script?.endsWith(".ts") || script?.endsWith(".js");
113const cliPrefix = isDevMode ? `bun run ${script}` : "executor";137const cliPrefix = isDevMode ? `bun run ${script}` : "executor";
114 138
115const ensureDaemon = (baseUrl: string) =>139const toError = (cause: unknown): Error =>
140 cause instanceof Error ? cause : new Error(String(cause));
141
142const parseDaemonUrl = (baseUrl: string) =>
143 Effect.try({
144 try: () => parseDaemonBaseUrl(baseUrl, DEFAULT_PORT),
145 catch: (cause) =>
146 cause instanceof Error ? cause : new Error(`Invalid base URL: ${String(cause)}`),
147 });
148
149const daemonBaseUrl = (hostname: string, port: number): string =>
150 `http://${canonicalDaemonHost(hostname)}:${port}`;
151
152const cleanupPointer = (input: { hostname: string; scopeId: string; port: number }) =>
116 Effect.gen(function* () {153 Effect.gen(function* () {
117 if (yield* isServerReachable(baseUrl)) return;154 yield* removeDaemonPointer({ hostname: input.hostname, scopeId: input.scopeId }).pipe(Effect.ignore);
155 yield* removeDaemonRecord({ hostname: input.hostname, port: input.port }).pipe(Effect.ignore);
156 });
118 157
119 const parsed = yield* Effect.try({158const resolveDaemonTarget = (baseUrl: string) =>
120 try: () => parseDaemonBaseUrl(baseUrl, DEFAULT_PORT),159 Effect.gen(function* () {
121 catch: (cause) =>160 const parsed = yield* parseDaemonUrl(baseUrl);
122 cause instanceof Error ? cause : new Error(`Invalid base URL: ${String(cause)}`),161 const host = canonicalDaemonHost(parsed.hostname);
123 });162 const scopeId = currentDaemonScopeId();
163 const pointer = yield* readDaemonPointer({ hostname: host, scopeId });
164
165 if (pointer) {
166 const pointerUrl = daemonBaseUrl(pointer.hostname, pointer.port);
167 if (isPidAlive(pointer.pid) && (yield* isServerReachable(pointerUrl))) {
168 return {
169 baseUrl: pointerUrl,
170 hostname: pointer.hostname,
171 port: pointer.port,
172 scopeId,
173 };
174 }
175
176 yield* cleanupPointer({ hostname: pointer.hostname, scopeId, port: pointer.port });
177 }
178
179 return {
180 baseUrl: daemonBaseUrl(host, parsed.port),
181 hostname: host,
182 port: parsed.port,
183 scopeId,
184 };
185 });
186
187const ensureDaemon = (
188 baseUrl: string,
189): Effect.Effect<string, Error, FileSystem.FileSystem | PlatformPath.Path> =>
190 Effect.gen(function* () {
191 const resolvedTarget = yield* resolveDaemonTarget(baseUrl);
192 if (yield* isServerReachable(resolvedTarget.baseUrl)) {
193 return resolvedTarget.baseUrl;
194 }
195
196 const parsed = yield* parseDaemonUrl(baseUrl);
197 const scopeId = currentDaemonScopeId();
198 const host = canonicalDaemonHost(parsed.hostname);
124 199
125 if (!canAutoStartLocalDaemonForHost(parsed.hostname)) {200 if (!canAutoStartLocalDaemonForHost(host)) {
126 return yield* Effect.fail(201 return yield* Effect.fail(
127 new Error(202 new Error(
128 [203 [
129 `Executor daemon is not reachable at ${baseUrl}.`,204 `Executor daemon is not reachable at ${baseUrl}.`,
130 "Auto-start is only supported for local hosts.",205 "Auto-start is only supported for local hosts.",
131 `Start it manually: ${cliPrefix} daemon run --port ${parsed.port} --hostname ${parsed.hostname}`,206 `Start it manually: ${cliPrefix} daemon run --port ${parsed.port} --hostname ${host}`,
132 ].join("\n"),207 ].join("\n"),
133 ),208 ),
134 );209 );
135 }210 }
136 211
137 const spec = yield* Effect.try({212 const lock = yield* acquireDaemonStartLock({ hostname: host, scopeId });
138 try: () =>
139 buildDaemonSpawnSpec({
140 port: parsed.port,
141 hostname: parsed.hostname,
142 isDevMode,
143 scriptPath: script,
144 executablePath: process.execPath,
145 }),
146 catch: (cause) =>
147 cause instanceof Error ? cause : new Error(`Failed to build daemon command: ${String(cause)}`),
148 });
149 213
150 console.error(`Starting daemon on ${parsed.hostname}:${parsed.port}...`);214 try {
151 yield* spawnDetached({215 const existing = yield* resolveDaemonTarget(baseUrl);
152 command: spec.command,216 if (yield* isServerReachable(existing.baseUrl)) {
153 args: spec.args,217 return existing.baseUrl;
154 env: process.env,218 }
155 });
156 219
157 const ready = yield* waitForReachable({220 const selectedPort = yield* chooseDaemonPort({
158 check: isServerReachable(baseUrl),221 preferredPort: parsed.port,
159 timeoutMs: DAEMON_BOOT_TIMEOUT_MS,222 hostname: host,
160 intervalMs: DAEMON_BOOT_POLL_MS,223 });
161 });
162 224
163 if (!ready) {225 if (selectedPort !== parsed.port) {
164 return yield* Effect.fail(226 console.error(
165 new Error(227 `Port ${parsed.port} is in use. Starting daemon on available port ${selectedPort} instead.`,
166 [228 );
167 `Daemon did not become reachable at ${baseUrl} within ${DAEMON_BOOT_TIMEOUT_MS}ms.`,229 }
168 `Run in foreground to inspect logs: ${cliPrefix} daemon run --port ${parsed.port} --hostname ${parsed.hostname}`,230
169 ].join("\n"),231 const spec = yield* Effect.try({
170 ),232 try: () =>
171 );233 buildDaemonSpawnSpec({
234 port: selectedPort,
235 hostname: host,
236 isDevMode,
237 scriptPath: script,
238 executablePath: process.execPath,
239 }),
240 catch: (cause) =>
241 cause instanceof Error ? cause : new Error(`Failed to build daemon command: ${String(cause)}`),
242 });
243
244 const startBaseUrl = daemonBaseUrl(host, selectedPort);
245 console.error(`Starting daemon on ${host}:${selectedPort}...`);
246 yield* spawnDetached({
247 command: spec.command,
248 args: spec.args,
249 env: process.env,
250 });
251
252 const ready = yield* waitForReachable({
253 check: isServerReachable(startBaseUrl),
254 timeoutMs: DAEMON_BOOT_TIMEOUT_MS,
255 intervalMs: DAEMON_BOOT_POLL_MS,
256 });
257
258 if (!ready) {
259 return yield* Effect.fail(
260 new Error(
261 [
262 `Daemon did not become reachable at ${startBaseUrl} within ${DAEMON_BOOT_TIMEOUT_MS}ms.`,
263 `Run in foreground to inspect logs: ${cliPrefix} daemon run --port ${selectedPort} --hostname ${host}`,
264 ].join("\n"),
265 ),
266 );
267 }
268
269 return startBaseUrl;
270 } finally {
271 yield* releaseDaemonStartLock(lock).pipe(Effect.ignore);
172 }272 }
173 });273 }).pipe(Effect.mapError(toError));
174 274
175const stopDaemon = (baseUrl: string) =>275const stopDaemon = (
276 baseUrl: string,
277): Effect.Effect<void, Error, FileSystem.FileSystem | PlatformPath.Path> =>
176 Effect.gen(function* () {278 Effect.gen(function* () {
177 const parsed = yield* Effect.try({279 const target = yield* resolveDaemonTarget(baseUrl);
178 try: () => parseDaemonBaseUrl(baseUrl, DEFAULT_PORT),280 const host = canonicalDaemonHost(target.hostname);
179 catch: (cause) =>281 const scopeId = target.scopeId;
180 cause instanceof Error ? cause : new Error(`Invalid base URL: ${String(cause)}`),282 const record = yield* readDaemonRecord({ hostname: host, port: target.port });
181 });283 const reachable = yield* isServerReachable(target.baseUrl);
182
183 const host = canonicalDaemonHost(parsed.hostname);
184 const record = yield* readDaemonRecord({ hostname: host, port: parsed.port });
185 const reachable = yield* isServerReachable(baseUrl);
186 284
187 if (!record) {285 if (!record) {
188 if (reachable) {286 if (reachable) {
189 return yield* Effect.fail(287 return yield* Effect.fail(
190 new Error(288 new Error(
191 [289 [
192 `Executor is reachable at ${baseUrl} but no daemon record exists.`,290 `Executor is reachable at ${target.baseUrl} but no daemon record exists.`,
193 "It may not be managed by this CLI process.",291 "It may not be managed by this CLI process.",
194 "Stop it from the terminal/session where it was started.",292 "Stop it from the terminal/session where it was started.",
195 ].join("\n"),293 ].join("\n"),
196 ),294 ),
197 );295 );
198 }296 }
199 console.log(`No daemon running at ${baseUrl}.`);297 console.log(`No daemon running at ${target.baseUrl}.`);
200 return;298 return;
201 }299 }
202 300
203 if (!isPidAlive(record.pid)) {301 if (!isPidAlive(record.pid)) {
204 yield* removeDaemonRecord({ hostname: host, port: parsed.port });302 yield* removeDaemonRecord({ hostname: host, port: target.port });
303 yield* removeDaemonPointer({ hostname: host, scopeId }).pipe(Effect.ignore);
205 if (reachable) {304 if (reachable) {
206 return yield* Effect.fail(305 return yield* Effect.fail(
207 new Error(306 new Error(
208 [307 [
209 `Daemon record for ${baseUrl} points to dead pid ${record.pid}, but endpoint is still reachable.`,308 `Daemon record for ${target.baseUrl} points to dead pid ${record.pid}, but endpoint is still reachable.`,
210 "Refusing to stop an unknown process without ownership metadata.",309 "Refusing to stop an unknown process without ownership metadata.",
211 ].join("\n"),310 ].join("\n"),
212 ),311 ),
213 );312 );
214 }313 }
215 console.log(`No daemon running at ${baseUrl} (removed stale record for pid ${record.pid}).`);314 console.log(`No daemon running at ${target.baseUrl} (removed stale record for pid ${record.pid}).`);
216 return;315 return;
217 }316 }
218 317
219 console.log(`Stopping daemon at ${baseUrl} (pid ${record.pid})...`);318 console.log(`Stopping daemon at ${target.baseUrl} (pid ${record.pid})...`);
220 319
221 yield* terminatePid(record.pid);320 yield* terminatePid(record.pid);
222 321
223 const stopped = yield* waitForUnreachable({322 const stopped = yield* waitForUnreachable({
224 check: isServerReachable(baseUrl),323 check: isServerReachable(target.baseUrl),
225 timeoutMs: DAEMON_STOP_TIMEOUT_MS,324 timeoutMs: DAEMON_STOP_TIMEOUT_MS,
226 intervalMs: DAEMON_BOOT_POLL_MS,325 intervalMs: DAEMON_BOOT_POLL_MS,
227 });326 });
@@ -230,16 +329,17 @@ const stopDaemon = (baseUrl: string) => · 2 unmodified lines
230 return yield* Effect.fail(329 return yield* Effect.fail(
231 new Error(330 new Error(
232 [331 [
233 `Daemon at ${baseUrl} did not stop within ${DAEMON_STOP_TIMEOUT_MS}ms.`,332 `Daemon at ${target.baseUrl} did not stop within ${DAEMON_STOP_TIMEOUT_MS}ms.`,
234 "Try terminating the process manually.",333 "Try terminating the process manually.",
235 ].join("\n"),334 ].join("\n"),
236 ),335 ),
237 );336 );
238 }337 }
239 338
240 yield* removeDaemonRecord({ hostname: host, port: parsed.port });339 yield* removeDaemonRecord({ hostname: host, port: target.port });
241 console.log(`Daemon stopped at ${baseUrl}.`);340 yield* removeDaemonPointer({ hostname: host, scopeId }).pipe(Effect.ignore);
242 });341 console.log(`Daemon stopped at ${target.baseUrl}.`);
342 }).pipe(Effect.mapError(toError));
243 343
244type ExecuteCodeOutcome =344type ExecuteCodeOutcome =
245 | {345 | {
@@ -252,16 +352,13 @@ type ExecuteCodeOutcome = · 6 unmodified lines
252 readonly executionId: string | undefined;352 readonly executionId: string | undefined;
253 };353 };
254 354
255const toError = (cause: unknown): Error =>
256 cause instanceof Error ? cause : new Error(String(cause));
257
258const executeCode = (input: {355const executeCode = (input: {
259 baseUrl: string;356 baseUrl: string;
260 code: string;357 code: string;
261}): Effect.Effect<ExecuteCodeOutcome, Error> =>358}): Effect.Effect<ExecuteCodeOutcome, Error, FileSystem.FileSystem | PlatformPath.Path> =>
262 Effect.gen(function* () {359 Effect.gen(function* () {
263 yield* ensureDaemon(input.baseUrl);360 const daemonUrl = yield* ensureDaemon(input.baseUrl);
264 const client = yield* makeApiClient(input.baseUrl);361 const client = yield* makeApiClient(daemonUrl);
265 const response = yield* client.executions.execute({362 const response = yield* client.executions.execute({
266 payload: {363 payload: {
267 code: input.code,364 code: input.code,
@@ -361,6 +458,26 @@ const runDaemonSession = (input: { · 93 unmodified lines
361 allowedHosts: ReadonlyArray<string>;458 allowedHosts: ReadonlyArray<string>;
362}) =>459}) =>
363 Effect.gen(function* () {460 Effect.gen(function* () {
461 const daemonHost = canonicalDaemonHost(input.hostname);
462 const scopeId = currentDaemonScopeId();
463 const existing = yield* readDaemonPointer({ hostname: daemonHost, scopeId });
464
465 if (existing) {
466 const existingUrl = daemonBaseUrl(existing.hostname, existing.port);
467 if (isPidAlive(existing.pid) && (yield* isServerReachable(existingUrl))) {
468 return yield* Effect.fail(
469 new Error(
470 [
471 `A daemon is already running for scope ${scopeId} on ${daemonHost}.`,
472 `Existing daemon: ${existingUrl} (pid ${existing.pid}).`,
473 `Stop it first: ${cliPrefix} daemon stop`,
474 ].join("\n"),
475 ),
476 );
477 }
478 yield* cleanupPointer({ hostname: existing.hostname, scopeId, port: existing.port });
479 }
480
364 const server = yield* Effect.promise(() =>481 const server = yield* Effect.promise(() =>
365 startServer({482 startServer({
366 port: input.port,483 port: input.port,
@@ -370,8 +487,8 @@ const runDaemonSession = (input: { · 3 unmodified lines
370 }),487 }),
371 );488 );
372 489
373 const daemonHost = canonicalDaemonHost(input.hostname);
374 const daemonPort = server.port;490 const daemonPort = server.port;
491 const token = randomUUID();
375 492
376 yield* writeDaemonRecord({493 yield* writeDaemonRecord({
377 hostname: daemonHost,494 hostname: daemonHost,
@@ -379,6 +496,14 @@ const runDaemonSession = (input: { · 1 unmodified line
379 pid: process.pid,496 pid: process.pid,
380 scopeDir: process.env.EXECUTOR_SCOPE_DIR ?? null,497 scopeDir: process.env.EXECUTOR_SCOPE_DIR ?? null,
381 });498 });
499 yield* writeDaemonPointer({
500 hostname: daemonHost,
501 port: daemonPort,
502 pid: process.pid,
503 scopeId,
504 scopeDir: process.env.EXECUTOR_SCOPE_DIR ?? null,
505 token,
506 });
382 507
383 console.log(`Daemon ready on http://${daemonHost}:${daemonPort}`);508 console.log(`Daemon ready on http://${daemonHost}:${daemonPort}`);
384 509
@@ -387,6 +512,7 @@ const runDaemonSession = (input: { · 2 unmodified lines
387 } finally {512 } finally {
388 yield* Effect.promise(() => server.stop());513 yield* Effect.promise(() => server.stop());
389 yield* removeDaemonRecord({ hostname: daemonHost, port: daemonPort });514 yield* removeDaemonRecord({ hostname: daemonHost, port: daemonPort });
515 yield* removeDaemonPointer({ hostname: daemonHost, scopeId }).pipe(Effect.ignore);
390 }516 }
391 });517 });
392 518
@@ -469,9 +595,9 @@ const callCommand = Command.make( · 76 unmodified lines
469 Effect.gen(function* () {595 Effect.gen(function* () {
470 applyScope(scope);596 applyScope(scope);
471 const resolvedCode = yield* readCode({ code, file, stdin });597 const resolvedCode = yield* readCode({ code, file, stdin });
472 yield* ensureDaemon(baseUrl);598 const daemonUrl = yield* ensureDaemon(baseUrl);
473 599
474 const client = yield* makeApiClient(baseUrl);600 const client = yield* makeApiClient(daemonUrl);
475 const result = yield* client.executions.execute({ payload: { code: resolvedCode } });601 const result = yield* client.executions.execute({ payload: { code: resolvedCode } });
476 602
477 if (result.status === "completed") {603 if (result.status === "completed") {
@@ -507,12 +633,12 @@ const resumeCommand = Command.make( · 29 unmodified lines
507 ({ executionId, action, content, baseUrl, scope }) =>633 ({ executionId, action, content, baseUrl, scope }) =>
508 Effect.gen(function* () {634 Effect.gen(function* () {
509 applyScope(scope);635 applyScope(scope);
510 yield* ensureDaemon(baseUrl);636 const daemonUrl = yield* ensureDaemon(baseUrl);
511 637
512 const parsedContent = Option.getOrUndefined(content);638 const parsedContent = Option.getOrUndefined(content);
513 const contentObj = parsedContent ? JSON.parse(parsedContent) : undefined;639 const contentObj = parsedContent ? JSON.parse(parsedContent) : undefined;
514 640
515 const client = yield* makeApiClient(baseUrl);641 const client = yield* makeApiClient(daemonUrl);
516 const result = yield* client.executions.resume({642 const result = yield* client.executions.resume({
517 path: { executionId },643 path: { executionId },
518 payload: { action: action as "accept" | "decline" | "cancel", content: contentObj },644 payload: { action: action as "accept" | "decline" | "cancel", content: contentObj },
@@ -711,41 +837,43 @@ const daemonStatusCommand = Command.make( · 192 unmodified lines
711 },837 },
712 ({ baseUrl }) =>838 ({ baseUrl }) =>
713 Effect.gen(function* () {839 Effect.gen(function* () {
714 const parsed = yield* Effect.try({840 const target = yield* resolveDaemonTarget(baseUrl);
715 try: () => parseDaemonBaseUrl(baseUrl, DEFAULT_PORT),841 const host = canonicalDaemonHost(target.hostname);
716 catch: (cause) =>
717 cause instanceof Error ? cause : new Error(`Invalid base URL: ${String(cause)}`),
718 });
719 const host = canonicalDaemonHost(parsed.hostname);
720 842
721 const [record, reachable] = yield* Effect.all([843 const [record, reachable] = yield* Effect.all([
722 readDaemonRecord({ hostname: host, port: parsed.port }),844 readDaemonRecord({ hostname: host, port: target.port }),
723 isServerReachable(baseUrl),845 isServerReachable(target.baseUrl),
724 ]);846 ]);
725 847
726 if (!record) {848 if (!record) {
727 if (reachable) {849 if (reachable) {
728 console.log(`Daemon reachable at ${baseUrl} (no local ownership record).`);850 console.log(`Daemon reachable at ${target.baseUrl} (no local ownership record).`);
729 } else {851 } else {
730 console.log(`Daemon not running at ${baseUrl}.`);852 console.log(`Daemon not running at ${target.baseUrl}.`);
731 }853 }
732 return;854 return;
733 }855 }
734 856
735 if (!isPidAlive(record.pid)) {857 if (!isPidAlive(record.pid)) {
736 if (!reachable) {858 if (!reachable) {
737 yield* removeDaemonRecord({ hostname: host, port: parsed.port });859 yield* removeDaemonRecord({ hostname: host, port: target.port });
738 console.log(`Daemon not running at ${baseUrl} (removed stale record for pid ${record.pid}).`);860 yield* removeDaemonPointer({ hostname: host, scopeId: target.scopeId }).pipe(Effect.ignore);
861 console.log(
862 `Daemon not running at ${target.baseUrl} (removed stale record for pid ${record.pid}).`,
863 );
739 return;864 return;
740 }865 }
741 console.log(866 console.log(
742 `Daemon reachable at ${baseUrl}, but recorded pid ${record.pid} is not alive (ownership mismatch).`,867 `Daemon reachable at ${target.baseUrl}, but recorded pid ${record.pid} is not alive (ownership mismatch).`,
743 );868 );
744 return;869 return;
745 }870 }
746 871
747 const state = reachable ? "running" : "unreachable";872 const state = reachable ? "running" : "unreachable";
748 console.log(`Daemon ${state} at ${baseUrl} (pid ${record.pid}).`);873 console.log(`Daemon ${state} at ${target.baseUrl} (pid ${record.pid}).`);
874 if (target.baseUrl !== baseUrl) {
875 console.log(`Requested: ${baseUrl}`);
876 }
749 if (record.scopeDir) {877 if (record.scopeDir) {
750 console.log(`Scope: ${record.scopeDir}`);878 console.log(`Scope: ${record.scopeDir}`);
751 }879 }
@@ -770,8 +898,8 @@ const daemonRestartCommand = Command.make( · 18 unmodified lines
770 Effect.gen(function* () {898 Effect.gen(function* () {
771 applyScope(scope);899 applyScope(scope);
772 yield* stopDaemon(baseUrl);900 yield* stopDaemon(baseUrl);
773 yield* ensureDaemon(baseUrl);901 const daemonUrl = yield* ensureDaemon(baseUrl);
774 console.log(`Daemon restarted at ${baseUrl}.`);902 console.log(`Daemon restarted at ${daemonUrl}.`);
775 }),903 }),
776).pipe(Command.withDescription("Restart the local daemon"));904).pipe(Command.withDescription("Restart the local daemon"));
777 905
tests/daemon-bootstrap.test.ts+28−0
@@ -1,8 +1,11 @@
1import { describe, expect, it } from "vitest";1import { describe, expect, it } from "vitest";
2import { createServer } from "node:net";
3import * as Effect from "effect/Effect";
2 4
3import {5import {
4 buildDaemonSpawnSpec,6 buildDaemonSpawnSpec,
5 canAutoStartLocalDaemonForHost,7 canAutoStartLocalDaemonForHost,
8 chooseDaemonPort,
6 parseDaemonBaseUrl,9 parseDaemonBaseUrl,
7} from "../apps/cli/src/daemon";10} from "../apps/cli/src/daemon";
8 11
@@ -83,4 +86,29 @@ describe("daemon bootstrap helpers", () => { · 74 unmodified lines
83 }),86 }),
84 ).toThrow("Cannot auto-start daemon in dev mode");87 ).toThrow("Cannot auto-start daemon in dev mode");
85 });88 });
89
90 it("falls back when preferred daemon port is occupied", async () => {
91 const blocker = createServer();
92 await new Promise<void>((resolve, reject) => {
93 blocker.once("error", reject);
94 blocker.listen({ port: 0, host: "127.0.0.1" }, () => resolve());
95 });
96
97 const occupied = (() => {
98 const address = blocker.address();
99 return typeof address === "object" && address !== null ? address.port : 0;
100 })();
101
102 try {
103 const picked = await Effect.runPromise(
104 chooseDaemonPort({
105 preferredPort: occupied,
106 hostname: "127.0.0.1",
107 }),
108 );
109 expect(picked).not.toBe(occupied);
110 } finally {
111 await new Promise<void>((resolve) => blocker.close(() => resolve()));
112 }
113 });
86});114});
tests/daemon-state.test.ts+88−2
@@ -9,10 +9,16 @@ import { describe, expect, it } from "@effect/vitest"; · 8 unmodified lines
9import { Effect, Layer } from "effect";9import { Effect, Layer } from "effect";
10 10
11import {11import {
12 acquireDaemonStartLock,
12 canonicalDaemonHost,13 canonicalDaemonHost,
14 currentDaemonScopeId,
13 isPidAlive,15 isPidAlive,
16 readDaemonPointer,
14 readDaemonRecord,17 readDaemonRecord,
18 releaseDaemonStartLock,
19 removeDaemonPointer,
15 removeDaemonRecord,20 removeDaemonRecord,
21 writeDaemonPointer,
16 writeDaemonRecord,22 writeDaemonRecord,
17} from "../apps/cli/src/daemon-state";23} from "../apps/cli/src/daemon-state";
18 24
@@ -31,9 +37,9 @@ const fileSystemLayer = FileSystem.layerNoop({ · 12 unmodified lines
31 try: () => mkdir(path, { recursive: options?.recursive, mode: options?.mode }),37 try: () => mkdir(path, { recursive: options?.recursive, mode: options?.mode }),
32 catch: (cause) => fileSystemError("makeDirectory", cause),38 catch: (cause) => fileSystemError("makeDirectory", cause),
33 }),39 }),
34 writeFileString: (path, data, _options) =>40 writeFileString: (path, data, options) =>
35 Effect.tryPromise({41 Effect.tryPromise({
36 try: () => writeFile(path, data, "utf8"),42 try: () => writeFile(path, data, { encoding: "utf8", flag: options?.flag }),
37 catch: (cause) => fileSystemError("writeFileString", cause),43 catch: (cause) => fileSystemError("writeFileString", cause),
38 }),44 }),
39 readFileString: (path, encoding = "utf8") =>45 readFileString: (path, encoding = "utf8") =>
@@ -104,6 +110,86 @@ describe("daemon state", () => { · 64 unmodified lines
104 ),110 ),
105 );111 );
106 112
113 it.effect("writes, reads, and removes daemon pointers", () =>
114 withDaemonDataDir(
115 Effect.gen(function* () {
116 yield* writeDaemonPointer({
117 hostname: "127.0.0.1",
118 port: 5799,
119 pid: 24680,
120 scopeId: "scope:/tmp/project",
121 scopeDir: "/tmp/project",
122 token: "tok_123",
123 });
124
125 const stored = yield* readDaemonPointer({
126 hostname: "localhost",
127 scopeId: "scope:/tmp/project",
128 });
129 expect(stored).toEqual({
130 version: 1,
131 hostname: "localhost",
132 port: 5799,
133 pid: 24680,
134 startedAt: expect.any(String),
135 scopeId: "scope:/tmp/project",
136 scopeDir: "/tmp/project",
137 token: "tok_123",
138 });
139
140 yield* removeDaemonPointer({
141 hostname: "localhost",
142 scopeId: "scope:/tmp/project",
143 });
144 const after = yield* readDaemonPointer({
145 hostname: "localhost",
146 scopeId: "scope:/tmp/project",
147 });
148 expect(after).toBeNull();
149 }),
150 ),
151 );
152
153 it.effect("serializes daemon startup with lock files", () =>
154 withDaemonDataDir(
155 Effect.gen(function* () {
156 const lock = yield* acquireDaemonStartLock({
157 hostname: "localhost",
158 scopeId: "scope:/tmp/project",
159 });
160
161 const second = yield* acquireDaemonStartLock({
162 hostname: "localhost",
163 scopeId: "scope:/tmp/project",
164 }).pipe(Effect.flip);
165
166 expect(second.message).toContain("already in progress");
167
168 yield* releaseDaemonStartLock(lock);
169
170 const third = yield* acquireDaemonStartLock({
171 hostname: "localhost",
172 scopeId: "scope:/tmp/project",
173 });
174 yield* releaseDaemonStartLock(third);
175 }),
176 ),
177 );
178
179 it("derives scope id from EXECUTOR_SCOPE_DIR or cwd", () => {
180 const prev = process.env.EXECUTOR_SCOPE_DIR;
181 process.env.EXECUTOR_SCOPE_DIR = "/tmp/explicit-scope";
182 expect(currentDaemonScopeId()).toBe("scope:/tmp/explicit-scope");
183
184 if (prev === undefined) {
185 delete process.env.EXECUTOR_SCOPE_DIR;
186 } else {
187 process.env.EXECUTOR_SCOPE_DIR = prev;
188 }
189
190 expect(currentDaemonScopeId()).toContain("cwd:");
191 });
192
107 it("detects live and invalid pids", () => {193 it("detects live and invalid pids", () => {
108 expect(isPidAlive(process.pid)).toBe(true);194 expect(isPidAlive(process.pid)).toBe(true);
109 expect(isPidAlive(-1)).toBe(false);195 expect(isPidAlive(-1)).toBe(false);