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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,10 @@ specification those changes are written against.
The lease stands until its deadline and any later invocation can
`simlock lease renew <lease-id>` it. Exit `14` keeps its narrower meaning:
the daemon ended the lease while the connection was alive.
- **`doctor` reports an unavailable optional module as `module-unavailable`.**
The finding carries a `module` field (`agent-device` today) and replaces
`agent-device-unavailable`; the stderr line is `<module> fail <message>;
<remedy>`. The daemon protocol is now 37.

### Features

Expand Down
6 changes: 3 additions & 3 deletions docs/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -2196,9 +2196,9 @@ 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,
With `agentDevice.enabled` `true`, `doctor` reports a `module-unavailable`
finding (its `module` is `agent-device`) 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:
Expand Down
10 changes: 5 additions & 5 deletions e2e/slow-agent-device.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,12 +195,12 @@ function defineSuite(target: Target): void {
);

it(
"with this working agent-device, simlock doctor reports no agent-device-unavailable finding and no finding whose message names agent-device",
"with this working agent-device, simlock doctor reports no module-unavailable finding and no finding whose message names agent-device",
{ timeout: 600_000 },
async () => {
const doctor = await env.cli(["doctor"], { timeout: 60_000 });
const findings = (doctor.json as { findings: { kind: string; message?: string }[] }).findings;
expect(findings.map((finding) => finding.kind)).not.toContain("agent-device-unavailable");
expect(findings.map((finding) => finding.kind)).not.toContain("module-unavailable");
expect(findings.filter((finding) => /agent-device/i.test(finding.message ?? ""))).toEqual([]);
},
);
Expand Down Expand Up @@ -503,7 +503,7 @@ function defineSuite(target: Target): void {
);

it(
"with agentDevice.command pointing at an agent-device that reports 0.9.2, the daemon starts and grants a plain simlock lease --detach; an agent's open fails with agent-device is not available on this simlock: found 0.9.2, need 0.21.28 or later; simlock doctor reports agent-device-unavailable with found 0.9.2, need 0.21.28 or later",
"with agentDevice.command pointing at an agent-device that reports 0.9.2, the daemon starts and grants a plain simlock lease --detach; an agent's open fails with agent-device is not available on this simlock: found 0.9.2, need 0.21.28 or later; simlock doctor reports module-unavailable with found 0.9.2, need 0.21.28 or later",
{ timeout: 900_000 },
async () => {
const root = await mkdtemp(join(tmpdir(), "simlock-ad-old-"));
Expand All @@ -525,10 +525,10 @@ function defineSuite(target: Target): void {
const doctor = await env.cli(["doctor"], { timeout: 60_000 });
finding = (
doctor.json as { findings: { kind: string; message: string }[] }
).findings.find((row) => row.kind === "agent-device-unavailable");
).findings.find((row) => row.kind === "module-unavailable");
return finding !== undefined;
},
{ timeout: 60_000, interval: 500, label: "doctor reports agent-device-unavailable" },
{ timeout: 60_000, interval: 500, label: "doctor reports module-unavailable" },
);
expect(finding?.message).toContain(message);

Expand Down
5 changes: 3 additions & 2 deletions src/agent-device/host/availability-findings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ describe("agentDeviceFindings", () => {
] as const;

it.each(reasons)(
"returns one finding with the availability's message and remedy for the %s reason",
"returns one finding with module agent-device and the availability's message and remedy for the %s reason",
(reason) => {
const availability: AgentDeviceAvailability = {
message: `message for ${reason}`,
Expand All @@ -24,7 +24,8 @@ describe("agentDeviceFindings", () => {

expect(agentDeviceFindings(availability)).toStrictEqual([
{
kind: "agent-device-unavailable",
kind: "module-unavailable",
module: "agent-device",
message: `message for ${reason}`,
remedy: `remedy for ${reason}`,
},
Expand Down
3 changes: 2 additions & 1 deletion src/agent-device/host/availability-findings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ export function agentDeviceFindings(
if (availability.state !== "unavailable") return [];
return [
{
kind: "agent-device-unavailable",
kind: "module-unavailable",
module: "agent-device",
message: availability.message,
remedy: availability.remedy,
},
Expand Down
28 changes: 26 additions & 2 deletions src/cli/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1421,11 +1421,12 @@ describe("CLI: daemon status (ADR 0003 §11)", () => {
);
});

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 () => {
it("simlock doctor writes agent-device fail <message>; <remedy> on stderr for a module-unavailable finding, 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,
kind: "module-unavailable" as const,
module: "agent-device",
message: `found 0.20.3,\n need ${"x".repeat(600)}`,
remedy: `install agent-device 0.21.28 or later,\nthen ${"y".repeat(600)}`,
},
Expand Down Expand Up @@ -1476,6 +1477,29 @@ describe("CLI: daemon status (ADR 0003 §11)", () => {
expect(JSON.parse(output.stdout)).toEqual({ findings });
});

it("simlock doctor writes <module> fail … for a finding of another module", async () => {
const output = outputCapture();
const findings = [
{
kind: "module-unavailable" as const,
message: "needs\n tmux",
module: "other-mod",
remedy: "install tmux",
},
];

await runCli(
["doctor"],
output.environmentWith({
connectAdmin: async () =>
fakeClient({ runDoctor: () => Promise.resolve({ findings } as never) }),
}),
);

expect(output.stderr.split("\n")).toContain("other-mod fail needs tmux; install tmux");
expect(output.stderr).not.toContain("agent-device");
});

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" }];
Expand Down
8 changes: 4 additions & 4 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1090,7 +1090,7 @@ async function runDoctor(
writeDriverAdvisoryWarnings(environment, response);
writeMissingPrerequisites(environment, response);
writeUnreachableWarmTargets(environment, response);
writeAgentDeviceUnavailable(environment, response);
writeModuleUnavailable(environment, response);
writeResult(environment, response);
return 0;
} finally {
Expand Down Expand Up @@ -1123,12 +1123,12 @@ function writeUnreachableWarmTargets(environment: CliEnvironment, report: Doctor
}
}

function writeAgentDeviceUnavailable(environment: CliEnvironment, report: DoctorReport): void {
function writeModuleUnavailable(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;
if (finding.kind !== "module-unavailable") continue;
environment.stderr.write(
`agent-device fail ${oneLine(finding.message)}; ${oneLine(finding.remedy)}\n`,
`${finding.module} fail ${oneLine(finding.message)}; ${oneLine(finding.remedy)}\n`,
);
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/contract/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ Three identities:

## Protocol version

- Now `{ min: 36, max: 36 }`. `DAEMON_PROTOCOL_VERSION` (`src/daemon-protocol/`) is its `max`.
- Now `{ min: 37, max: 37 }`. `DAEMON_PROTOCOL_VERSION` (`src/daemon-protocol/`) is its `max`.
- Every change to a contract shape bumps it by one in the same PR. Move `min` too, unless
a compatibility path stays side by side.
- `hello` picks the highest overlap. None → `PROTOCOL_VERSION_UNSUPPORTED` with both ranges.
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: 36, max: 36 };
export const PROTOCOL_VERSION_RANGE: ProtocolRange = { min: 37, max: 37 };

const LEGACY_DAEMON_PROTOCOL_VERSION = 2;

Expand Down
28 changes: 23 additions & 5 deletions src/contract/schemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -656,22 +656,40 @@ describe("the status device entry's absent state (ADR 0022 §7)", () => {
});
});

describe("doctorReportSchema's agent-device-unavailable finding", () => {
describe("doctorReportSchema's module-unavailable finding", () => {
const finding = {
kind: "agent-device-unavailable",
kind: "module-unavailable",
module: "agent-device",
message: "found 0.20.3, need 0.21.28 or later",
remedy: "install agent-device 0.21.28 or later",
};
const parses = (candidate: unknown) =>
doctorReportSchema.safeParse({ findings: [candidate] }).success;

it("parses a report with the finding, and refuses one without message or without remedy", () => {
it("parses a module-unavailable finding, and refuses one without module, without message or without remedy", () => {
expect(doctorReportSchema.parse({ findings: [finding] })).toStrictEqual({
findings: [finding],
});

const { module: _module, ...withoutModule } = 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);
expect(parses(withoutModule)).toBe(false);
expect(parses(withoutMessage)).toBe(false);
expect(parses(withoutRemedy)).toBe(false);
});

it("refuses a module that fails the capability name check", () => {
for (const module of ["", "Agent-Device", "1abc", "has space", `a${"b".repeat(32)}`]) {
expect(parses({ ...finding, module })).toBe(false);
}
expect(parses({ ...finding, module: `a${"b".repeat(31)}` })).toBe(true);
});

it("refuses the old agent-device-unavailable kind", () => {
const { module: _module, ...rest } = finding;
expect(parses({ ...rest, kind: "agent-device-unavailable" })).toBe(false);
expect(parses({ ...finding, kind: "agent-device-unavailable" })).toBe(false);
});
});

Expand Down
9 changes: 5 additions & 4 deletions src/contract/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -682,7 +682,9 @@ const driverDeviceSchema = z.object({
mode: deviceModeSchema.optional(),
});

/** Mirrors the `DoctorFinding` discriminated union (src/core/doctor.ts) field for field. */
export const capabilityNameSchema = z.string().regex(/^[a-z][a-z0-9-]{0,31}$/);

// why: mirrors the `DoctorFinding` union (src/core/doctor.ts) field for field, so a new member lands in both.
const doctorFindingSchema = z.discriminatedUnion("kind", [
z.object({
kind: z.literal("registry-device-missing"),
Expand Down Expand Up @@ -744,7 +746,8 @@ const doctorFindingSchema = z.discriminatedUnion("kind", [
remedy: z.string(),
}),
z.object({
kind: z.literal("agent-device-unavailable"),
kind: z.literal("module-unavailable"),
module: capabilityNameSchema,
message: z.string(),
remedy: z.string(),
}),
Expand Down Expand Up @@ -1051,8 +1054,6 @@ export const statusInstallsSchema = boundedInstalls(
*/
export const INSTALL_LIST_LIMIT = MAX_LISTED_INSTALLS;

export const capabilityNameSchema = z.string().regex(/^[a-z][a-z0-9-]{0,31}$/);

// why: \p{Cc} is exactly U+0000-U+001F and U+007F-U+009F, the spec's control characters.
const CONTROL_CHARACTERS = /[\p{Cc}\u2028\u2029]/gu;
const CAPABILITY_REASON_MAX = 500;
Expand Down
9 changes: 5 additions & 4 deletions src/core/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2650,7 +2650,8 @@ describe("Doctor with a platform it cannot observe", () => {

describe("Doctor: optional module findings", () => {
const unavailable: OptionalModuleFinding = {
kind: "agent-device-unavailable",
kind: "module-unavailable",
module: "agent-device",
message: "found 0.20.3, need 0.21.28 or later",
remedy: "install agent-device 0.21.28 or later, then restart the daemon",
};
Expand Down Expand Up @@ -2740,12 +2741,12 @@ describe("Doctor: optional module findings", () => {

expect(report.findings.map((finding) => finding.kind)).toEqual([
"warm-pool-target-unreachable",
"agent-device-unavailable",
"module-unavailable",
]);
expect(report.findings.at(-1)).toStrictEqual(unavailable);
});

it("returns no agent-device-unavailable when optionalModules is omitted or returns none", async () => {
it("returns no module-unavailable finding when optionalModules is omitted or returns none", async () => {
const omitted = await (await setup()).doctor.reconcile();
const none = await (await setup({ optionalModules: () => [] })).doctor.reconcile();

Expand Down Expand Up @@ -2788,7 +2789,7 @@ describe("Doctor: optional module findings", () => {
expect(destroysWhenCalled).toBe(1);
expect(report.findings.map((finding) => finding.kind)).toEqual([
"registry-device-missing",
"agent-device-unavailable",
"module-unavailable",
]);
});

Expand Down
10 changes: 4 additions & 6 deletions src/core/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,15 +117,13 @@ export type DoctorFinding =
readonly remedy: string;
}
| {
readonly kind: "agent-device-unavailable";
readonly kind: "module-unavailable";
readonly module: string;
readonly message: string;
readonly remedy: string;
};

export type OptionalModuleFinding = Extract<
DoctorFinding,
{ readonly kind: "agent-device-unavailable" }
>;
export type OptionalModuleFinding = Extract<DoctorFinding, { readonly kind: "module-unavailable" }>;

export type ProvenanceDrift = "erased" | "mark-mismatch" | "durable-mark-missing";

Expand Down Expand Up @@ -1199,7 +1197,7 @@ function isDrift(finding: DoctorFinding): boolean {
finding.kind !== "driver-advisory" &&
finding.kind !== "prerequisite-missing" &&
finding.kind !== "warm-pool-target-unreachable" &&
finding.kind !== "agent-device-unavailable"
finding.kind !== "module-unavailable"
);
}

Expand Down
2 changes: 1 addition & 1 deletion src/daemon-protocol/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {

describe("daemon protocol", () => {
it("keeps the current protocol version and newline framing", () => {
expect(DAEMON_PROTOCOL_VERSION).toBe(36);
expect(DAEMON_PROTOCOL_VERSION).toBe(37);
expect(serializeFrame({ id: 1, type: "hello" })).toBe('{"id":1,"type":"hello"}\n');
});

Expand Down
2 changes: 1 addition & 1 deletion src/daemon/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ An HTTP bind failure after a good start stops the daemon (`http-start-failed`).
Principal: `hello.principal`, else `SIMLOCK_AGENT_ID`, else the daemon's pid.
Both are fixed for the connection's life.
3. **Version** — ranges overlap → highest common version. `PROTOCOL_VERSION_RANGE`
is `{ min: 36, max: 36 }` (`src/contract/protocol.ts`). No overlap → the
is `{ min: 37, max: 37 }` (`src/contract/protocol.ts`). No overlap → the
connection stays open, and every request but `daemon.stop` gets
`PROTOCOL_VERSION_UNSUPPORTED`.
4. **Request** — one frame → one `dispatch()` call. While stopping: `DAEMON_STOPPING`.
Expand Down
16 changes: 9 additions & 7 deletions src/daemon/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2593,9 +2593,9 @@ describe("startDaemon with the agent-device edge", () => {
};

const agentDeviceFinding = (report: Report) =>
report.findings.find((finding) => finding.kind === "agent-device-unavailable");
report.findings.find((finding) => finding.kind === "module-unavailable");

it("reports agent-device-unavailable naming the found and needed versions, and an ordinary client is still granted a lease", async () => {
it("reports module-unavailable for agent-device, 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([
{
Expand All @@ -2617,7 +2617,8 @@ describe("startDaemon with the agent-device edge", () => {
},
)
.toMatchObject({
kind: "agent-device-unavailable",
kind: "module-unavailable",
module: "agent-device",
message: expect.stringContaining("found 0.20.3, need 0.21.28 or later"),
remedy: expect.any(String),
});
Expand All @@ -2626,7 +2627,7 @@ describe("startDaemon with the agent-device edge", () => {
});
}, 10_000);

it("reports agent-device-unavailable naming the command when agentDevice.command is found nowhere", async () => {
it("reports module-unavailable for agent-device, naming the command when agentDevice.command is found nowhere", async () => {
const setup = await setUp({
agentDevice: { command: "/opt/missing/agent-device", enabled: true },
});
Expand All @@ -2644,12 +2645,13 @@ describe("startDaemon with the agent-device edge", () => {
},
)
.toMatchObject({
kind: "agent-device-unavailable",
kind: "module-unavailable",
module: "agent-device",
message: expect.stringContaining("/opt/missing/agent-device"),
});
}, 10_000);

it("reports no agent-device-unavailable with agentDevice.enabled false", async () => {
it("reports no module-unavailable finding with agentDevice.enabled false", async () => {
const setup = await setUp({ agentDevice: { enabled: false } });
const { daemon } = await startEdge(setup, {
processRunner: new ScriptedProcessRunner([]),
Expand All @@ -2661,7 +2663,7 @@ describe("startDaemon with the agent-device edge", () => {
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 () => {
it("reports no module-unavailable finding 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, {
Expand Down
Loading
Loading