| @@ -1,4 +1,5 @@ |
| 1 | 1 | // Ensure binaries next to the executor (e.g. secure-exec-v8) are on $PATH |
| 2 | +import { randomUUID } from "node:crypto"; |
| 2 | 3 | import { dirname, join, resolve } from "node:path"; |
| 3 | 4 | const execDir = dirname(process.execPath); |
| 4 | 5 | if (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 | |
| 34 | 35 | import { Command, Options, Args } from "@effect/cli"; |
| 35 | 36 | import { BunContext, BunRuntime } from "@effect/platform-bun"; |
| 36 | | −import { FetchHttpClient, FileSystem, HttpApiClient } from "@effect/platform"; |
| 37 | +import { FetchHttpClient, FileSystem, HttpApiClient, Path as PlatformPath } from "@effect/platform"; |
| 37 | 38 | import * as Effect from "effect/Effect"; |
| 38 | 39 | import * as Option from "effect/Option"; |
| 39 | 40 | import * as Cause from "effect/Cause"; |
| @@ -43,6 +44,7 @@ import { startServer, runMcpStdioServer, getExecutor } from "@executor/local"; · 3 unmodified lines |
| 43 | 44 | import { makeQuickJsExecutor } from "@executor/runtime-quickjs"; |
| 44 | 45 | import { |
| 45 | 46 | buildDaemonSpawnSpec, |
| 47 | + chooseDaemonPort, |
| 46 | 48 | canAutoStartLocalDaemonForHost, |
| 47 | 49 | parseDaemonBaseUrl, |
| 48 | 50 | spawnDetached, |
| @@ -50,11 +52,17 @@ import { · 1 unmodified line |
| 50 | 52 | waitForUnreachable, |
| 51 | 53 | } from "./daemon"; |
| 52 | 54 | import { |
| 55 | + acquireDaemonStartLock, |
| 53 | 56 | canonicalDaemonHost, |
| 57 | + currentDaemonScopeId, |
| 54 | 58 | isPidAlive, |
| 59 | + readDaemonPointer, |
| 55 | 60 | readDaemonRecord, |
| 61 | + releaseDaemonStartLock, |
| 62 | + removeDaemonPointer, |
| 56 | 63 | removeDaemonRecord, |
| 57 | 64 | terminatePid, |
| 65 | + writeDaemonPointer, |
| 58 | 66 | writeDaemonRecord, |
| 59 | 67 | } from "./daemon-state"; |
| 60 | 68 | import { |
| @@ -102,9 +110,25 @@ const waitForShutdownSignal = () => · 41 unmodified lines |
| 102 | 110 | // Background server management |
| 103 | 111 | // --------------------------------------------------------------------------- |
| 104 | 112 | |
| 113 | +const isRecord = (value: unknown): value is Record<string, unknown> => |
| 114 | + typeof value === "object" && value !== null && !Array.isArray(value); |
| 115 | + |
| 105 | 116 | const isServerReachable = (baseUrl: string): Effect.Effect<boolean> => |
| 106 | 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 | 132 | Effect.catchAll(() => Effect.succeed(false)), |
| 109 | 133 | ); |
| 110 | 134 | |
| @@ -112,116 +136,191 @@ const script = process.argv[1]; · 1 unmodified line |
| 112 | 136 | const isDevMode = script?.endsWith(".ts") || script?.endsWith(".js"); |
| 113 | 137 | const cliPrefix = isDevMode ? `bun run ${script}` : "executor"; |
| 114 | 138 | |
| 115 | | −const ensureDaemon = (baseUrl: string) => |
| 139 | +const toError = (cause: unknown): Error => |
| 140 | + cause instanceof Error ? cause : new Error(String(cause)); |
| 141 | + |
| 142 | +const 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 | + |
| 149 | +const daemonBaseUrl = (hostname: string, port: number): string => |
| 150 | + `http://${canonicalDaemonHost(hostname)}:${port}`; |
| 151 | + |
| 152 | +const cleanupPointer = (input: { hostname: string; scopeId: string; port: number }) => |
| 116 | 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({ |
| 120 | | − try: () => parseDaemonBaseUrl(baseUrl, DEFAULT_PORT), |
| 121 | | − catch: (cause) => |
| 122 | | − cause instanceof Error ? cause : new Error(`Invalid base URL: ${String(cause)}`), |
| 123 | | − }); |
| 158 | +const resolveDaemonTarget = (baseUrl: string) => |
| 159 | + Effect.gen(function* () { |
| 160 | + const parsed = yield* parseDaemonUrl(baseUrl); |
| 161 | + const host = canonicalDaemonHost(parsed.hostname); |
| 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 | + |
| 187 | +const 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 | 201 | return yield* Effect.fail( |
| 127 | 202 | new Error( |
| 128 | 203 | [ |
| 129 | 204 | `Executor daemon is not reachable at ${baseUrl}.`, |
| 130 | 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 | 207 | ].join("\n"), |
| 133 | 208 | ), |
| 134 | 209 | ); |
| 135 | 210 | } |
| 136 | 211 | |
| 137 | | − const spec = yield* Effect.try({ |
| 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 | | − }); |
| 212 | + const lock = yield* acquireDaemonStartLock({ hostname: host, scopeId }); |
| 149 | 213 | |
| 150 | | − console.error(`Starting daemon on ${parsed.hostname}:${parsed.port}...`); |
| 151 | | − yield* spawnDetached({ |
| 152 | | − command: spec.command, |
| 153 | | − args: spec.args, |
| 154 | | − env: process.env, |
| 155 | | − }); |
| 214 | + try { |
| 215 | + const existing = yield* resolveDaemonTarget(baseUrl); |
| 216 | + if (yield* isServerReachable(existing.baseUrl)) { |
| 217 | + return existing.baseUrl; |
| 218 | + } |
| 156 | 219 | |
| 157 | | − const ready = yield* waitForReachable({ |
| 158 | | − check: isServerReachable(baseUrl), |
| 159 | | − timeoutMs: DAEMON_BOOT_TIMEOUT_MS, |
| 160 | | − intervalMs: DAEMON_BOOT_POLL_MS, |
| 161 | | − }); |
| 220 | + const selectedPort = yield* chooseDaemonPort({ |
| 221 | + preferredPort: parsed.port, |
| 222 | + hostname: host, |
| 223 | + }); |
| 162 | 224 | |
| 163 | | − if (!ready) { |
| 164 | | − return yield* Effect.fail( |
| 165 | | − new Error( |
| 166 | | − [ |
| 167 | | − `Daemon did not become reachable at ${baseUrl} within ${DAEMON_BOOT_TIMEOUT_MS}ms.`, |
| 168 | | − `Run in foreground to inspect logs: ${cliPrefix} daemon run --port ${parsed.port} --hostname ${parsed.hostname}`, |
| 169 | | − ].join("\n"), |
| 170 | | − ), |
| 171 | | − ); |
| 225 | + if (selectedPort !== parsed.port) { |
| 226 | + console.error( |
| 227 | + `Port ${parsed.port} is in use. Starting daemon on available port ${selectedPort} instead.`, |
| 228 | + ); |
| 229 | + } |
| 230 | + |
| 231 | + const spec = yield* Effect.try({ |
| 232 | + try: () => |
| 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 | |
| 175 | | −const stopDaemon = (baseUrl: string) => |
| 275 | +const stopDaemon = ( |
| 276 | + baseUrl: string, |
| 277 | +): Effect.Effect<void, Error, FileSystem.FileSystem | PlatformPath.Path> => |
| 176 | 278 | Effect.gen(function* () { |
| 177 | | − const parsed = yield* Effect.try({ |
| 178 | | − try: () => parseDaemonBaseUrl(baseUrl, DEFAULT_PORT), |
| 179 | | − catch: (cause) => |
| 180 | | − cause instanceof Error ? cause : new Error(`Invalid base URL: ${String(cause)}`), |
| 181 | | − }); |
| 182 | | − |
| 183 | | − const host = canonicalDaemonHost(parsed.hostname); |
| 184 | | − const record = yield* readDaemonRecord({ hostname: host, port: parsed.port }); |
| 185 | | − const reachable = yield* isServerReachable(baseUrl); |
| 279 | + const target = yield* resolveDaemonTarget(baseUrl); |
| 280 | + const host = canonicalDaemonHost(target.hostname); |
| 281 | + const scopeId = target.scopeId; |
| 282 | + const record = yield* readDaemonRecord({ hostname: host, port: target.port }); |
| 283 | + const reachable = yield* isServerReachable(target.baseUrl); |
| 186 | 284 | |
| 187 | 285 | if (!record) { |
| 188 | 286 | if (reachable) { |
| 189 | 287 | return yield* Effect.fail( |
| 190 | 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 | 291 | "It may not be managed by this CLI process.", |
| 194 | 292 | "Stop it from the terminal/session where it was started.", |
| 195 | 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 | 298 | return; |
| 201 | 299 | } |
| 202 | 300 | |
| 203 | 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 | 304 | if (reachable) { |
| 206 | 305 | return yield* Effect.fail( |
| 207 | 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 | 309 | "Refusing to stop an unknown process without ownership metadata.", |
| 211 | 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 | 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 | 320 | yield* terminatePid(record.pid); |
| 222 | 321 | |
| 223 | 322 | const stopped = yield* waitForUnreachable({ |
| 224 | | − check: isServerReachable(baseUrl), |
| 323 | + check: isServerReachable(target.baseUrl), |
| 225 | 324 | timeoutMs: DAEMON_STOP_TIMEOUT_MS, |
| 226 | 325 | intervalMs: DAEMON_BOOT_POLL_MS, |
| 227 | 326 | }); |
| @@ -230,16 +329,17 @@ const stopDaemon = (baseUrl: string) => · 2 unmodified lines |
| 230 | 329 | return yield* Effect.fail( |
| 231 | 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 | 333 | "Try terminating the process manually.", |
| 235 | 334 | ].join("\n"), |
| 236 | 335 | ), |
| 237 | 336 | ); |
| 238 | 337 | } |
| 239 | 338 | |
| 240 | | − yield* removeDaemonRecord({ hostname: host, port: parsed.port }); |
| 241 | | − console.log(`Daemon stopped at ${baseUrl}.`); |
| 242 | | − }); |
| 339 | + yield* removeDaemonRecord({ hostname: host, port: target.port }); |
| 340 | + yield* removeDaemonPointer({ hostname: host, scopeId }).pipe(Effect.ignore); |
| 341 | + console.log(`Daemon stopped at ${target.baseUrl}.`); |
| 342 | + }).pipe(Effect.mapError(toError)); |
| 243 | 343 | |
| 244 | 344 | type ExecuteCodeOutcome = |
| 245 | 345 | | { |
| @@ -252,16 +352,13 @@ type ExecuteCodeOutcome = · 6 unmodified lines |
| 252 | 352 | readonly executionId: string | undefined; |
| 253 | 353 | }; |
| 254 | 354 | |
| 255 | | −const toError = (cause: unknown): Error => |
| 256 | | − cause instanceof Error ? cause : new Error(String(cause)); |
| 257 | | − |
| 258 | 355 | const executeCode = (input: { |
| 259 | 356 | baseUrl: string; |
| 260 | 357 | code: string; |
| 261 | | −}): Effect.Effect<ExecuteCodeOutcome, Error> => |
| 358 | +}): Effect.Effect<ExecuteCodeOutcome, Error, FileSystem.FileSystem | PlatformPath.Path> => |
| 262 | 359 | Effect.gen(function* () { |
| 263 | | − yield* ensureDaemon(input.baseUrl); |
| 264 | | − const client = yield* makeApiClient(input.baseUrl); |
| 360 | + const daemonUrl = yield* ensureDaemon(input.baseUrl); |
| 361 | + const client = yield* makeApiClient(daemonUrl); |
| 265 | 362 | const response = yield* client.executions.execute({ |
| 266 | 363 | payload: { |
| 267 | 364 | code: input.code, |
| @@ -361,6 +458,26 @@ const runDaemonSession = (input: { · 93 unmodified lines |
| 361 | 458 | allowedHosts: ReadonlyArray<string>; |
| 362 | 459 | }) => |
| 363 | 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 | 481 | const server = yield* Effect.promise(() => |
| 365 | 482 | startServer({ |
| 366 | 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 | 490 | const daemonPort = server.port; |
| 491 | + const token = randomUUID(); |
| 375 | 492 | |
| 376 | 493 | yield* writeDaemonRecord({ |
| 377 | 494 | hostname: daemonHost, |
| @@ -379,6 +496,14 @@ const runDaemonSession = (input: { · 1 unmodified line |
| 379 | 496 | pid: process.pid, |
| 380 | 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 | 508 | console.log(`Daemon ready on http://${daemonHost}:${daemonPort}`); |
| 384 | 509 | |
| @@ -387,6 +512,7 @@ const runDaemonSession = (input: { · 2 unmodified lines |
| 387 | 512 | } finally { |
| 388 | 513 | yield* Effect.promise(() => server.stop()); |
| 389 | 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 | 595 | Effect.gen(function* () { |
| 470 | 596 | applyScope(scope); |
| 471 | 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 | 601 | const result = yield* client.executions.execute({ payload: { code: resolvedCode } }); |
| 476 | 602 | |
| 477 | 603 | if (result.status === "completed") { |
| @@ -507,12 +633,12 @@ const resumeCommand = Command.make( · 29 unmodified lines |
| 507 | 633 | ({ executionId, action, content, baseUrl, scope }) => |
| 508 | 634 | Effect.gen(function* () { |
| 509 | 635 | applyScope(scope); |
| 510 | | − yield* ensureDaemon(baseUrl); |
| 636 | + const daemonUrl = yield* ensureDaemon(baseUrl); |
| 511 | 637 | |
| 512 | 638 | const parsedContent = Option.getOrUndefined(content); |
| 513 | 639 | const contentObj = parsedContent ? JSON.parse(parsedContent) : undefined; |
| 514 | 640 | |
| 515 | | − const client = yield* makeApiClient(baseUrl); |
| 641 | + const client = yield* makeApiClient(daemonUrl); |
| 516 | 642 | const result = yield* client.executions.resume({ |
| 517 | 643 | path: { executionId }, |
| 518 | 644 | payload: { action: action as "accept" | "decline" | "cancel", content: contentObj }, |
| @@ -711,41 +837,43 @@ const daemonStatusCommand = Command.make( · 192 unmodified lines |
| 711 | 837 | }, |
| 712 | 838 | ({ baseUrl }) => |
| 713 | 839 | Effect.gen(function* () { |
| 714 | | − const parsed = yield* Effect.try({ |
| 715 | | − try: () => parseDaemonBaseUrl(baseUrl, DEFAULT_PORT), |
| 716 | | − catch: (cause) => |
| 717 | | − cause instanceof Error ? cause : new Error(`Invalid base URL: ${String(cause)}`), |
| 718 | | − }); |
| 719 | | − const host = canonicalDaemonHost(parsed.hostname); |
| 840 | + const target = yield* resolveDaemonTarget(baseUrl); |
| 841 | + const host = canonicalDaemonHost(target.hostname); |
| 720 | 842 | |
| 721 | 843 | const [record, reachable] = yield* Effect.all([ |
| 722 | | − readDaemonRecord({ hostname: host, port: parsed.port }), |
| 723 | | − isServerReachable(baseUrl), |
| 844 | + readDaemonRecord({ hostname: host, port: target.port }), |
| 845 | + isServerReachable(target.baseUrl), |
| 724 | 846 | ]); |
| 725 | 847 | |
| 726 | 848 | if (!record) { |
| 727 | 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 | 851 | } else { |
| 730 | | − console.log(`Daemon not running at ${baseUrl}.`); |
| 852 | + console.log(`Daemon not running at ${target.baseUrl}.`); |
| 731 | 853 | } |
| 732 | 854 | return; |
| 733 | 855 | } |
| 734 | 856 | |
| 735 | 857 | if (!isPidAlive(record.pid)) { |
| 736 | 858 | if (!reachable) { |
| 737 | | − yield* removeDaemonRecord({ hostname: host, port: parsed.port }); |
| 738 | | − console.log(`Daemon not running at ${baseUrl} (removed stale record for pid ${record.pid}).`); |
| 859 | + yield* removeDaemonRecord({ hostname: host, port: target.port }); |
| 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 | 864 | return; |
| 740 | 865 | } |
| 741 | 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 | 869 | return; |
| 745 | 870 | } |
| 746 | 871 | |
| 747 | 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 | 877 | if (record.scopeDir) { |
| 750 | 878 | console.log(`Scope: ${record.scopeDir}`); |
| 751 | 879 | } |
| @@ -770,8 +898,8 @@ const daemonRestartCommand = Command.make( · 18 unmodified lines |
| 770 | 898 | Effect.gen(function* () { |
| 771 | 899 | applyScope(scope); |
| 772 | 900 | yield* stopDaemon(baseUrl); |
| 773 | | − yield* ensureDaemon(baseUrl); |
| 774 | | − console.log(`Daemon restarted at ${baseUrl}.`); |
| 901 | + const daemonUrl = yield* ensureDaemon(baseUrl); |
| 902 | + console.log(`Daemon restarted at ${daemonUrl}.`); |
| 775 | 903 | }), |
| 776 | 904 | ).pipe(Command.withDescription("Restart the local daemon")); |
| 777 | 905 | |