| @@ -1,4 +1,5 @@ |
| 1 | // Ensure binaries next to the executor (e.g. secure-exec-v8) are on $PATH | 1 | // Ensure binaries next to the executor (e.g. secure-exec-v8) are on $PATH |
| | 2 | import { randomUUID } from "node:crypto"; |
| 2 | import { dirname, join, resolve } from "node:path"; | 3 | import { dirname, join, resolve } from "node:path"; |
| 3 | const execDir = dirname(process.execPath); | 4 | const execDir = dirname(process.execPath); |
| 4 | if (process.env.PATH && !process.env.PATH.includes(execDir)) { | 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 | import { Command, Options, Args } from "@effect/cli"; | 35 | import { Command, Options, Args } from "@effect/cli"; |
| 35 | import { BunContext, BunRuntime } from "@effect/platform-bun"; | 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 | import * as Effect from "effect/Effect"; | 38 | import * as Effect from "effect/Effect"; |
| 38 | import * as Option from "effect/Option"; | 39 | import * as Option from "effect/Option"; |
| 39 | import * as Cause from "effect/Cause"; | 40 | import * as Cause from "effect/Cause"; |
| @@ -43,6 +44,7 @@ import { startServer, runMcpStdioServer, getExecutor } from "@executor/local"; · 3 unmodified lines |
| 43 | import { makeQuickJsExecutor } from "@executor/runtime-quickjs"; | 44 | import { makeQuickJsExecutor } from "@executor/runtime-quickjs"; |
| 44 | import { | 45 | import { |
| 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"; |
| 52 | import { | 54 | import { |
| | 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"; |
| 60 | import { | 68 | import { |
| @@ -102,9 +110,25 @@ const waitForShutdownSignal = () => · 41 unmodified lines |
| 102 | // Background server management | 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 | const isServerReachable = (baseUrl: string): Effect.Effect<boolean> => | 116 | const 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 |
| 112 | const isDevMode = script?.endsWith(".ts") || script?.endsWith(".js"); | 136 | const isDevMode = script?.endsWith(".ts") || script?.endsWith(".js"); |
| 113 | const cliPrefix = isDevMode ? `bun run ${script}` : "executor"; | 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 | 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({ | 158 | const 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 | |
| | 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 | 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 | |
| 175 | const stopDaemon = (baseUrl: string) => | 275 | const 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 | |
| 244 | type ExecuteCodeOutcome = | 344 | type 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 | |
| 255 | const toError = (cause: unknown): Error => | | |
| 256 | cause instanceof Error ? cause : new Error(String(cause)); | | |
| 257 | | | |
| 258 | const executeCode = (input: { | 355 | const 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 | |