Skip to content

Commit 0adad79

Browse files
committed
add scope-aware daemon pointer and automatic port fallback
1 parent 2515183 commit 0adad79

6 files changed

Lines changed: 636 additions & 94 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ executor tools invoke gmail.send --input '{"to":"alice@example.com","subject":"H
8989
```
9090

9191
`executor call`, `executor resume`, and `executor tools ...` commands auto-start a local daemon if needed.
92+
If the default port is busy, the CLI will pick an available local port and track it automatically.
9293

9394
If an execution pauses for auth or approval, resume it:
9495

apps/cli/src/daemon-state.ts

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,23 @@ export interface DaemonRecord {
1616
readonly scopeDir: string | null;
1717
}
1818

19+
export 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+
30+
export interface DaemonStartLock {
31+
readonly path: string;
32+
readonly hostname: string;
33+
readonly scopeId: string;
34+
}
35+
1936
// ---------------------------------------------------------------------------
2037
// Host normalization
2138
// ---------------------------------------------------------------------------
@@ -27,6 +44,14 @@ export const canonicalDaemonHost = (hostname: string): string => {
2744
return LOCAL_HOST_ALIASES.has(normalized) ? "localhost" : normalized;
2845
};
2946

47+
export 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+
3055
// ---------------------------------------------------------------------------
3156
// Paths
3257
// ---------------------------------------------------------------------------
@@ -35,12 +60,22 @@ const resolveDaemonDataDir = (path: Path.Path): string =>
3560
process.env.EXECUTOR_DATA_DIR ?? path.join(homedir(), ".executor");
3661

3762
const sanitizeHostForPath = (hostname: string): string => hostname.replaceAll(/[^a-z0-9.-]+/gi, "_");
63+
const sanitizeScopeForPath = (scopeId: string): string => scopeId.replaceAll(/[^a-z0-9.-]+/gi, "_");
3864

3965
const daemonRecordPath = (path: Path.Path, input: { hostname: string; port: number }): string => {
4066
const host = sanitizeHostForPath(canonicalDaemonHost(input.hostname));
4167
return path.join(resolveDaemonDataDir(path), `daemon-${host}-${input.port}.json`);
4268
};
4369

70+
const 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+
76+
const daemonStartLockPath = (path: Path.Path, input: { hostname: string; scopeId: string }): string =>
77+
`${daemonPointerPath(path, input)}.lock`;
78+
4479
// ---------------------------------------------------------------------------
4580
// Persistence
4681
// ---------------------------------------------------------------------------
@@ -111,6 +146,48 @@ const parseRecord = (raw: string): DaemonRecord | null => {
111146
};
112147
};
113148

149+
const 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+
114191
export const readDaemonRecord = (input: {
115192
hostname: string;
116193
port: number;
@@ -135,6 +212,148 @@ export const removeDaemonRecord = (input: {
135212
yield* fs.remove(daemonRecordPath(path, input), { force: true });
136213
});
137214

215+
export 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+
246+
export 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+
260+
export 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+
270+
const 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+
289+
export 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+
347+
export 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+
138357
// ---------------------------------------------------------------------------
139358
// Process helpers
140359
// ---------------------------------------------------------------------------

apps/cli/src/daemon.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { spawn } from "node:child_process";
2+
import { createServer } from "node:net";
23
import * as Clock from "effect/Clock";
34
import * as Effect from "effect/Effect";
45

@@ -142,3 +143,82 @@ export const waitForUnreachable = <E, R>(input: {
142143
timeoutMs: input.timeoutMs,
143144
intervalMs: input.intervalMs,
144145
});
146+
147+
const 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+
155+
const 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+
184+
const 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+
206+
export 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+
});

0 commit comments

Comments
 (0)