Reports › Contract docs › build-plan-v1

IO diagnostics v1 — build plan (2026-10-22, Zach: "own the dashboard; yes please start implementing")

Turns V6 converged (prototypes/v6-converged) into the real tool. Spec sources: docs/converged-design-2026-10-22.md, docs/diagnostics-record-v0.md + contract/record.py (the record contract — FROZEN once committed; lanes import it, never edit it), docs/research/*.

Decisions (Zach, 2026-10-22)

  1. Own the dashboard. Interop by adapter: one pure function to_stock(record) -> DiagnosticStatus dict that strips our added fields; a standard viewer (Lichtblick, or ROS 2 ros2 topic echo /diagnostics) must show our records. "I always like that design, it makes it backwards compatible."
  2. Implement now: one Python process, a tray chip, a web window; driver diagnostics in moteus_servo and intel_rgbd.
  3. Report feedback (visual noise): - Disclosure chevrons appear on hover (Notion-style, over the existing icon) or IDE-style left of the row — never a permanent column of arrows. - Type icon and name sit together; the status icon moves to the RIGHT. - Quiet when OK: no green dots. OK rows show nothing. Warnings and errors use SHAPES (▲ warning, ⬣/✖ error, clock for stale), not just colour dots. - Not connected = fully greyed; connected = normal grey text; only problems get colour.
  4. Beyond USB: discovery must also cover PCI/PCIe (hyperspectral camera on a frame grabber), network interfaces (EtherCAT segments: detect the NIC + any running master, don't scan the bus yet), serial ttys, video4linux.

Plugin boundary (implementation decision — orchestrator, 2026-10-22)

A plugin is an EXECUTABLE that speaks JSON lines on stdout (Arduino pluggable-discovery precedent, research lane): <cmd> describe → {name, version, schema: contract.record.SCHEMA ("diagnostics-record/0"), matches: [usb vid:pid | by-id glob | pci class | netdev | tty glob], actions: [...]}; <cmd> diagnose --device <id> → v0 records as JSON lines, each tagged by its record key (Entity / DiagnosticStatus / Event; never Fault — faults are kept by the host's FaultManager); <cmd> act --device <id> --action <name> --confirm <token> → result record. Plugins are declared in plugins.d/*.toml (name, command, cwd, timeout, period). WHY a subprocess: driver dependencies (moteus, pyrealsense2) stay in the driver's own uv env, a crashing driver can't take the host down, any language works, and the driver's diagnostics.py is "the exact same code" the driver already has.

A real act is a run on a terminal (additive, 2026-10-24; Zach: "I would like to see the terminal output ... or enter user input"; contract: docs/runs-v1.md). The host starts a real act (never a dry run) with stdin, stdout and stderr on a PTY, so a tool prints progressively, redraws with \r and can wait for a typed answer, and passes the plugin a second descriptor named in IO_DIAGNOSTICS_RECORD_FD: - Set: the plugin writes its JSON-line records (the action-result, Events like ActionCompleted) to that fd and everything a person would read to stdout / stderr; a wrapped vendor tool INHERITS the terminal (live output, it can prompt). Not set (a dry run, diagnose, probe, follow, an old plugin that ignores the variable): stdout stays the record channel, exactly as before. An old plugin under a run still works: the host finds no record on the fd and reads the terminal's lines that parse as a record (they also show in the terminal). - stop, an optional verb ("stop": true in describe): <cmd> stop --device <id> [--node N] puts the device in its quiet state (moteus: mode 0), needs no --confirm (stopping is the safe direction), prints JSON lines, and is safe at any time. The host runs it itself, never the page, after a moves-class run that did not finish (stopped, timed out, failed). - Stop from the page is SIGINT to the plugin's process group, then SIGTERM and SIGKILL after 3 s each; a step that must survive a stopped plugin (a firmware flash) keeps its own session.

follow, an optional long-running verb (additive, 2026-10-23; Zach: "something that feels a bit like system monitor with the live data"). A plugin may declare in describe "follow": {"period_s": 1} (or "follow": true, 1 s). The host's service (serve, tray; never once) then runs NO periodic diagnose for that plugin's devices and keeps ONE <cmd> follow --device <id> [diagnose_args] per matched device alive instead (runner.Follower): - Lines are exactly diagnose's (contract.record JSON lines: Entity / DiagnosticStatus / Event, paths relative to the device, the same validation), printed and flushed one batch per period, forever. The host applies each line as it arrives, so the history fills at the plugin's rate. Statuses set expected_period_s to their real cadence. - Environment as for diagnose; IO_DIAGNOSTICS_NOW is the start time. - Restarts: when it exits (any code) or prints nothing for max(10 × period_s, timeout_s) (its process group is killed), the host restarts it after 1 s, doubling to 60 s, back to 1 s after a run of 60 s or more. While it is down the device row's <plugin>: plugin run is ERROR PluginCrashed ("follow exited 1: ... · restarting in 4 s"); while it runs, OK "following since ...", re-said once per period, so a silent follower goes STALE. - Bounded: a line over 256 KiB is rejected and skipped unread, stderr keeps its last 20 lines, the job's record cache keeps the latest per entity / check and at most 500 events. A device that leaves stops its follower. - Stop: the host kills the process group (SIGKILL) on stop or when the device leaves; a follower must hold nothing that needs a clean exit. plugins/computer follows at 1 Hz (docs/api-v1.md /api/v1/plugins → follow).

probe, an optional on-connect verb (additive, 2026-10-23; Zach: "each device tells diagnostics what to do"). A plugin may declare in describe "probe": {"timeout_s": 30} (or true). The host's service then runs <cmd> probe --device <id> [diagnose_args] once per connection (first match, host start, re-enumeration) in place of that connection's first diagnose, starts the periodic diagnose / follow period_s after it, and runs it again when it ended blocked (PortBusy / BusLockHeld): ~1 s after the last holder pid exits, plus a 10/20/40 s backoff capped at period_s, inside the exclusive queue. The driver says what it found in its own Found / Probed / ProbeBlocked events; the host derives none. The contract, with the standard codes and the safety rule (query-only): docs/probe-on-connect.md; the host side: io_diagnostics/probe.py, Host._probed, tests/test_probe.py.

Lanes (disjoint write sets)

Hard rules

Frozen interfaces between lanes (orchestrator, 2026-10-22)