diff --git a/docs/CLI.md b/docs/CLI.md index 0152b961..3b17dd2b 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -2126,6 +2126,24 @@ warm-pool-target-unreachable iPhone 17 / 27.0 / full: iOS 27.0 is not installed It never changes the exit code, and `--fix` never acts on it. With `warmPool.enabled` `false` there is none. +With `agentDevice.enabled` `true`, `doctor` reports an `agent-device-unavailable` +finding when the agent-device daemon is not running: agent-device is not on +`PATH`, is too old or speaks another RPC protocol, is not agent-device at all, +cannot use its state folder, or failed to start. It carries what was found, what +is needed, and what to do, and is printed to stderr as one line, cut to 500 +characters for each of the two texts: + +```text +agent-device fail found 0.20.3, need 0.21.25 or later; install agent-device 0.21.25 or later, then restart the daemon (`simlock daemon stop`, then `simlock daemon start`) +``` + +While agent-device is still starting, or running, or switched off, there is +none. The check runs when the daemon starts agent-device, so after fixing the +cause, restart the daemon, except when agent-device failed to start: the +daemon retries a failed start every 30 seconds, so the finding clears by +itself once the cause is fixed. It never changes the exit code, and `--fix` +never acts on it. + ## `simlock nuke [--delete-devices] [--yes]` Emergency reset: force-release all leases, kill emulator/simulator processes diff --git a/src/agent-device/host/availability-findings.test.ts b/src/agent-device/host/availability-findings.test.ts new file mode 100644 index 00000000..5a7dab55 --- /dev/null +++ b/src/agent-device/host/availability-findings.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; + +import type { AgentDeviceAvailability } from "./agent-device-supervisor.js"; +import { agentDeviceFindings } from "./availability-findings.js"; + +describe("agentDeviceFindings", () => { + const reasons = [ + "not-found", + "not-agent-device", + "wrong-version", + "state-dir", + "start-failed", + ] as const; + + it.each(reasons)( + "returns one finding with the availability's message and remedy for the %s reason", + (reason) => { + const availability: AgentDeviceAvailability = { + message: `message for ${reason}`, + reason, + remedy: `remedy for ${reason}`, + state: "unavailable", + }; + + expect(agentDeviceFindings(availability)).toStrictEqual([ + { + kind: "agent-device-unavailable", + message: `message for ${reason}`, + remedy: `remedy for ${reason}`, + }, + ]); + }, + ); + + it.each([ + { state: "off" }, + { state: "starting" }, + { + baseUrl: "http://127.0.0.1:4310", + generation: 1, + leaseMaxTtlMs: 1000, + platforms: ["ios"], + state: "running", + token: "t", + version: "0.21.25", + }, + ] as const satisfies readonly AgentDeviceAvailability[])( + "returns no finding for the $state state", + (availability) => { + expect(agentDeviceFindings(availability)).toStrictEqual([]); + }, + ); +}); diff --git a/src/agent-device/host/availability-findings.ts b/src/agent-device/host/availability-findings.ts new file mode 100644 index 00000000..a977d81b --- /dev/null +++ b/src/agent-device/host/availability-findings.ts @@ -0,0 +1,15 @@ +import type { OptionalModuleFinding } from "../../core/index.js"; +import type { AgentDeviceAvailability } from "./agent-device-supervisor.js"; + +export function agentDeviceFindings( + availability: AgentDeviceAvailability, +): readonly OptionalModuleFinding[] { + if (availability.state !== "unavailable") return []; + return [ + { + kind: "agent-device-unavailable", + message: availability.message, + remedy: availability.remedy, + }, + ]; +} diff --git a/src/agent-device/index.ts b/src/agent-device/index.ts index 15db789b..7d711624 100644 --- a/src/agent-device/index.ts +++ b/src/agent-device/index.ts @@ -1,3 +1,4 @@ +export { agentDeviceFindings } from "./host/availability-findings.js"; export { AgentDeviceSupervisor } from "./host/agent-device-supervisor.js"; // fallow-ignore-next-line unused-type -- the availability contract the front (#472) and doctor (#471) read. export type { AgentDeviceAvailability } from "./host/agent-device-supervisor.js"; diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index 625e1955..dc00f8bb 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -1369,6 +1369,75 @@ describe("CLI: daemon status (ADR 0003 §11)", () => { expect(JSON.parse(output.stdout)).toEqual({ findings }); }); + it("simlock doctor writes agent-device fail ; on stderr, one line of at most 500 characters per part, after the advisory, prerequisite and warm-pool lines, prints the report on stdout unchanged, and exits 0", async () => { + const output = outputCapture(); + const findings = [ + { + kind: "agent-device-unavailable" as const, + message: `found 0.20.3,\n need ${"x".repeat(600)}`, + remedy: `install agent-device 0.21.25 or later,\nthen ${"y".repeat(600)}`, + }, + { + kind: "warm-pool-target-unreachable" as const, + message: "iOS 27.0 is not installed", + platform: "ios" as const, + reason: "runtime-missing" as const, + remedy: "run simlock component install ios 27.0", + target: "iPhone 17 / 27.0 / full", + }, + { + kind: "prerequisite-missing" as const, + message: "Xcode is not installed.", + platform: "ios" as const, + prerequisite: "xcode", + remedy: "Run `xcode-select`.", + }, + { + code: "slim-runtime-unsupported", + kind: "driver-advisory" as const, + message: "detail", + platform: "ios" as const, + }, + ]; + + await expect( + runCli( + ["doctor"], + output.environmentWith({ + connectAdmin: async () => + fakeClient({ runDoctor: () => Promise.resolve({ findings } as never) }), + }), + ), + ).resolves.toBe(0); + + const lines = output.stderr.split("\n"); + const index = lines.findIndex((line) => line.startsWith("agent-device fail ")); + expect(index).toBeGreaterThan(-1); + expect(lines[index]).toBe( + `agent-device fail found 0.20.3, need ${"x".repeat(500 - "found 0.20.3, need ".length)}; ${("install agent-device 0.21.25 or later, then " + "y".repeat(600)).slice(0, 500)}`, + ); + expect(lines.filter((line) => line.startsWith("agent-device"))).toHaveLength(1); + for (const earlier of ["Warning [ios]", "Missing [ios]", "warm-pool-target-unreachable"]) { + expect(lines.findIndex((line) => line.startsWith(earlier))).toBeLessThan(index); + expect(lines.findIndex((line) => line.startsWith(earlier))).toBeGreaterThan(-1); + } + expect(JSON.parse(output.stdout)).toEqual({ findings }); + }); + + it("simlock doctor writes no agent-device line when the report has no such finding", async () => { + const output = outputCapture(); + const findings = [{ deviceId: "d-1", kind: "expired-live-lease" as const, leaseId: "l-1" }]; + + await runCli( + ["doctor"], + output.environmentWith({ + connectAdmin: async () => fakeClient({ runDoctor: () => Promise.resolve({ findings }) }), + }), + ); + + expect(output.stderr).not.toContain("agent-device"); + }); + it("keeps --purge-orphans off the wire when --fix is all that was asked for", async () => { const output = outputCapture(); const seen: unknown[] = []; diff --git a/src/cli/index.ts b/src/cli/index.ts index 082e78b8..8bde4dd2 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1391,6 +1391,7 @@ async function runDoctor( writeDriverAdvisoryWarnings(environment, response); writeMissingPrerequisites(environment, response); writeUnreachableWarmTargets(environment, response); + writeAgentDeviceUnavailable(environment, response); writeResult(environment, response); return 0; } finally { @@ -1441,6 +1442,16 @@ function writeUnreachableWarmTargets(environment: CliEnvironment, report: Doctor } } +function writeAgentDeviceUnavailable(environment: CliEnvironment, report: DoctorReport): void { + const oneLine = (text: string): string => text.replace(/\s+/g, " ").slice(0, 500); + for (const finding of report.findings) { + if (finding.kind !== "agent-device-unavailable") continue; + environment.stderr.write( + `agent-device fail ${oneLine(finding.message)}; ${oneLine(finding.remedy)}\n`, + ); + } +} + async function runNuke( argv: readonly string[], environment: CliEnvironment, diff --git a/src/contract/protocol.ts b/src/contract/protocol.ts index c5c03604..a11ac0b6 100644 --- a/src/contract/protocol.ts +++ b/src/contract/protocol.ts @@ -10,7 +10,7 @@ export interface ProtocolRange { const protocolRangeSchema = z.object({ min: z.number().int(), max: z.number().int() }); /** Widen only once a second version is kept alive side by side with the first; a bump that leaves no shim behind moves both ends. */ -export const PROTOCOL_VERSION_RANGE: ProtocolRange = { min: 30, max: 30 }; +export const PROTOCOL_VERSION_RANGE: ProtocolRange = { min: 31, max: 31 }; const LEGACY_DAEMON_PROTOCOL_VERSION = 2; diff --git a/src/contract/schemas.test.ts b/src/contract/schemas.test.ts index 5b71ee62..862df4e7 100644 --- a/src/contract/schemas.test.ts +++ b/src/contract/schemas.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { deviceRecordSchema, + doctorReportSchema, fitHostFacts, fitPlatformCatalog, grantedDeviceSchema, @@ -649,3 +650,22 @@ describe("the status device entry's absent state (ADR 0022 §7)", () => { expect(parsed({ ...absent, absentReason: "unplugged" }).success).toBe(false); }); }); + +describe("doctorReportSchema's agent-device-unavailable finding", () => { + const finding = { + kind: "agent-device-unavailable", + message: "found 0.20.3, need 0.21.25 or later", + remedy: "install agent-device 0.21.25 or later", + }; + + it("parses a report with the finding, and refuses one without message or without remedy", () => { + expect(doctorReportSchema.parse({ findings: [finding] })).toStrictEqual({ + findings: [finding], + }); + + const { message: _message, ...withoutMessage } = finding; + const { remedy: _remedy, ...withoutRemedy } = finding; + expect(doctorReportSchema.safeParse({ findings: [withoutMessage] }).success).toBe(false); + expect(doctorReportSchema.safeParse({ findings: [withoutRemedy] }).success).toBe(false); + }); +}); diff --git a/src/contract/schemas.ts b/src/contract/schemas.ts index 0cb10788..c1b4ee91 100644 --- a/src/contract/schemas.ts +++ b/src/contract/schemas.ts @@ -689,6 +689,11 @@ const doctorFindingSchema = z.discriminatedUnion("kind", [ message: z.string(), remedy: z.string(), }), + z.object({ + kind: z.literal("agent-device-unavailable"), + message: z.string(), + remedy: z.string(), + }), ]); export const doctorReportSchema = z.object({ findings: z.array(doctorFindingSchema) }); diff --git a/src/core/create-core.ts b/src/core/create-core.ts index ffc50c78..df08a5c6 100644 --- a/src/core/create-core.ts +++ b/src/core/create-core.ts @@ -19,7 +19,7 @@ import type { } from "./core-ports.js"; import { DeviceOperationClaims } from "./device-operation-claims.js"; import { DeviceProvisioner } from "./device-provisioner.js"; -import { Doctor } from "./doctor.js"; +import { Doctor, type OptionalModuleFinding } from "./doctor.js"; import type { DeviceMode, Platform, TargetResolution, WaitingDemand } from "./domain.js"; import type { DeviceRequest, Driver, DriverRejection, PrerequisiteCheck } from "./driver.js"; import { DriverCatalog, type ModelPreferences } from "./driver-catalog.js"; @@ -61,6 +61,7 @@ interface CoreOptions { readonly logger?: Logger | undefined; /** ADR 0015 §4: the model names to try for each class, per platform, operator's list first. */ readonly modelPreferences?: ModelPreferences | undefined; + readonly optionalModules?: (() => readonly OptionalModuleFinding[]) | undefined; /** The checks `doctor` runs per platform this host could run. None if omitted. */ readonly prerequisiteChecks?: readonly PrerequisiteCheck[] | undefined; readonly registry: Registry; @@ -283,6 +284,7 @@ export function createCore(options: CoreOptions): Core { eventBus: options.eventBus, leaseExpirer: { expire: async (leaseId) => port("leaseExpirer").expire(leaseId) }, ...(options.logger === undefined ? {} : { logger: options.logger }), + ...(options.optionalModules === undefined ? {} : { optionalModules: options.optionalModules }), prerequisiteChecks: options.prerequisiteChecks ?? [], quarantine, registry, diff --git a/src/core/doctor.test.ts b/src/core/doctor.test.ts index b7f89c58..2a5494bf 100644 --- a/src/core/doctor.test.ts +++ b/src/core/doctor.test.ts @@ -1,11 +1,17 @@ import { describe, expect, it, vi } from "vitest"; import { EventBus } from "../bus/index.js"; -import { FakeClock, MemoryFilesystem } from "../ports/index.js"; +import { FakeClock, JsonLinesLogger, MemoryFilesystem, MemoryLogSink } from "../ports/index.js"; import { FakeSystemStats } from "../ports/index.js"; import type { Config } from "./config.js"; import { DeviceOperationClaims } from "./device-operation-claims.js"; -import { Doctor, type DoctorFinding, type DoctorReport, isStalledTransition } from "./doctor.js"; +import { + Doctor, + type DoctorFinding, + type DoctorReport, + isStalledTransition, + type OptionalModuleFinding, +} from "./doctor.js"; import { DriverCatalog } from "./driver-catalog.js"; import type { DriverRejection, PrerequisiteCheck } from "./driver.js"; import { FakeDriver } from "./fake-driver.js"; @@ -2642,6 +2648,198 @@ describe("Doctor with a platform it cannot observe", () => { }); }); +describe("Doctor: optional module findings", () => { + const unavailable: OptionalModuleFinding = { + kind: "agent-device-unavailable", + message: "found 0.20.3, need 0.21.25 or later", + remedy: "install agent-device 0.21.25 or later, then restart the daemon", + }; + + async function setup( + options: { + readonly optionalModules?: () => readonly OptionalModuleFinding[]; + readonly orphan?: boolean; + readonly missing?: boolean; + readonly sink?: MemoryLogSink; + } = {}, + ) { + const clock = new FakeClock(10_000); + const eventBus = new EventBus(clock); + const registry = await loadRegistry(clock, eventBus); + if (options.missing === true) { + await readyDevice(registry, "gone", "ios"); + } + const driver = new FakeDriver({ clock, platform: "ios" }); + if (options.orphan === true) { + driver.setManagedReality({ + devices: [ + { + address: "simlock-orphan-address", + deviceId: "simlock-orphan", + driverData: { fakeDeviceId: "simlock-orphan" }, + runState: "running", + }, + ], + processes: [], + }); + } + const doctor = new Doctor({ + decisions: new SerializedDecision(), + clock, + config: config(), + drivers: [driver], + eventBus, + registry, + ...(options.sink === undefined + ? {} + : { logger: new JsonLinesLogger({ clock, level: "debug", sink: options.sink }) }), + ...(options.optionalModules === undefined + ? {} + : { optionalModules: options.optionalModules }), + }); + return { doctor, driver, eventBus, registry }; + } + + it("returns each finding optionalModules gives, with its kind, message and remedy unchanged, after a warm-pool-target-unreachable finding returned by the same run", async () => { + const clock = new FakeClock(10_000); + const eventBus = new EventBus(clock); + const doctor = new Doctor({ + decisions: new SerializedDecision(), + clock, + config: { ...config(), warmPool: { ...config().warmPool, enabled: true } }, + drivers: [new FakeDriver({ clock, platform: "ios" })], + eventBus, + optionalModules: () => [unavailable], + registry: await loadRegistry(clock, eventBus), + warmPool: { + figures: () => ({ + enabled: true, + reserveRunning: { android: 0, ios: 0 }, + targets: [ + { + booting: 0, + count: 1, + mode: "full" as const, + model: "iPhone 17", + osVersion: "27.0", + platform: "ios" as const, + ready: 0, + short: "runtime-missing" as const, + }, + ], + }), + runningCapacity: () => ({ + android: { maxRunning: 10, overLimit: false, reserved: 0, running: 0 }, + global: { maxRunning: 20, overLimit: false, reserved: 0, running: 0 }, + ios: { maxRunning: 10, overLimit: false, reserved: 0, running: 0 }, + }), + }, + } as ConstructorParameters[0]); + + const report = await doctor.reconcile(); + + expect(report.findings.map((finding) => finding.kind)).toEqual([ + "warm-pool-target-unreachable", + "agent-device-unavailable", + ]); + expect(report.findings.at(-1)).toStrictEqual(unavailable); + }); + + it("returns no agent-device-unavailable when optionalModules is omitted or returns none", async () => { + const omitted = await (await setup()).doctor.reconcile(); + const none = await (await setup({ optionalModules: () => [] })).doctor.reconcile(); + + expect(omitted.findings).toEqual([]); + expect(none.findings).toEqual([]); + }); + + it("reports the finding on a run where optionalModules returns it, and not on the next where it returns none", async () => { + const answers: (readonly OptionalModuleFinding[])[] = [[unavailable], []]; + const { doctor } = await setup({ optionalModules: () => answers.shift() ?? [] }); + + const first = await doctor.reconcile(); + const second = await doctor.reconcile(); + + expect(first.findings).toStrictEqual([unavailable]); + expect(second.findings).toEqual([]); + }); + + it("calls optionalModules only after fix and purge have run, with fix and purgeOrphans on, and reports its finding", async () => { + let stateWhenCalled: string | undefined; + let destroysWhenCalled: number | undefined; + let registryRef: Awaited>["registry"] | undefined; + let driverRef: Awaited>["driver"] | undefined; + const harness = await setup({ + missing: true, + optionalModules: () => { + stateWhenCalled = registryRef?.snapshot.devices[0]?.state; + destroysWhenCalled = driverRef?.calls.filter((call) => call.operation === "destroy").length; + return [unavailable]; + }, + orphan: true, + }); + registryRef = harness.registry; + driverRef = harness.driver; + + const report = await harness.doctor.reconcile({ fix: true, purgeOrphans: true }); + + expect(stateWhenCalled).toBe("deleted"); + // why: the purge destroys the orphan, so a call made before it would count no destroy. + expect(destroysWhenCalled).toBe(1); + expect(report.findings.map((finding) => finding.kind)).toEqual([ + "registry-device-missing", + "agent-device-unavailable", + ]); + }); + + it("leaves the finding out of doctor.reconciled's driftFindings and emits no other event for it", async () => { + const withModule = await setup({ optionalModules: () => [unavailable], orphan: true }); + const without = await setup({ orphan: true }); + + const report = await withModule.doctor.reconcile(); + await without.doctor.reconcile(); + + expect(report.findings).toContainEqual(unavailable); + + const reconciled = withModule.eventBus + .replay() + .find((event) => event.event === "doctor.reconciled"); + expect(reconciled?.payload).toMatchObject({ driftFindings: [{ kind: "orphan-device" }] }); + expect(reconciled?.payload).toHaveProperty("driftFindings.length", 1); + expect(withModule.eventBus.replay().map((event) => event.event)).toEqual( + without.eventBus.replay().map((event) => event.event), + ); + }); + + it("returns the finding with prerequisites false too", async () => { + const { doctor } = await setup({ optionalModules: () => [unavailable] }); + + const report = await doctor.reconcile({ prerequisites: false }); + + expect(report.findings).toStrictEqual([unavailable]); + }); + + it("logs a warning naming the error when optionalModules throws, contributes nothing, and still returns the other findings", async () => { + const sink = new MemoryLogSink(); + const { doctor } = await setup({ + optionalModules: () => { + throw new Error("supervisor exploded"); + }, + orphan: true, + sink, + }); + + const report = await doctor.reconcile(); + + expect(report.findings.map((finding) => finding.kind)).toEqual(["orphan-device"]); + const warning = sink.records.find( + (record) => record.message === "Could not read optional module findings", + ); + expect(warning?.level).toBe("warn"); + expect(JSON.stringify(warning?.fields)).toContain("supervisor exploded"); + }); +}); + async function legacyDeviceSetup() { const clock = new FakeClock(10_000); const eventBus = new EventBus(clock); diff --git a/src/core/doctor.ts b/src/core/doctor.ts index cbc627e5..ef00d460 100644 --- a/src/core/doctor.ts +++ b/src/core/doctor.ts @@ -114,8 +114,18 @@ export type DoctorFinding = readonly reason: "runtime-missing" | "unknown-model" | "over-limit"; readonly message: string; readonly remedy: string; + } + | { + readonly kind: "agent-device-unavailable"; + readonly message: string; + readonly remedy: string; }; +export type OptionalModuleFinding = Extract< + DoctorFinding, + { readonly kind: "agent-device-unavailable" } +>; + export type ProvenanceDrift = "erased" | "mark-mismatch" | "durable-mark-missing"; type OrphanDeviceFinding = Extract; @@ -143,6 +153,7 @@ export interface DoctorOptions { readonly claims?: Pick; readonly driverRejections?: readonly DriverRejection[]; readonly logger?: Logger; + readonly optionalModules?: () => readonly OptionalModuleFinding[]; readonly warmPool?: { readonly figures: WarmPoolReader["figures"]; readonly runningCapacity: () => CapacityReader["runningCapacity"]; @@ -243,6 +254,7 @@ export class Doctor { ...(purgeOrphans ? await this.#purgeOrphans(findings, driversByPlatform) : findings), ...(await this.#collectPrerequisites(prerequisites)), ...this.#warmPoolFindings(), + ...this.#optionalModuleFindings(), ]; const report = { findings: remaining }; @@ -555,6 +567,17 @@ export class Doctor { return perCheck.flat(); } + #optionalModuleFindings(): readonly OptionalModuleFinding[] { + try { + return this.options.optionalModules?.() ?? []; + } catch (error) { + this.#logger.warn("Could not read optional module findings", { + error: errorMessage(error), + }); + return []; + } + } + /** * The warm pool's targets that cannot be met as configured: those the last pass found a runtime * or a model missing for, and the platforms (or the machine) whose targets add up to more than @@ -1128,7 +1151,8 @@ function isDrift(finding: DoctorFinding): boolean { return ( finding.kind !== "driver-advisory" && finding.kind !== "prerequisite-missing" && - finding.kind !== "warm-pool-target-unreachable" + finding.kind !== "warm-pool-target-unreachable" && + finding.kind !== "agent-device-unavailable" ); } diff --git a/src/core/index.ts b/src/core/index.ts index 6d99d48d..0f1f9e16 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -92,7 +92,12 @@ export { } from "./driver-catalog.js"; // fallow-ignore-next-line unused-type -- wire-visible rejection vocabulary for Simlock's own adb server. export type { AdbServerRejectionReason, DriverRejectionReason } from "./driver.js"; -export { Doctor, isStalledTransition, type StallInput } from "./doctor.js"; +export { + Doctor, + isStalledTransition, + type OptionalModuleFinding, + type StallInput, +} from "./doctor.js"; export { StartupRead } from "./startup-read.js"; export { type HostFacts, HostFactsReader } from "./host-facts.js"; export { ensureOwnedRoot, OWNED_ROOT_MARKER_FILE, OwnedRootError } from "./device-root.js"; diff --git a/src/daemon-protocol/index.test.ts b/src/daemon-protocol/index.test.ts index 78adef7f..88ba0074 100644 --- a/src/daemon-protocol/index.test.ts +++ b/src/daemon-protocol/index.test.ts @@ -9,7 +9,7 @@ import { describe("daemon protocol", () => { it("keeps the current protocol version and newline framing", () => { - expect(DAEMON_PROTOCOL_VERSION).toBe(30); + expect(DAEMON_PROTOCOL_VERSION).toBe(31); expect(serializeFrame({ id: 1, type: "hello" })).toBe('{"id":1,"type":"hello"}\n'); }); diff --git a/src/daemon/main.test.ts b/src/daemon/main.test.ts index f073a25a..2a6ee8c4 100644 --- a/src/daemon/main.test.ts +++ b/src/daemon/main.test.ts @@ -2540,4 +2540,101 @@ describe("startDaemon with the agent-device edge", () => { expect(runner.calls).toEqual([]); expect(processSupervisor.signals).toEqual([]); }); + + describe("doctor.run", () => { + const operator = { + manageEventSubscription: () => undefined, + principal: "operator", + role: "admin", + } as const; + + type Report = { + readonly findings: readonly { readonly kind: string; readonly message?: string }[]; + }; + + const agentDeviceFinding = (report: Report) => + report.findings.find((finding) => finding.kind === "agent-device-unavailable"); + + it("reports agent-device-unavailable naming the found and needed versions, and an ordinary client is still granted a lease", async () => { + const setup = await setUp({ agentDevice: { command, enabled: true } }); + const runner = new ScriptedProcessRunner([ + { + match: { args: [realCli, "--version"], command: process.execPath }, + result: { code: 0, stderr: "", stdout: "0.20.3\n" }, + }, + ]); + const { daemon } = await startEdge(setup, { + processRunner: runner, + processSupervisor: new FakeProcessSupervisor(), + }); + + await expect + .poll( + async () => + agentDeviceFinding((await daemon.dispatch("doctor.run", {}, operator)) as Report), + { + timeout: 5_000, + }, + ) + .toMatchObject({ + kind: "agent-device-unavailable", + message: expect.stringContaining("found 0.20.3, need 0.21.25 or later"), + remedy: expect.any(String), + }); + await expect(daemon.dispatch("lease.request", lease, agent)).resolves.toMatchObject({ + lease: { id: expect.any(String) }, + }); + }, 10_000); + + it("reports agent-device-unavailable naming the command when agentDevice.command is found nowhere", async () => { + const setup = await setUp({ + agentDevice: { command: "/opt/missing/agent-device", enabled: true }, + }); + const { daemon } = await startEdge(setup, { + processRunner: new ScriptedProcessRunner([]), + processSupervisor: new FakeProcessSupervisor(), + }); + + await expect + .poll( + async () => + agentDeviceFinding((await daemon.dispatch("doctor.run", {}, operator)) as Report), + { + timeout: 5_000, + }, + ) + .toMatchObject({ + kind: "agent-device-unavailable", + message: expect.stringContaining("/opt/missing/agent-device"), + }); + }, 10_000); + + it("reports no agent-device-unavailable with agentDevice.enabled false", async () => { + const setup = await setUp({ agentDevice: { enabled: false } }); + const { daemon } = await startEdge(setup, { + processRunner: new ScriptedProcessRunner([]), + processSupervisor: new FakeProcessSupervisor(), + }); + + const report = (await daemon.dispatch("doctor.run", {}, operator)) as Report; + + expect(agentDeviceFinding(report)).toBeUndefined(); + }); + + it("reports no agent-device-unavailable while agent-device's /health never answers and the readiness budget has not run out", async () => { + const setup = await setUp({ agentDevice: { command, enabled: true } }); + const runner = versionAndDaemon(setup.directory); + const { daemon } = await startEdge(setup, { + processRunner: runner, + processSupervisor: new FakeProcessSupervisor(), + }); + await expect + .poll(() => runner.calls.some((call) => call.args[0] === entry), { timeout: 5_000 }) + .toBe(true); + + const report = (await daemon.dispatch("doctor.run", {}, operator)) as Report; + + expect(agentDeviceFinding(report)).toBeUndefined(); + }); + }); }); diff --git a/src/daemon/main.ts b/src/daemon/main.ts index 9db627d2..ad5096a2 100644 --- a/src/daemon/main.ts +++ b/src/daemon/main.ts @@ -3,6 +3,7 @@ import { join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { + agentDeviceFindings, AgentDeviceSupervisor, type AgentDeviceHealthProbe, NodeAgentDeviceHealthProbe, @@ -283,6 +284,7 @@ export async function startDaemon(options: StartDaemonOptions = {}): Promise agentDeviceFindings(agentDevice.availability()), prerequisiteChecks, registry, systemStats, diff --git a/src/daemon/server.test.ts b/src/daemon/server.test.ts index a68eee7a..93eb3716 100644 --- a/src/daemon/server.test.ts +++ b/src/daemon/server.test.ts @@ -192,8 +192,9 @@ describe("DaemonServer", () => { [27, "cannot read config.get's lease.maxPerRequester"], [28, "cannot read config.get's lease.endWaitMs"], [29, "cannot read config.get's agentDevice"], + [30, "cannot read the agent-device-unavailable finding in doctor.run"], ])( - "answers PROTOCOL_VERSION_UNSUPPORTED, naming protocol 30, to a client on protocol %i, which %s", + "answers PROTOCOL_VERSION_UNSUPPORTED, naming protocol 31, to a client on protocol %i, which %s", async (version) => { const harness = await createHarness(); const previous = await createClient(harness.socketPath); @@ -203,7 +204,7 @@ describe("DaemonServer", () => { ).resolves.toMatchObject({ error: { code: "PROTOCOL_VERSION_UNSUPPORTED", - details: { client: { min: version, max: version }, daemon: { min: 30, max: 30 } }, + details: { client: { min: version, max: version }, daemon: { min: 31, max: 31 } }, }, ok: false, }); diff --git a/src/gateway/service.test.ts b/src/gateway/service.test.ts index 9bdd4e99..edbbfee9 100644 --- a/src/gateway/service.test.ts +++ b/src/gateway/service.test.ts @@ -1207,7 +1207,7 @@ describe("GatewayService", () => { min: PROTOCOL_VERSION_RANGE.max - 1, max: PROTOCOL_VERSION_RANGE.max - 1, }; - expect(PROTOCOL_VERSION_RANGE.max).toBe(30); + expect(PROTOCOL_VERSION_RANGE.max).toBe(31); expect(negotiateProtocolVersion(PROTOCOL_VERSION_RANGE, previous)).toBeUndefined(); const harness = fleet(); await harness.service.start();