Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions docs/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 53 additions & 0 deletions src/agent-device/host/availability-findings.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
},
);
});
15 changes: 15 additions & 0 deletions src/agent-device/host/availability-findings.ts
Original file line number Diff line number Diff line change
@@ -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,
},
];
}
1 change: 1 addition & 0 deletions src/agent-device/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
69 changes: 69 additions & 0 deletions src/cli/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <message>; <remedy> 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[] = [];
Expand Down
11 changes: 11 additions & 0 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/contract/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
20 changes: 20 additions & 0 deletions src/contract/schemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";

import {
deviceRecordSchema,
doctorReportSchema,
fitHostFacts,
fitPlatformCatalog,
grantedDeviceSchema,
Expand Down Expand Up @@ -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);
});
});
5 changes: 5 additions & 0 deletions src/contract/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) });
Expand Down
4 changes: 3 additions & 1 deletion src/core/create-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading