Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
e0daa29
test: the driver given deviceSet runs every simctl call with or witho…
V3RON Oct 24, 2026
a94cd24
feat(drivers): the iOS driver keeps its simulators in Xcode's set und…
V3RON Oct 24, 2026
7d5b46b
test: an operator with a bad value or a conflicting deviceRoot gets i…
V3RON Oct 24, 2026
9a761a0
feat(drivers): the iOS driver refuses a bad deviceSet or one that con…
V3RON Oct 24, 2026
3428e75
test: an operator who switches with leftovers gets device-set-changed…
V3RON Oct 24, 2026
ccde91d
feat(drivers): the iOS driver refuses to start with simulators left f…
V3RON Oct 24, 2026
9574932
test: a new or reclaimed simulator's marks name this instance (#968)
V3RON Oct 24, 2026
78d46c2
feat(drivers): iOS marks name the instance; a failed mark write in Xc…
V3RON Oct 24, 2026
8387661
test: under deviceSet default listManaged reports only devices marked…
V3RON Oct 24, 2026
95922aa
feat(drivers): under deviceSet default listManaged reports only this …
V3RON Oct 24, 2026
dc46bff
test: under deviceSet default foreignDevices and the unused count tak…
V3RON Oct 24, 2026
ef4005e
feat(drivers): under deviceSet default foreign and unused counts leav…
V3RON Oct 24, 2026
d1776d5
test: a lease holder and the screen capture get the device set only u…
V3RON Oct 24, 2026
83ce0f2
feat(drivers): under deviceSet default no grant or screen capture nam…
V3RON Oct 24, 2026
8b37041
test: simlock simctl under deviceSet default runs plain xcrun simctl …
V3RON Oct 24, 2026
41fb7c2
feat(drivers): the simctl passthrough carries no --set under deviceSe…
V3RON Oct 24, 2026
ddc8311
test: the agent-device simulator daemon gets no device set when the l…
V3RON Oct 24, 2026
dd93844
test: the iOS builds section names the build route for each device se…
V3RON Oct 24, 2026
ab6de27
feat(instructions): the iOS builds section names the route for each d…
V3RON Oct 24, 2026
6114645
Merge remote-tracking branch 'origin/feature/962-adr' into HEAD
V3RON Oct 24, 2026
9de6229
test: doctor.run answers a driver-unavailable finding while iOS is re…
V3RON Oct 24, 2026
daa438d
feat(contract): doctor.run carries a driver-unavailable finding (#968)
V3RON Oct 24, 2026
78edb2b
test: simlock doctor writes Unavailable [ios] <reason>: <detail> on s…
V3RON Oct 24, 2026
020c990
feat(cli): simlock doctor writes why a driver is unavailable on stder…
V3RON Oct 24, 2026
133c9fa
test: a daemon with drivers.ios.deviceSet default lists a leased simu…
V3RON Oct 24, 2026
afe408f
test: slow-e2e clean and stop delete the simulators a lane instance m…
V3RON Oct 24, 2026
c62ab0f
feat(e2e): slow-e2e clean and stop delete the simulators a lane insta…
V3RON Oct 24, 2026
c8c4fb7
docs: the device set setting in the manuals, EVENTS and the website, …
V3RON Oct 24, 2026
e42ad18
Merge remote-tracking branch 'origin/feature/962-adr' into HEAD
V3RON Oct 24, 2026
832caec
docs: the events page lists the device-set refusals under driver.root…
V3RON Oct 24, 2026
4e02370
fix(drivers): ios driver takes Xcode's set root from options, comment…
V3RON Oct 24, 2026
0cb331a
Merge origin/feature/962-adr into task/968
V3RON Oct 26, 2026
d1cec86
docs: the iOS driver manual fits the 150-line budget, safety rule 9 l…
V3RON Oct 26, 2026
34876b8
docs(adr): ADR 0048 is Accepted (#968)
V3RON Oct 26, 2026
b3d69b9
fix(contract): a driver-unavailable finding's detail is bounded, and …
V3RON Oct 26, 2026
e7a2901
fix(contract): one cut helper for quarantine messages and driver summ…
V3RON Oct 26, 2026
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
2 changes: 1 addition & 1 deletion docs/internal/EVENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ left behind (#79), each when there is one.
| `disk.pressure-detected` | free bytes, threshold | free disk crossed under the configured threshold (edge-triggered: once per crossing, not once per tick while it persists) | CleanupReaper | implemented |
| `cleanup.executed` | rule name, action, target, reason | cleanup executor committed a proposed action | CleanupExecutor | implemented |
| `doctor.reconciled` | drift findings | daemon reconciliation completed | Doctor | implemented |
| `driver.root-rejected` | platform, root path, reason (not-absolute/missing-marker/invalid-marker/wrong-instance/symlink/wrong-owner/wrong-permissions/non-empty-unowned-root/unreadable) | a driver's device root failed ownership validation at startup, so that platform's driver did not start | DaemonServer | implemented |
| `driver.root-rejected` | platform, root path, reason (not-absolute/missing-marker/invalid-marker/wrong-instance/symlink/wrong-owner/wrong-permissions/non-empty-unowned-root/unreadable/invalid-device-set/device-set-changed) | a driver's device root failed ownership validation at startup, or (iOS) `drivers.ios.deviceSet` was not `simlock` or `default`, conflicted with `drivers.ios.deviceRoot`, or was switched with simulators left in the set being given up (ADR 0048; `root` is then the configured value or the set holding them), so that platform's driver did not start | DaemonServer | implemented |
| `driver.adb-server-rejected` | port, reason (occupied/start-failed/invalid-port) | Simlock's own adb server could not be established — the port was occupied by a server it does not own, the server it started never began listening, or the configured port is not usable — so the Android driver did not start | DaemonServer | implemented |

## Fleet (gateway mode)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 0048. An iOS worker may keep its simulators in Xcode's device set

- **Status:** Accepted — not yet implemented
- **Status:** Accepted
- **Date:** 2026-10-23
- **Issue:** [#962](https://github.com/callstackincubator/simlock/issues/962), from request [#946](https://github.com/callstackincubator/simlock/issues/946)
- **Narrows:** ADR 0001 §1, §2, §3, §5, §6 and §7, for iOS simulators under `drivers.ios.deviceSet: "default"`. Android, physical devices and the `simlock` setting keep ADR 0001 unchanged.
Expand Down
2 changes: 1 addition & 1 deletion docs/internal/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,4 +86,4 @@ the status is stale.
| [0045](0045-a-devices-screen-streams-to-operators-not-a-leases.md) | A device's screen streams to operators, not a lease's | Accepted — not yet implemented |
| [0046](0046-a-standalone-worker-serves-agent-device-by-default-with-a-private-profile.md) | A standalone worker serves agent-device by default and hands local agents a private profile | Accepted — not yet implemented; narrows 0023 §1, §3–4, 0030 §2, 0039 and 0043 §4 |
| [0047](0047-device-exec-fills-in-the-leases-device.md) | `device.exec` fills in the lease's device, and refuses another | Accepted — not yet implemented; extends 0005 §19a; narrows 0003 §11 for one MCP tool |
| [0048](0048-an-ios-worker-may-keep-its-simulators-in-xcodes-device-set.md) | An iOS worker may keep its simulators in Xcode's device set | Accepted — not yet implemented; narrows 0001 §1, §2, §3, §5, §6 and §7 for iOS under `deviceSet: "default"` |
| [0048](0048-an-ios-worker-may-keep-its-simulators-in-xcodes-device-set.md) | An iOS worker may keep its simulators in Xcode's device set | Accepted; narrows 0001 §1, §2, §3, §5, §6 and §7 for iOS under `deviceSet: "default"` |
3 changes: 2 additions & 1 deletion docs/internal/agent-rules/safety.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,8 @@ never enforce them only inside an individual rule or driver.
`deviceSet: "default"` there is no root to validate; the driver instead
refuses to start while devices remain in the other setting's location (ADR
0048 §3).
Never fall back to the default device location, and never adopt or mark a
After a failed root validation, never fall back to the default device
location, and never adopt or mark a
root Simlock did not create empty itself. The same applies to Simlock's adb
server port: if it is occupied, the Android driver fails rather than
attaching to whatever server is already listening there. Physical Android
Expand Down
2 changes: 1 addition & 1 deletion docs/internal/flows/device-exec.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ Runs the command on the machine that owns the device and streams its output back

| Tool | Device | Command | Refused |
|---|---|---|---|
| `simctl` | iOS simulator | `xcrun simctl --set <device root> <args>`; the lease's UDID goes first among the operands of a device verb (`src/drivers/ios/simctl-device.ts`) | another UDID or `booted` on a device verb, `simctl` on a non-iOS lease, `create`, `erase`, `delete`, `shutdown all`, `runtime delete`, any flag before the subcommand, a physical device |
| `simctl` | iOS simulator | `xcrun simctl --set <device root> <args>` (no `--set` under `drivers.ios.deviceSet: default`); the lease's UDID goes first among the operands of a device verb (`src/drivers/ios/simctl-device.ts`) | another UDID or `booted` on a device verb, `simctl` on a non-iOS lease, `create`, `erase`, `delete`, `shutdown all`, `runtime delete`, any flag before the subcommand, a physical device |
| `devicectl` | iOS physical | `xcrun devicectl <cmd> --device <udid> <rest>`; `--device` added unless the args name one; a `--device` / `-d` that is not the lease's UDID is refused | a command not on the allowed list (`src/drivers/ios/devicectl-exec.ts`), an option off it, host-path options (`--json-output`, `--log-output`, `--destination`, `--file`, `--filter`), a simulator |
| `adb` | Android emulator | `adb -P <simlock port> -s <serial> <args>`, `ANDROID_ADB_SERVER_PORT` set; no `-s` for a no-device subcommand (`devices`, `version`, `forward --list`, …) or an empty address | `-s` other than the lease's serial, `-t` whatever it names, `-e`, `-d`, `adb` on a non-Android lease, bare `adb shell`, `kill-server`, `emu kill`, `emu avd stop`, `emu avd snapshot delete`, globals other than `-s`/`-t`/`-d`/`-e` |
| `adb` | Android physical | `adb -P <default port> -s <serial> <args>`; strips `ANDROID_ADB_SERVER_ADDRESS`, `ADB_SERVER_SOCKET`, `ANDROID_SERIAL` | bare `adb shell`, any leading flag, server and transport verbs (`kill-server`, `connect`, `tcpip`, `usb`, …), `reboot bootloader`/`sideload`, a closed default-server gate |
Expand Down
58 changes: 58 additions & 0 deletions e2e/slow-agent-device.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -666,6 +666,64 @@ describe.skipIf(AGENT_DEVICE === undefined || !iosAvailable)(
() => defineSuite(IOS),
);

// why: under `deviceSet: "default"` Simlock keeps its simulators in Xcode's own set, so plain `xcrun simctl` (no `--set`) must see the leased one; a daemon with a home of its own makes this a second daemon, not the shared one above.
describe.skipIf(AGENT_DEVICE === undefined || !iosAvailable)(
"agent-device through simlock (iOS, drivers.ios.deviceSet default)",
{ tags: ["slow", "ios"] },
() => {
let env: SharedTestEnv;

beforeAll(async () => {
const pinned = await assertPinnedAgentDevice("ios");
env = await withSharedDaemon({
driver: "real",
configOverrides: {
http: { enabled: true, host: "127.0.0.1", port: await freeLoopbackPort() },
capabilities: { "agent-device": { enabled: true, command: pinned } },
capacity: { config: { limits: { ios: { maxDevices: 1, maxRunning: 1 } } } },
drivers: { ios: { deviceSet: "default" } },
},
});
sharedEnvs.add(env);
const probe = await newAgentToken(env);
await waitForAgentDevice(env, probe.secret);
}, 420_000);

afterAll(async () => {
await cleanupAgents();
// why: the simulators are in the maintainer's Xcode set, which the home's removal does not clean.
if (env !== undefined) {
await env.cli(["release", "--all", "--yes"], { timeout: 60_000 });
await env.cli(["nuke", "--delete-devices", "--yes"], { timeout: 300_000 });
}
await env?.teardown();
sharedEnvs.delete(env);
}, 420_000);

it(
"under a daemon with drivers.ios.deviceSet default, an agent opens an iOS device, plain xcrun simctl list devices lists its UDID, and snapshot answers",
{ timeout: 900_000 },
async () => {
const a = await makeAgent(env, "default-set");
const opened = await a.run(["open", "--platform", "ios"], { timeout: 330_000 });
ok(opened);
const [lease] = await listLeases(env);
const udid = (await listDevices(env)).find(
(device) => device.id === lease?.deviceId,
)?.address;
expect(udid, "the leased device's address").toBeDefined();
expect(
execFileSync("xcrun", ["simctl", "list", "devices"], { encoding: "utf8" }),
).toContain(udid as string);
ok(await a.run(["install", "Appduct", IOS.app]));
ok(await a.run(["open", IOS.bundleId]));
ok(await a.run(["snapshot"], FIRST_SNAPSHOT));
ok(await a.run(["close"]));
},
);
},
);

describe.skipIf(AGENT_DEVICE === undefined || !androidAvailable)(
"agent-device through simlock (Android)",
{ tags: ["slow", "android"] },
Expand Down
35 changes: 34 additions & 1 deletion scripts/slow-e2e.sh
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@
# Before a run it sources `.slow-lane.env` from the main checkout's root, if there is one: this
# machine's settings, such as SIMLOCK_E2E_AGENT_DEVICE. A run's temporary homes live under
# /tmp/simlock-slow, so `clean` and `stop` find a run's daemons, emulators and simulators by
# that path and touch nothing else. A run starts by cleaning up after earlier ones.
# that path and touch nothing else, except the simulators in Xcode's own device set whose durable
# mark names the instance of a home under that path (a daemon run with drivers.ios.deviceSet
# "default" keeps them there): `clean` and `stop` delete those too. A run starts by cleaning up
# after earlier ones.
#
# The run is detached from the calling shell, so no tool time limit kills it halfway and
# leaves booted devices behind. Its own harness tears down the devices it created.
Expand Down Expand Up @@ -90,10 +93,40 @@ def clean():
os.kill(pid, signal.SIGKILL)
except OSError:
pass
delete_marked_simulators()
shutil.rmtree(lane_tmp, ignore_errors=True)
for pid, name in procs:
print(f"stopped {pid} {name}")

def lane_instance_ids():
# why: each lane home holds its instance id in instance.json (a gateway's under gateway/); the homes sit one or two levels below lane_tmp.
ids = set()
for base, dirs, files in os.walk(lane_tmp):
if base[len(lane_tmp):].count(os.sep) >= 3:
dirs[:] = []
if "instance.json" in files:
try:
ids.add(json.loads(read(os.path.join(base, "instance.json")))["instanceId"])
except (ValueError, KeyError, TypeError):
pass
return ids

def delete_marked_simulators():
# why: with drivers.ios.deviceSet "default" the lane's simulators live in the maintainer's Xcode set, which removing the lane folder does not clean; only a durable mark naming a lane instance proves a simulator is the lane's.
ids = lane_instance_ids()
devices = os.environ.get("SIMLOCK_SLOW_DEVICES") or os.path.expanduser("~/Library/Developer/CoreSimulator/Devices")
if not ids or not os.path.isdir(devices):
return
for udid in sorted(os.listdir(devices)):
try:
mark = json.loads(read(os.path.join(devices, udid, "simlock-mark.json")))
except ValueError:
continue
if isinstance(mark, dict) and mark.get("instanceId") in ids:
subprocess.run(["xcrun", "simctl", "shutdown", udid], capture_output=True)
subprocess.run(["xcrun", "simctl", "delete", udid], capture_output=True)
print(f"deleted simulator {udid}")

def free_gib(path):
stats = os.statvfs(path)
return stats.f_bavail * stats.f_frsize / 2**30
Expand Down
8 changes: 7 additions & 1 deletion src/agent-device/host/agent-device-supervisor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -622,7 +622,7 @@ describe("AgentDeviceSupervisor spawning", () => {
},
);

it("passes every key and value of the drivers' environment unchanged, and copies the iOS device set to the agent-device key", async () => {
it("passes every key and value of the drivers' environment unchanged, copies the iOS device set to the agent-device key, and sets none when the lease environment has none", async () => {
const harness = await run({
driverEnvironment: {
ANDROID_ADB_SERVER_PORT: "5199",
Expand All @@ -640,6 +640,12 @@ describe("AgentDeviceSupervisor spawning", () => {
});
expect(env["AGENT_DEVICE_ANDROID_DEVICE_ALLOWLIST"]).toBeUndefined();
expect(running(harness).platforms).toEqual(["ios", "android"]);

const xcodeSet = await run({ driverEnvironment: { ANDROID_ADB_SERVER_PORT: "5199" } });
expect(running(xcodeSet).platforms).toEqual(["ios", "android"]);
expect(xcodeSet.runner.calls[1]?.options.env).not.toHaveProperty(
"AGENT_DEVICE_IOS_SIMULATOR_DEVICE_SET",
);
});

it("points the daemon at an empty private directory when the iOS driver is off", async () => {
Expand Down
13 changes: 13 additions & 0 deletions src/cli/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ async function run(
try {
const response = await client.runDoctor({ fix: values.fix === true, purgeOrphans });
writeDriverAdvisoryWarnings(environment, response);
writeUnavailableDrivers(environment, response);
writeMissingPrerequisites(environment, response);
writeBrokenService(environment, response);
writeUnreachableWarmTargets(environment, response);
Expand All @@ -50,6 +51,18 @@ function writeDriverAdvisoryWarnings(
}
}

function writeUnavailableDrivers(
environment: CommandContext["environment"],
report: DoctorReport,
): void {
for (const finding of report.findings) {
if (finding.kind !== "driver-unavailable") continue;
environment.stderr.write(
`Unavailable [${finding.platform}] ${finding.reason}: ${finding.detail}\n`,
);
}
}

function writeMissingPrerequisites(
environment: CommandContext["environment"],
report: DoctorReport,
Expand Down
27 changes: 27 additions & 0 deletions src/cli/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2624,6 +2624,33 @@ describe("CLI: daemon status (ADR 0003 §11)", () => {
);
});

it("simlock doctor writes `Unavailable [<platform>] <reason>: <detail>` on stderr for a driver-unavailable finding and prints the report on stdout unchanged", async () => {
const output = outputCapture();
const findings = [
{
detail: "Refusing the ios device set: bad. Restart the daemon.",
kind: "driver-unavailable" as const,
platform: "ios" as const,
reason: "invalid-device-set",
},
];

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

expect(output.stderr.split("\n")).toContain(
"Unavailable [ios] invalid-device-set: Refusing the ios device set: bad. Restart the daemon.",
);
expect(JSON.parse(output.stdout)).toEqual({ findings });
});

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 = [
Expand Down
2 changes: 1 addition & 1 deletion src/contract/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ Three identities:

## Protocol version

- `PROTOCOL_VERSION_RANGE` in `protocol.ts` holds it, now `{ min: 59, max: 59 }`. `DAEMON_PROTOCOL_VERSION` (`src/daemon-protocol/`) is its `max`.
- `PROTOCOL_VERSION_RANGE` in `protocol.ts` holds it, now `{ min: 62, max: 62 }`. `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. `.agents/scripts/protocol-bump-check.mjs` checks
it on push and in CI; it counts every file here as a shape but tests, `index.ts` and the
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: 61, max: 61 };
export const PROTOCOL_VERSION_RANGE: ProtocolRange = { min: 62, max: 62 };

const LEGACY_DAEMON_PROTOCOL_VERSION = 2;

Expand Down
17 changes: 16 additions & 1 deletion src/contract/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,16 @@ export function quarantineCauseMessage(text: string): string | undefined {
const cleaned = text.replace(CONTROL_CHARACTERS, " ").trim();
if (cleaned === "") return undefined;
if (cleaned.length <= QUARANTINE_MESSAGE_MAX) return cleaned;
let cut = cleaned.slice(0, QUARANTINE_MESSAGE_MAX - 1);
return boundDriverSummary(cleaned, QUARANTINE_MESSAGE_MAX);
}

/** The longest `detail` a `driver-unavailable` finding carries. */
export const DRIVER_DETAIL_MAX = 1000;

/** Text cut to `max` characters, ending in `…` inside it and never inside a surrogate pair. */
export function boundDriverSummary(text: string, max: number): string {
if (text.length <= max) return text;
let cut = text.slice(0, max - 1);
const last = cut.charCodeAt(cut.length - 1);
if (last >= 0xd800 && last <= 0xdbff) cut = cut.slice(0, -1);
return `${cut}…`;
Expand Down Expand Up @@ -910,6 +919,12 @@ const doctorFindingSchema = z.discriminatedUnion("kind", [
code: z.string(),
message: z.string(),
}),
z.object({
kind: z.literal("driver-unavailable"),
platform: platformSchema,
reason: z.string().max(64),
detail: z.string().max(DRIVER_DETAIL_MAX),
}),
z.object({
kind: z.literal("service-broken"),
manager: z.enum(["launchd", "systemd"]),
Expand Down
4 changes: 3 additions & 1 deletion src/core/device-root.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ export type RootRejectionReason =
| "wrong-owner"
| "wrong-permissions"
| "non-empty-unowned-root"
| "unreadable";
| "unreadable"
| "invalid-device-set"
| "device-set-changed";

export interface OwnedRootMarker {
readonly schemaVersion: 1;
Expand Down
15 changes: 15 additions & 0 deletions src/core/doctor.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from "vitest";

import { EventBus } from "../bus/index.js";
import { DRIVER_DETAIL_MAX } from "../contract/index.js";
import { FakeClock, JsonLinesLogger, MemoryLogSink } from "../ports/index.js";
import { FakeSystemStats } from "../ports/index.js";
import type { Config } from "./config.js";
Expand Down Expand Up @@ -1637,7 +1638,21 @@ describe("Doctor", () => {
eventBus,
registry,
}).reconcile();
const long = await new Doctor({
decisions: new SerializedDecision(),
clock,
config: config(),
drivers: [],
driverRejections: [{ ...rootRejection(), summary: "x".repeat(5000) }],
eventBus,
registry,
}).reconcile();

const longDetail = (long.findings[0] as { detail: string }).detail;
expect(longDetail.length).toBeLessThanOrEqual(DRIVER_DETAIL_MAX);
expect(longDetail).toMatch(
/…\. Driver discovery runs once.*restart the daemon.*unavailable\.$/,
);
expect(report.findings.map((finding) => finding.kind)).toEqual(["driver-unavailable"]);
expect(report.findings[0]).toMatchObject({
// Discovery happens once, at startup, so the finding has to say what actually
Expand Down
5 changes: 3 additions & 2 deletions src/core/doctor.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { EventBus } from "../bus/index.js";
import { parseOsConstraint } from "../contract/index.js";
import { boundDriverSummary, DRIVER_DETAIL_MAX, parseOsConstraint } from "../contract/index.js";
import { type Clock, type Logger, NoopLogger } from "../ports/index.js";
import type { Config } from "./config.js";
import {
Expand Down Expand Up @@ -1136,7 +1136,8 @@ const DRIVER_RETRY_REMEDY =

function driverUnavailableFindings(rejections: readonly DriverRejection[]): DoctorFinding[] {
return rejections.map((rejection) => ({
detail: `${rejection.summary}. ${DRIVER_RETRY_REMEDY}`,
// why: a driver's summary is an error message of any length; the remedy is kept whole.
detail: `${boundDriverSummary(rejection.summary, DRIVER_DETAIL_MAX - DRIVER_RETRY_REMEDY.length - 2)}. ${DRIVER_RETRY_REMEDY}`,
kind: "driver-unavailable" as const,
platform: rejection.platform,
reason: rejection.reason,
Expand Down
Loading
Loading