History

add scope-aware daemon pointer and automatic port fallback

Rhys Sullivan committed 6 months ago

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

Showing the changes to apps/cli/src (3 of 6 files). Show all

Unified · Split

3 files
  1. apps/cli/src/daemon-state.ts +219 −0
  2. apps/cli/src/daemon.ts +80 −0
  3. apps/cli/src/main.ts +220 −92
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