| author | |
| committer | |
| log | 4292720fd927f4c81c21da4e596bdfcedbde32ce |
| tree | 915565debd4933913f27474c3c007b665bb05eec |
| parent | 4d7683557ad4a2411d6de405e500e4a56b43d0e1 |
| signature | Signed by PGP key B5690EEEBB952194 |
5 files changed, 357 insertions(+), 10 deletions(-)
apps/server/src/device/DeviceHost.ts+34-1| ... | ... | @@ -39,6 +39,39 @@ export class DeviceHostTimeoutError extends Schema.TaggedError<DeviceHostTimeout |
| 39 | 39 | } |
| 40 | 40 | } |
| 41 | 41 | |
| 42 | export class AgentDeviceStartError extends Schema.TaggedError<AgentDeviceStartError>()( | |
| 43 | "AgentDeviceStartError", | |
| 44 | { | |
| 45 | hostId: Schema.String, | |
| 46 | stateDir: Schema.String, | |
| 47 | exitCode: Schema.NullOr(Schema.Int), | |
| 48 | reason: Schema.Literals([ | |
| 49 | "daemon_lock_not_directory", | |
| 50 | "daemon_registration_unproven", | |
| 51 | "daemon_retirement_unconfirmed", | |
| 52 | "command_failed", | |
| 53 | ]), | |
| 54 | cause: Schema.Defect(), | |
| 55 | }, | |
| 56 | ) { | |
| 57 | override get message(): string { | |
| 58 | const exit = this.exitCode === null ? "" : ` (exit code ${this.exitCode})`; | |
| 59 | const detail = | |
| 60 | this.reason === "daemon_lock_not_directory" | |
| 61 | ? "Daemon registration ownership could not be verified: daemon.lock is not a directory." | |
| 62 | : this.reason === "daemon_registration_unproven" | |
| 63 | ? "Daemon registration ownership could not be verified." | |
| 64 | : this.reason === "daemon_retirement_unconfirmed" | |
| 65 | ? "Daemon replacement could not be confirmed." | |
| 66 | : "The AgentDevice startup command failed."; | |
| 67 | const recovery = | |
| 68 | this.reason === "command_failed" | |
| 69 | ? `Inspect daemon diagnostics in ${this.stateDir}.` | |
| 70 | : `Inspect daemon.lock and daemon diagnostics in ${this.stateDir}. Stop older clients and daemons with their original CLI before manually repairing retained state.`; | |
| 71 | return `Agent tools failed to start${exit}. ${detail} ${recovery}`; | |
| 72 | } | |
| 73 | } | |
| 74 | ||
| 42 | 75 | export interface DeviceHubEndpoint { |
| 43 | 76 | /** Loopback origin of expo-device-hub, e.g. `http://127.0.0.1:3400`. */ |
| 44 | 77 | readonly origin: string; |
| ... | ... | @@ -96,7 +129,7 @@ export class DeviceHost extends Context.Service< |
| 96 | 129 | onPhase: (phase: "installing" | "starting", detail?: string) => Effect.Effect<void>, |
| 97 | 130 | ) => Effect.Effect< |
| 98 | 131 | DeviceHostAgentReady, |
| 99 | DeviceHostError | DeviceHostTimeoutError | NodeRuntimeUnavailableError | |
| 132 | DeviceHostError | DeviceHostTimeoutError | AgentDeviceStartError | NodeRuntimeUnavailableError | |
| 100 | 133 | >; |
| 101 | 134 | /** Current endpoints when already running, without starting anything. */ |
| 102 | 135 | readonly current: Effect.Effect<DeviceHostReady | null>; |
apps/server/src/device/DeviceService.test.ts+36-2| ... | ... | @@ -66,7 +66,10 @@ const fixture = Effect.fn("fixture")(function* ( |
| 66 | 66 | onBoot: Effect.Effect<void> = Effect.void, |
| 67 | 67 | bootError?: string, |
| 68 | 68 | failListAfterShutdown = false, |
| 69 | runtimeFailure?: NodeRuntimeUnavailableError | DeviceHost.DeviceHostError, | |
| 69 | runtimeFailure?: | |
| 70 | | NodeRuntimeUnavailableError | |
| 71 | | DeviceHost.DeviceHostError | |
| 72 | | DeviceHost.AgentDeviceStartError, | |
| 70 | 73 | inspectError = false, |
| 71 | 74 | installTool?: Parameters<typeof DeviceService.makeWithHosts>[3], |
| 72 | 75 | ) { |
| ... | ... | @@ -107,7 +110,8 @@ const fixture = Effect.fn("fixture")(function* ( |
| 107 | 110 | platformAvailability: (platform) => Effect.succeed({ platform, available: true }), |
| 108 | 111 | ensureReady: (onPhase) => |
| 109 | 112 | Effect.gen(function* () { |
| 110 | if (runtimeFailure) return yield* runtimeFailure; | |
| 113 | if (runtimeFailure && runtimeFailure._tag !== "AgentDeviceStartError") | |
| 114 | return yield* runtimeFailure; | |
| 111 | 115 | starts.push("start"); |
| 112 | 116 | yield* onPhase("installing", "Updating device hub from 0.9.0 to 0.10.1…"); |
| 113 | 117 | return ready; |
| ... | ... | @@ -217,6 +221,36 @@ const fixture = Effect.fn("fixture")(function* ( |
| 217 | 221 | }); |
| 218 | 222 | |
| 219 | 223 | describe("device setup consent", () => { |
| 224 | it.effect("exposes daemon startup diagnostics through agent readiness and host status", () => | |
| 225 | Effect.gen(function* () { | |
| 226 | const startupError = new DeviceHost.AgentDeviceStartError({ | |
| 227 | hostId: LOCAL_DEVICE_HOST_ID, | |
| 228 | stateDir: "/test/device/agent-device", | |
| 229 | exitCode: 1, | |
| 230 | reason: "daemon_lock_not_directory", | |
| 231 | cause: { stdout: "private startup diagnostics", code: 1 }, | |
| 232 | }); | |
| 233 | const { service, settings } = yield* fixture(Effect.void, undefined, false, startupError); | |
| 234 | yield* Ref.update(settings, (current) => ({ | |
| 235 | ...current, | |
| 236 | enableDeviceSupport: true, | |
| 237 | enableAgentDeviceAccess: true, | |
| 238 | })); | |
| 239 | const error = yield* service.agentReadinessIfSupported().pipe(Effect.flip); | |
| 240 | expect(error).toMatchObject({ | |
| 241 | _tag: "DeviceHostUnavailableError", | |
| 242 | reason: startupError.message, | |
| 243 | cause: startupError, | |
| 244 | }); | |
| 245 | expect(error.message).toContain("daemon.lock is not a directory"); | |
| 246 | expect(error.message).not.toContain("private startup diagnostics"); | |
| 247 | expect((yield* service.state).hostStatuses[LOCAL_DEVICE_HOST_ID]).toEqual({ | |
| 248 | status: "failed", | |
| 249 | detail: startupError.message, | |
| 250 | }); | |
| 251 | }).pipe(Effect.scoped), | |
| 252 | ); | |
| 253 | ||
| 220 | 254 | it.effect( |
| 221 | 255 | "preserves missing-runtime guidance and causes through manual and agent readiness", |
| 222 | 256 | () => |
apps/server/src/device/DeviceService.ts+5-3| ... | ... | @@ -337,9 +337,11 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* |
| 337 | 337 | ? nodeRuntimeUnavailableMessage("Local device support") |
| 338 | 338 | : error._tag === "DeviceHostTimeoutError" |
| 339 | 339 | ? `Agent tools did not start within ${error.timeoutMs} ms.` |
| 340 | : error.step === "probe" | |
| 341 | ? "Could not connect to this host over SSH." | |
| 342 | : `Device support failed during ${error.step}.`, | |
| 340 | : error._tag === "AgentDeviceStartError" | |
| 341 | ? error.message | |
| 342 | : error.step === "probe" | |
| 343 | ? "Could not connect to this host over SSH." | |
| 344 | : `Device support failed during ${error.step}.`, | |
| 343 | 345 | cause: error, |
| 344 | 346 | }), |
| 345 | 347 | ), |
apps/server/src/device/LocalDeviceHost.test.ts+223-1| ... | ... | @@ -6,14 +6,236 @@ import * as Layer from "effect/Layer"; |
| 6 | 6 | import * as Option from "effect/Option"; |
| 7 | 7 | import * as Path from "effect/Path"; |
| 8 | 8 | import * as FileSystem from "effect/FileSystem"; |
| 9 | import * as Sink from "effect/Sink"; | |
| 10 | import * as Stream from "effect/Stream"; | |
| 9 | 11 | |
| 10 | 12 | import * as LocalDeviceHost from "./LocalDeviceHost.ts"; |
| 11 | 13 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 12 | 14 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 13 | import { HttpClient } from "effect/http"; | |
| 15 | import { HttpClient, HttpClientResponse } from "effect/http"; | |
| 14 | 16 | import * as NetService from "@t3tools/shared/Net"; |
| 15 | 17 | import * as ServerConfig from "../config.ts"; |
| 16 | 18 | import * as ProcessRunner from "../processRunner.ts"; |
| 19 | import { | |
| 20 | AGENT_DEVICE_VERSION, | |
| 21 | DEVICE_HUB_VERSION, | |
| 22 | agentDeviceStateDir, | |
| 23 | } from "./DeviceToolchain.ts"; | |
| 24 | ||
| 25 | const processOutput = (code: number, stdout = "", stderr = "", timedOut = false) => ({ | |
| 26 | code: ChildProcessSpawner.ExitCode(code), | |
| 27 | stdout, | |
| 28 | stderr, | |
| 29 | timedOut, | |
| 30 | stdoutTruncated: false, | |
| 31 | stderrTruncated: false, | |
| 32 | stdoutInvalidUtf8: false, | |
| 33 | stderrInvalidUtf8: false, | |
| 34 | }); | |
| 35 | ||
| 36 | const agentFixture = Effect.fn("agentFixture")(function* ( | |
| 37 | startup: ( | |
| 38 | stateDir: string, | |
| 39 | ) => Effect.Effect<ProcessRunner.ProcessRunOutput, ProcessRunner.ProcessRunError>, | |
| 40 | ) { | |
| 41 | const fs = yield* FileSystem.FileSystem; | |
| 42 | const path = yield* Path.Path; | |
| 43 | const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-device-agent-start-" }); | |
| 44 | const stateDir = agentDeviceStateDir(path, path.join(baseDir, "userdata")); | |
| 45 | for (const [name, version, entry] of [ | |
| 46 | ["expo-device-hub", DEVICE_HUB_VERSION, "dist/server/cli.mjs"], | |
| 47 | ["agent-device", AGENT_DEVICE_VERSION, "bin/agent-device.mjs"], | |
| 48 | ]) { | |
| 49 | const installDir = path.join(baseDir, "tools", name!, version!); | |
| 50 | const entryPath = path.join(installDir, "node_modules", name!, entry!); | |
| 51 | yield* fs.makeDirectory(path.dirname(entryPath), { recursive: true }); | |
| 52 | yield* fs.writeFileString(entryPath, ""); | |
| 53 | yield* fs.writeFileString(path.join(installDir, ".install-complete"), version!); | |
| 54 | } | |
| 55 | const host = yield* LocalDeviceHost.make().pipe( | |
| 56 | Effect.provide(Layer.mergeAll(ServerConfig.layerTest(baseDir, baseDir), NetService.layer)), | |
| 57 | Effect.provideService(HostProcess.Environment, { HOME: baseDir, PATH: "" }), | |
| 58 | Effect.provideService(HostProcess.IsExecutable, false), | |
| 59 | Effect.provideService(HostProcess.Platform, "darwin"), | |
| 60 | Effect.provideService( | |
| 61 | ChildProcessSpawner.ChildProcessSpawner, | |
| 62 | ChildProcessSpawner.make(() => | |
| 63 | Effect.succeed( | |
| 64 | ChildProcessSpawner.makeHandle({ | |
| 65 | pid: ChildProcessSpawner.ProcessId(123), | |
| 66 | stdout: Stream.empty, | |
| 67 | stderr: Stream.empty, | |
| 68 | all: Stream.empty, | |
| 69 | exitCode: Effect.never, | |
| 70 | isRunning: Effect.succeed(true), | |
| 71 | kill: () => Effect.void, | |
| 72 | stdin: Sink.drain, | |
| 73 | getInputFd: () => Sink.drain, | |
| 74 | getOutputFd: () => Stream.empty, | |
| 75 | unref: Effect.succeed(Effect.void), | |
| 76 | }), | |
| 77 | ), | |
| 78 | ), | |
| 79 | ), | |
| 80 | Effect.provideService(ProcessRunner.ProcessRunner, { | |
| 81 | run: (input) => | |
| 82 | input.args.includes("devices") ? startup(stateDir) : Effect.succeed(processOutput(0)), | |
| 83 | }), | |
| 84 | Effect.provideService( | |
| 85 | HttpClient.HttpClient, | |
| 86 | HttpClient.make((request) => | |
| 87 | Effect.succeed(HttpClientResponse.fromWeb(request, new Response("ok"))), | |
| 88 | ), | |
| 89 | ), | |
| 90 | ); | |
| 91 | yield* host.ensureReady(() => Effect.void); | |
| 92 | return { host, fs, stateDir }; | |
| 93 | }); | |
| 94 | ||
| 95 | describe("AgentDevice startup", () => { | |
| 96 | it.effect("reports an incompatible lock immediately and retains it for manual repair", () => | |
| 97 | Effect.gen(function* () { | |
| 98 | const result = processOutput( | |
| 99 | 1, | |
| 100 | JSON.stringify({ | |
| 101 | success: false, | |
| 102 | error: { | |
| 103 | message: "private command output", | |
| 104 | details: { | |
| 105 | reason: "daemon_registration_unproven", | |
| 106 | inspection: { state: "unproven", reason: "non-directory" }, | |
| 107 | }, | |
| 108 | }, | |
| 109 | }), | |
| 110 | ); | |
| 111 | const { host, fs, stateDir } = yield* agentFixture(() => Effect.succeed(result)); | |
| 112 | const lock = `${stateDir}/daemon.lock`; | |
| 113 | const legacyOwner = JSON.stringify({ pid: 85669, processStartTime: "old start time" }); | |
| 114 | yield* fs.writeFileString(lock, legacyOwner); | |
| 115 | const error = yield* host.ensureAgentReady(() => Effect.void).pipe(Effect.flip); | |
| 116 | expect(error).toMatchObject({ | |
| 117 | _tag: "AgentDeviceStartError", | |
| 118 | reason: "daemon_lock_not_directory", | |
| 119 | exitCode: 1, | |
| 120 | cause: result, | |
| 121 | }); | |
| 122 | expect(error.message).toContain("Daemon registration ownership could not be verified"); | |
| 123 | expect(error.message).toContain("daemon.lock is not a directory"); | |
| 124 | expect(error.message).toContain(stateDir); | |
| 125 | expect(error.message).toContain("original CLI"); | |
| 126 | expect(error.message).not.toContain("private command output"); | |
| 127 | expect(yield* fs.readFileString(lock)).toBe(legacyOwner); | |
| 128 | expect(yield* host.current).not.toBeNull(); | |
| 129 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 130 | ); | |
| 131 | ||
| 132 | it.effect("reports unknown nonzero exits without exposing arbitrary command output", () => | |
| 133 | Effect.gen(function* () { | |
| 134 | const result = processOutput(2, "invalid JSON", "private credentials"); | |
| 135 | const { host, stateDir } = yield* agentFixture(() => Effect.succeed(result)); | |
| 136 | const error = yield* host.ensureAgentReady(() => Effect.void).pipe(Effect.flip); | |
| 137 | expect(error).toMatchObject({ | |
| 138 | _tag: "AgentDeviceStartError", | |
| 139 | reason: "command_failed", | |
| 140 | exitCode: 2, | |
| 141 | cause: result, | |
| 142 | }); | |
| 143 | expect(error.message).toContain("exit code 2"); | |
| 144 | expect(error.message).toContain(stateDir); | |
| 145 | expect(error.message).not.toContain("private credentials"); | |
| 146 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 147 | ); | |
| 148 | ||
| 149 | it.effect("preserves process execution failures instead of waiting for a daemon file", () => | |
| 150 | Effect.gen(function* () { | |
| 151 | const cause = new ProcessRunner.ProcessSpawnError({ | |
| 152 | command: "/missing/node", | |
| 153 | argumentCount: 3, | |
| 154 | cause: new Error("missing executable"), | |
| 155 | }); | |
| 156 | const { host } = yield* agentFixture(() => Effect.fail(cause)); | |
| 157 | const error = yield* host.ensureAgentReady(() => Effect.void).pipe(Effect.flip); | |
| 158 | expect(error).toMatchObject({ | |
| 159 | _tag: "DeviceHostError", | |
| 160 | step: "starting agent tools", | |
| 161 | cause, | |
| 162 | }); | |
| 163 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 164 | ); | |
| 165 | ||
| 166 | it.effect("returns a command timeout without starting another readiness wait", () => | |
| 167 | Effect.gen(function* () { | |
| 168 | const { host } = yield* agentFixture(() => Effect.succeed(processOutput(124, "", "", true))); | |
| 169 | const error = yield* host.ensureAgentReady(() => Effect.void).pipe(Effect.flip); | |
| 170 | expect(error).toMatchObject({ _tag: "DeviceHostTimeoutError", timeoutMs: 30_000 }); | |
| 171 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 172 | ); | |
| 173 | ||
| 174 | it.effect("uses a registered daemon when reading startup command output fails", () => | |
| 175 | Effect.gen(function* () { | |
| 176 | const fs = yield* FileSystem.FileSystem; | |
| 177 | const cause = new ProcessRunner.ProcessReadError({ | |
| 178 | command: process.execPath, | |
| 179 | argumentCount: 3, | |
| 180 | stream: "stdout", | |
| 181 | cause: new Error("output read failed after registration"), | |
| 182 | }); | |
| 183 | let attempts = 0; | |
| 184 | const { host } = yield* agentFixture((stateDir) => | |
| 185 | Effect.gen(function* () { | |
| 186 | attempts++; | |
| 187 | yield* fs | |
| 188 | .writeFileString( | |
| 189 | `${stateDir}/daemon.json`, | |
| 190 | JSON.stringify({ httpPort: 12345, token: "test-token" }), | |
| 191 | ) | |
| 192 | .pipe(Effect.orDie); | |
| 193 | return yield* cause; | |
| 194 | }), | |
| 195 | ); | |
| 196 | const ready = yield* host.ensureAgentReady(() => Effect.void); | |
| 197 | expect(ready.agentDevice).toMatchObject({ | |
| 198 | baseUrl: "http://127.0.0.1:12345", | |
| 199 | token: "test-token", | |
| 200 | }); | |
| 201 | expect((yield* host.ensureAgentReady(() => Effect.void)).agentDevice).toEqual( | |
| 202 | ready.agentDevice, | |
| 203 | ); | |
| 204 | expect(attempts).toBe(1); | |
| 205 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 206 | ); | |
| 207 | ||
| 208 | it.effect("allows retry and uses a registered daemon even when discovery exits nonzero", () => | |
| 209 | Effect.gen(function* () { | |
| 210 | const fs = yield* FileSystem.FileSystem; | |
| 211 | let attempts = 0; | |
| 212 | const { host } = yield* agentFixture((stateDir) => | |
| 213 | Effect.gen(function* () { | |
| 214 | attempts++; | |
| 215 | if (attempts > 1) { | |
| 216 | yield* fs | |
| 217 | .writeFileString( | |
| 218 | `${stateDir}/daemon.json`, | |
| 219 | JSON.stringify({ httpPort: 12345, token: "test-token" }), | |
| 220 | ) | |
| 221 | .pipe(Effect.orDie); | |
| 222 | } | |
| 223 | return processOutput(1); | |
| 224 | }), | |
| 225 | ); | |
| 226 | yield* host.ensureAgentReady(() => Effect.void).pipe(Effect.flip); | |
| 227 | const ready = yield* host.ensureAgentReady(() => Effect.void); | |
| 228 | expect(ready.agentDevice).toMatchObject({ | |
| 229 | baseUrl: "http://127.0.0.1:12345", | |
| 230 | token: "test-token", | |
| 231 | }); | |
| 232 | expect((yield* host.ensureAgentReady(() => Effect.void)).agentDevice).toEqual( | |
| 233 | ready.agentDevice, | |
| 234 | ); | |
| 235 | expect(attempts).toBe(2); | |
| 236 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 237 | ); | |
| 238 | }); | |
| 17 | 239 | |
| 18 | 240 | const diagnose = ( |
| 19 | 241 | files: ReadonlyArray<string>, |
apps/server/src/device/LocalDeviceHost.ts+59-3| ... | ... | @@ -86,6 +86,19 @@ const AgentDeviceDaemonFile = Schema.Struct({ |
| 86 | 86 | }); |
| 87 | 87 | const decodeDaemonFile = Schema.decodeUnknownEffect(Schema.fromJsonString(AgentDeviceDaemonFile)); |
| 88 | 88 | |
| 89 | const decodeAgentDeviceFailure = Schema.decodeUnknownOption( | |
| 90 | Schema.fromJsonString( | |
| 91 | Schema.Struct({ | |
| 92 | error: Schema.Struct({ | |
| 93 | details: Schema.Struct({ | |
| 94 | reason: Schema.String, | |
| 95 | inspection: Schema.optional(Schema.Struct({ reason: Schema.String })), | |
| 96 | }), | |
| 97 | }), | |
| 98 | }), | |
| 99 | ), | |
| 100 | ); | |
| 101 | ||
| 89 | 102 | interface HubProcess { |
| 90 | 103 | readonly nodePath: string; |
| 91 | 104 | readonly child: ChildProcessSpawner.ChildProcessHandle; |
| ... | ... | @@ -498,7 +511,12 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { |
| 498 | 511 | const startAgentDeviceDaemon = Effect.fn("LocalDeviceHost.startAgentDeviceDaemon")(function* ( |
| 499 | 512 | agentTool: DeviceToolPaths, |
| 500 | 513 | nodePath: string, |
| 501 | ): Effect.fn.Return<DeviceHost.AgentDeviceEndpoint, DeviceHost.DeviceHostTimeoutError> { | |
| 514 | ): Effect.fn.Return< | |
| 515 | DeviceHost.AgentDeviceEndpoint, | |
| 516 | | DeviceHost.DeviceHostError | |
| 517 | | DeviceHost.DeviceHostTimeoutError | |
| 518 | | DeviceHost.AgentDeviceStartError | |
| 519 | > { | |
| 502 | 520 | const stateDir = agentDeviceStateDir(path, config.stateDir); |
| 503 | 521 | yield* fs.makeDirectory(stateDir, { recursive: true }).pipe(Effect.ignore); |
| 504 | 522 | const existing = yield* readDaemonFile().pipe(Effect.option); |
| ... | ... | @@ -536,7 +554,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { |
| 536 | 554 | } |
| 537 | 555 | // There is no `daemon start`; the first command in a state dir spawns the |
| 538 | 556 | // daemon and blocks until it answers. `devices` is the cheapest one. |
| 539 | yield* runner | |
| 557 | const runResult = yield* runner | |
| 540 | 558 | .run({ |
| 541 | 559 | command: nodePath, |
| 542 | 560 | args: [agentTool.entryPath, "devices", "--json"], |
| ... | ... | @@ -544,7 +562,44 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { |
| 544 | 562 | timeout: Duration.millis(DAEMON_READY_TIMEOUT_MS), |
| 545 | 563 | timeoutBehavior: "timedOutResult", |
| 546 | 564 | }) |
| 547 | .pipe(Effect.ignore); | |
| 565 | .pipe(Effect.result); | |
| 566 | // Discovery can fail after the daemon starts. A published endpoint still | |
| 567 | // lets agents connect; absent one, preserve the CLI failure instead of polling. | |
| 568 | const started = yield* readDaemonFile().pipe(Effect.option); | |
| 569 | if (started._tag === "Some") return toEndpoint(started.value); | |
| 570 | if (runResult._tag === "Failure") { | |
| 571 | return yield* new DeviceHost.DeviceHostError({ | |
| 572 | hostId, | |
| 573 | step: "starting agent tools", | |
| 574 | cause: runResult.failure, | |
| 575 | }); | |
| 576 | } | |
| 577 | const result = runResult.success; | |
| 578 | if (result.timedOut) { | |
| 579 | return yield* new DeviceHost.DeviceHostTimeoutError({ | |
| 580 | hostId, | |
| 581 | timeoutMs: DAEMON_READY_TIMEOUT_MS, | |
| 582 | }); | |
| 583 | } | |
| 584 | if (result.code !== 0) { | |
| 585 | const failure = decodeAgentDeviceFailure(result.stdout); | |
| 586 | const details = Option.isSome(failure) ? failure.value.error.details : undefined; | |
| 587 | const reason = | |
| 588 | details?.reason === "daemon_registration_unproven" | |
| 589 | ? details.inspection?.reason === "non-directory" | |
| 590 | ? "daemon_lock_not_directory" | |
| 591 | : "daemon_registration_unproven" | |
| 592 | : details?.reason === "daemon_retirement_unconfirmed" | |
| 593 | ? "daemon_retirement_unconfirmed" | |
| 594 | : "command_failed"; | |
| 595 | return yield* new DeviceHost.AgentDeviceStartError({ | |
| 596 | hostId, | |
| 597 | stateDir, | |
| 598 | exitCode: result.code === null ? null : Number(result.code), | |
| 599 | reason, | |
| 600 | cause: result, | |
| 601 | }); | |
| 602 | } | |
| 548 | 603 | const deadline = (yield* Clock.currentTimeMillis) + DAEMON_READY_TIMEOUT_MS; |
| 549 | 604 | while (true) { |
| 550 | 605 | const file = yield* readDaemonFile().pipe(Effect.option); |
| ... | ... | @@ -658,6 +713,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { |
| 658 | 713 | unknown, |
| 659 | 714 | | DeviceHost.DeviceHostError |
| 660 | 715 | | DeviceHost.DeviceHostTimeoutError |
| 716 | | DeviceHost.AgentDeviceStartError | |
| 661 | 717 | | NodeRuntimeUnavailableError |
| 662 | 718 | >, |
| 663 | 719 | DeviceHost.DeviceHostAgentReady |