Skip to content

Hotfix/make qpi driver py more extensible - #34

Merged
Tinitto merged 31 commits into
sopherapps:mainfrom
ch-ahindura:hotfix/make-qpi-driver-py-more-extensible
Jul 29, 2026
Merged

Hotfix/make qpi driver py more extensible#34
Tinitto merged 31 commits into
sopherapps:mainfrom
ch-ahindura:hotfix/make-qpi-driver-py-more-extensible

Conversation

@ch-ahindura

Copy link
Copy Markdown
Contributor

Why

It was hard to create custom drivers, even after the driver framework was set up.
PROCESS_DRIVERS, MONITOR_DRIVERS etc. were hard coded values in the qpi-driver and it was difficult to set up the CLI
for a custom driver because the CLI in qpi-driver depended on hard-coded variables.

What was done

Added

  • repo: Added make test-docs, and a CI job that runs it on every pull request — docs.yml only ever ran on a v* tag, so nothing checked the documentation before a merge. Five steps, so a failure says which kind of claim broke: static (every make target, repository path, markdown link and qpi-driver flag a document names, the flags read from each SDK's own --help), snippets (the Python blocks executed against the real SDK, the Go and TypeScript blocks extracted and compiled against this checkout, and the four error transcripts in docs/driver/operations.md compared with what the CLI prints), example (examples/custom_device installed against the local SDK, then asked for via catalog --json), catalog (sync-driver-catalog re-run and diffed against a snapshot, so a stale generated table fails rather than sitting there), and site (mkdocs build --strict, which catches a dead nav entry or internal link). A block that is meant to fail opts out with <!-- docs-check: skip -->; a compiled block opts in with <!-- docs-check: compile=<name> -->. CHANGELOG.md is exempt: it records commands that no longer work on purpose.
  • qpi-driver/py: Added the device catalog (RFC 0003 §5) — Operation, OperationSpec, DeviceSpec, OptionSpec and DeviceBuilder in qpi_driver.builtins.registry, with register(), operations(), devices() and resolve(). A device now describes itself as data next to its own code, so --device accepts it without any change to the CLI.
  • qpi-driver/py: Added qpi_driver.builtins.qpu.device_spec() for describing a process device that runs a given executor, including one the SDK does not ship.
  • qpi-driver/py: process --help and monitor --help now list every device the operation can run and every -o key each one reads, with its type, default, example and whether it is required — generated from the device specs, so a new device appears with no CLI change. A device whose install target is not fully installed is listed as qblox — unavailable: pip install "qpi-driver[cli,qblox]", read from package metadata without importing anything, so --help works when a device does not.
  • qpi-driver/py: Added qpi-driver devices [--operation OP], the same catalog as readable text for every operation at once.
  • qpi-driver/py: Added qpi-driver catalog --json, the whole catalog as JSON for another program to read. The shape is a contract — QPI-UI checks its own catalog against it, and the Go and TypeScript SDKs mirror it — carries a schema_version, and is documented in full on qpi_driver.builtins.catalog.catalog_dict (RFC 0003 §9).
  • qpi-driver/py: Added DeviceSpec.parse_options(), OptionSpec.parse/OptionSpec.type_name and qpi_driver.paths.as_safe_dir() — an option's value is checked and converted by its own schema, in one place, instead of each builder hand-rolling int(...), boolean-ish string parsing and path validation.
  • qpi-driver/py: A distribution can now advertise devices through the qpi_driver.devices entry-point group (RFC 0003 §6). pip install mylab-devices and its devices appear in --help, in catalog --json and to --device, indistinguishable from a built-in, with nothing to change in the SDK. An entry point that will not import or does not resolve to a DeviceSpec is logged and skipped, never fatal.
  • qpi-driver/py: A device can now be named by import path — --device mylab.devices:PrestoV2, or mylab.devices.PrestoV2, following Pydantic's ImportString convention. For process it must import to an Executor subclass or instance, for monitor to a device builder; either accepts a DeviceSpec, which is how a custom device gets a declared option schema and a --help entry. Having no declared schema, an import-path device's undeclared -o options are passed through as strings — its declared ones, data_dir included, are still validated, so the safe-path check applies on this route too. A path that will not import exits 1 with one line, with the filesystem path Python volunteers stripped out of it (RFC 0003 §10).
  • qpi-driver/py: Added qpu.device_spec(options=...) for a custom process device that wants to declare its executor's own -o options, and DeviceSpec.accepts_any_option for one that cannot.
  • qpi-driver/py: Added qpi-driver/py/examples/custom_device/ — an Executor, a DeviceSpec, and the pyproject.toml entry-point stanza, with a README covering all three ways to run it.
  • qpi-driver/py: JobPayload and CircuitPayload are now exported from qpi_driver itself, which is what writing an executor needs.
  • qpi-driver/go: Added the importable devices package — Operation, OperationSpec, OptionSpec, DeviceSpec, DeviceBuilder, Options, Registry, Register, Devices, Resolve, Catalog — the Go counterpart of the Python SDK's device catalog (RFC 0003 §5, §8). The device table was an unexported map[string]deviceRunner in package main, so nothing downstream could extend it.
  • qpi-driver/go: Added the importable cli package with NewRootCmd and Execute. Go has no runtime import by name, so extension is compile-time: a build of your own calls devices.Register and then cli.Execute, and gets the same start/devices/catalog commands, the same generated help and the same option validation as the shipped binary. qpi-driver/go/qpi-driver/main.go is now exactly that — register the built-ins, hand off. The README documents it with an example that is compiled as part of verifying it.
  • qpi-driver/go: Added qpi-driver devices [--operation OP] and qpi-driver catalog --json, emitting the same document as the Python SDK — byte-identical for the shared bluefors_gen1 device, apart from extra, which names a Python install target and is empty where a device is compiled in.
  • qpi-driver/go: The bluefors_gen1 monitor now describes itself as a DeviceSpec beside its own code, with its five -o options typed. qpi-driver/go/cli and qpi-driver/go/devices have tests where qpi-driver/go/qpi-driver had none at all.
  • qpi-driver/js: Added the qpi-driver/devices and qpi-driver/catalog entry points — Operation, OperationSpec, OptionSpec, DeviceSpec, DeviceBuilder, Options, registerDevice, devices, resolve, catalog, renderCatalog — also re-exported from the package root (RFC 0003 §5, §8). Registering a device makes the CLI run it, with generated help, a catalog --json entry, and -o values checked and converted by the device's own schema.
  • qpi-driver/js: A device can now be named by import path — --device ./dist/my-device.js#MyExport. The separator is # rather than Python's : because : is a URL scheme separator in a JavaScript module specifier; a name without # is still read as a registered name, so a typo gets the known-devices error rather than an import failure. The export may be a DeviceSpec or a builder. A path that will not import exits 1 with one line, with the absolute paths Node volunteers reduced to their last segment (RFC 0003 §10).
  • qpi-driver/js: Added qpi-driver devices [--operation OP] and qpi-driver catalog --json, in the same frozen shape as the Python and Go SDKs.
  • qpi-driver/js: src/builtins/cli.ts has a test file, and src/devices.ts/src/catalog.ts are covered too — 81 tests where the CLI had none.
  • qpi-driver/js: --ca-file is now honoured: the downloaded root CA is written there after it has been verified, as the Python and Go SDKs do. It was parsed and ignored, so the file never appeared.
  • qpi-driver, qpi-ui: Added coverage measurement and gates, where nothing measured coverage before. make test-py-cli enforces 96% over the Python framework modules (achieved 99%), make test-go-driver enforces 94% over the Go devices and cli packages (achieved 97.8%), and npm test enforces per-file thresholds on the TypeScript catalog, CLI and CA pinning (achieved 92–100%). Each gate was checked by making coverage drop and watching it fail. The hardware executors and the NNG transport are reported but not gated: they need real instruments or a live server, and are covered by make test-e2e-driver.agents/ROADMAP-0003-driver-extensibility.md records every exclusion with its reason.
  • qpi-driver/py: The QPU worker, the result pump, the SDK's receive loop and shutdown, the pinned-CA download, and the job envelope's validation all have unit tests now — the failure paths the e2e suite cannot reach: an executor that will not resolve, a job that raises, a malformed payload, a socket that times out, a fingerprint that does not match.
  • docs: Added an "Adding a device on a production node" runbook section (docs/driver/operations.md) — how an operator discovers what a node can run, the two ways a third-party device gets there, and what each of the four -o validation errors looks like, quoted from the CLI rather than paraphrased.
  • docs: The -o option tables in qpi-driver/py/README.md are now generated from catalog --json by make sync-driver-catalog, between <!-- catalog:begin --> markers, so they cannot fall behind the code.
  • docs: Added make sync-driver-catalog, which regenerates both the qpi-ui drift fixture and the README option tables from the Python SDK's catalog.
  • qpi-ui: Added a drift check between this server's driver catalog and the SDKs' (RFC 0003 §9). qpi-ui/internal/drivers now tests itself against a checked-in testdata/catalog.json generated by qpi-driver catalog --json, and fails when the two disagree about which devices exist, which install target ships them, or which -o keys each reads — and the other way, when the SDKs know an operation this server has no handler for. Regenerate the fixture with make sync-driver-catalog, which every failure message names; doing so is the deliberate act of recording a catalog change.
  • qpi-ui: drivers.Option now carries Help, Required, Default and InSnippet alongside Example, mirroring the SDKs' option schema. The process devices' five -o keys are filled in, where processSpec previously declared none — they have working defaults, so none of them is pre-filled in a rendered snippet, but the catalog now says they exist. InSnippet is what decides that: bluefors_gen1 pre-fills channels and base_url and keeps api_key, poll_interval and timeout out of a copy-pasted command.

Changed

  • qpi-driver: [BREAKING] The process and monitor subcommands are replaced by one start verb taking --operation, in all three SDKs at once (RFC 0003 §4). One verb because everything about launching a driver is the same whichever operation it is — and because a third party can add a device, but only QPI-UI can add an operation, so the operation is an argument rather than part of the grammar.

    Before After
    qpi-driver process --device qblox … qpi-driver start --operation process --device qblox …
    qpi-driver monitor --device bluefors_gen1 … qpi-driver start --operation monitor --device bluefors_gen1 …

    --operation is required, has no short form (-o is --option, and -O beside it would be a hazard), and reads QPI_OPERATION. --device and --name, when omitted, now come from the operation's own defaults. The dashboard's setup snippets, all three install-systemd.sh installers and the e2e harness render the new grammar; the OPERATION environment variable the installers take is unchanged.

  • qpi-driver/go, qpi-driver/js: [BREAKING] start --operation process now says that the SDK ships no process devices and where to find one, instead of unknown process device "mock"; known devices: with an empty list and a default device that never existed there (RFC 0003 §8).

  • qpi-driver/js: [BREAKING] A CA fingerprint is now required, and there is no longer any code path that connects without checking it. Certificate pinning is the industry term for what the check does: a driver downloads the server's root CA over plain HTTP, then refuses it unless the SHA-256 of its DER bytes equals the fingerprint the operator was handed out of band. Pinning one certificate is what makes the download safe — without it, anything that can answer for the server's address can hand the driver a CA of its own and read every job that follows. The SDK skipped the check entirely when caFingerprint was absent, so an unpinned connection was reachable by leaving an argument out — the kind of opt-out a copy-pasted command hits by accident (RFC 0003 §10). QpiDriverOptions.caFingerprint is now required, and verifyFingerprint throws on an empty one rather than returning quietly.

  • qpi-driver/js: [BREAKING] DeviceRunner is now DeviceBuilder, and a builder receives (config, options) where options is an Options carrying values already checked and converted against the device's own schema, rather than a Record<string, string>. An -o key the chosen device does not read is now an error naming the keys it does, where before it was silently ignored.

  • qpi-driver/js: [BREAKING] --recv-timeout-ms is gone. It was parsed and ignored, and it names a polling interval this SDK does not have — the TypeScript transport is event-driven, so there is nothing for it to time out. Accepting it was advertising a knob that did nothing.

  • qpi-driver/py: [BREAKING] An -o key the chosen device does not read is now an error naming the keys it does, where before it was silently ignored. A typo such as -o data_dirr=/data used to mean a driver running with a default nobody chose; it now exits 1. Anything that passed an unrecognised -o key deliberately, expecting it to reach the executor, must declare it in a DeviceSpec (see qpu.device_spec()).

  • qpi-driver/py: [BREAKING] A device builder is now handed options that have already been checked and coerced against its own DeviceSpecbuild_from_options(options=spec.parse_options(raw)) — rather than raw strings. Calling a builder directly with {"job_timeout": "30"} no longer coerces it, and an omitted key is no longer defaulted by the builder.

  • qpi-driver/py: [BREAKING] A device builder now returns an unstarted driver and the caller starts it, matching the TypeScript SDK (RFC 0003 §7). A driver can therefore be built and asserted on with no server running.

    Removed Replacement
    run_driver(...) QpuDriver(...).run()
    qpu.run_process(device=..., ...) qpu.build_from_options(executor=..., ...).run()
    bluefors_gen1.run_monitor(...) bluefors_gen1.build_from_options(...).run()
    builtins.PROCESS_DRIVERS, builtins.MONITOR_DRIVERS builtins.devices(operation) / builtins.resolve(operation, device)
    builtins.DriverRunner builtins.DeviceBuilder (returns a driver rather than blocking)
    resolve_executor(executor, custom_executors, ...) resolve_executor(executor, ...) — pass the class or instance itself
    QpuDriver(custom_executors={"name": Cls}) QpuDriver(executor=Cls)
    QpuDriver.OPERATION, BlueforsGen1Driver.OPERATION DeviceSpec.operation
    qpu.execute_job() qpu.job_worker()
  • qpi-driver/py: The driver-authoring surface is unchanged — QpiDriver, handle_event(), emit(), every(), Event, EventType and Executor keep their names and signatures. Only how a driver is registered and launched moved.

Fixed

  • qpi-driver/js: The SDK set no timeout on any outbound network call, where the Python and Go SDKs both use 10 seconds. A server behind a firewall that drops packets left the driver hanging inside run() instead of failing — Node's fetch falls back to undici's defaults, which are minutes rather than seconds, and tls.connect has none at all beyond the OS TCP timeout, so a TLS handshake that stalls after TCP connect waited indefinitely. Under systemd's Restart=on-failure such a unit is never restarted, because it never fails. The drivers/connect handshake, the root CA download and both NNG dials now share the same hard-coded 10s deadline as the other two SDKs, and one that expires says what timed out and against which address rather than raising a bare abort. The deadline bounds the dial only: an idle connection afterwards is normal for this event-driven transport, which is why --recv-timeout-ms was removed rather than repurposed.
  • qpi-driver/go: --help and devices no longer advertise an operation's default device in a build that does not have it, and omitting --device in such a build now asks for one, naming what is registered, instead of failing over a device the operator never typed. Which devices a Go binary has is decided when it is compiled.
  • qpi-driver, qpi-ui: [BREAKING] The driver no longer names itself. --name/-n, QPI_DRIVER_NAME, the Name/name config field in all three SDKs and default_name/DefaultName/defaultName on the operation specs are all removed, and handleDriverConnect no longer writes a name into the driver record. The token is a driver's whole identity — it is what the record is looked up by and, transitively, what says which QPU the driver belongs to — while name is a cosmetic, non-unique display label that an admin types in the dashboard. Nothing looks a driver up by it and no unique index exists, so the only thing a --name ever achieved was to overwrite what the admin chose, on every connect, from a unit file nobody re-reads. POST /api/op/drivers/connect now returns name, so a driver learns its label instead of asserting one: read driver.name (Python, TypeScript) or DriverName() (Go) after connecting. Name is gone from DriverConnectRequest; an older driver that still sends one is not rejected, the field is simply not read. Host and Version stay accepted and are flagged as dead on the wire — no SDK has ever sent either.
  • qpi-driver/py: [BREAKING] A process device's datasets record the executor's own name as their backend attribute (mock, qblox, …) rather than the driver's display label. QpuDriver used to override the executor's name with its own, which is the only reason a _sanitize_name existed: a driver called lab-1 produced datasets claiming a backend of lab_1. _sanitize_name is gone with it.
  • qpi-driver: [BREAKING] install-systemd.sh reads SERVICE_NAME where it read QPU_NAME, in all three SDKs, and the dashboard's systemd snippet renders the new name. It never named a QPU or a driver: it names the unit file, its SyslogIdentifier and its data directory. Existing invocations must be updated; there is no alias.
  • qpi-driver/js: qpi-driver --help and --version exit 0. exitOverride() makes commander throw instead of exiting, so that the command tree can be driven in-process by a test — but that also turned printing help into a rejected promise, which the bin reported as an error and exited 1 for. The Go and Python CLIs exit 0, and a set -e script that asks a CLI what it can do before using it died on the answer.
  • qpi-driver/go: install-systemd.sh downloads and unpacks the Go toolchain into /usr/local/go when the node has none, instead of exiting with instructions to install it and run the script again. GO_VERSION overrides which; an architecture with no prebuilt tarball still exits with the link, and QPI_SKIP_INSTALL=1 still skips the whole step.
  • qpi-driver/py: A device installed through the qpi_driver.devices entry point is no longer skipped. qpi_driver.builtins ran load_installed_devices() at its own import time, and it is imported by qpi_driver/__init__.py on the way to binding Executor, JobPayload and OptionSpec — so a device written the documented way (from qpi_driver import Executor) was loaded against a half-initialised package and skipped with "cannot import name 'Executor' from partially initialized module". The entry-point route, the SDK's whole story for shipping a device, therefore worked for nobody. Discovery now runs at the end of qpi_driver/__init__.py, the first moment the package a device imports is complete; importing any qpi_driver submodule runs that file first, so no route into the registry skips it. It went unnoticed because the only tests of this route mocked importlib.metadata.entry_points; make test-docs now installs examples/custom_device against the local SDK and asserts the device is in catalog --json.
  • qpi-driver/py: Removed a dead branch in qpu.job_worker, which tested whether data_dir was already among the executor options — it never could be, being a parameter of that same function.
  • qpi-driver/go, qpi-driver/js: [BREAKING] install-systemd.sh no longer offers devices the SDK does not ship. Both installers were copied from the Python one, so both prompted with the Python device list (mock, qiskit_aer, quantify, qblox, presto, bluefors_gen1) and defaulted to OPERATION=process, DEVICE=mock. Pressing return through the prompts wrote and enabled a unit whose ExecStart can never succeed — "this build ships no process devices" — and, with Restart=on-failure, crash-looped it. Both now prompt with and default to monitor/bluefors_gen1, the one device each actually has. An OPERATION=process passed explicitly is no longer offered anywhere and will still fail; run a QPU from the Python SDK or a device of your own.
  • qpi-driver/js: install-systemd.sh installs Node.js with nvm when the target user has none, instead of exiting with instructions to install it and run the script again — the one thing a one-command installer should not do. It also writes a PATH into the unit file that contains that node: qpi-driver is a script with a #!/usr/bin/env node shebang, and systemd's default PATH has no nvm install on it, so a unit written without this started only for operators whose Node happened to be system-wide. NVM_VERSION and NODE_VERSION override what it installs, and QPI_SKIP_INSTALL=1 still skips the whole step.
  • qpi-driver/py: install-systemd.sh prompts for DRIVER_OPTIONS whatever the operation. It asked only for a monitor, on the reasoning that a process device's options are all defaulted — but a process device reads -o keys too (job_timeout, is_dummy), and only the data dir and the quantify config paths are the installer's own to fill in. Setting anything else meant editing the unit file afterwards.
  • docs: The root README.md described qpi-driver as a Python QPU daemon; it now describes the driver framework it is, with the operation/device split that makes it extensible and a QPU as one device of one operation (RFC 0003 §14). Each SDK README states what it actually ships, since only the Python SDK has a process device.
  • docs: Corrected the driver architecture in both the root and Python READMEs: results are pumped by a thread in the main process, not a third "Result Sender Process", and the whole worker arrangement belongs to the process operation — a monitor has none of it.
  • docs: Fixed the custom-executor example in qpi-driver/py/README.md a second time: it defined only execute(), so Executor's other abstract method made it impossible to instantiate. Every Python snippet in the driver documentation is now executed against the real SDK by make test-docs, so a third occasion is a failing build.
  • docs: RFC 0003 is Implemented, and RFC 0001 §2 now points at it for the operation/device layer rather than being edited in place.
  • docs: Rewrote the Python README's extension material to tell the same story the Go and TypeScript READMEs do: one heading, "Adding a device of your own", leading with the device — an executor plus a device_spec — and the entry point that ships it. QpuDriver(executor=…) is a short note under it rather than the first thing offered, because a reader with three co-equal mechanisms in front of them has to work out which one is theirs. The mechanism is unchanged.
  • docs: examples/custom_device renamed its ThermometerExecutor to QuantumXExecutor and probe_count to qubit_count. It is a process device — a QPU — and a thermometer is a monitor, so the example named itself after the wrong operation. Its execute() also returned a dict where Executor declares xr.Dataset, which is the contract process_result() and the driver's own dataset writing depend on; it returns a dataset now. pyproject.toml depends on qpi-driver[cli] rather than the bare SDK — without the extra there is no typer and no qpi-driver script, so the README's next command could not run — and resolves it from the sibling source tree, so the test installs this checkout rather than a published wheel.
  • docs: The three SDK READMEs used "the pinned root CA" and "the catalog" as if both were self-evident. Each now says what it is where it first appears: pinning is refusing any root certificate whose SHA-256 is not the fingerprint the operator was handed out of band, and the catalog is the list of operations, devices and -o options a build knows about, with the DeviceSpecs as its only source.
  • docs: qpi-driver/go/README.md had an empty "Running a built-in as a systemd service" heading, and qpi-driver/js/README.md had its manual instructions commented out and its installer example asking for --operation monitor --device qblox — a process device that neither SDK ships. Both now document the monitor devices they actually have, by installer and by hand.
  • docs: The "Upgrading?" note in each SDK README promised a migration table at CHANGELOG.md#migration, an anchor that moves to whichever release most recently had one. It now names the release boundary it is about and links the change log itself, so it cannot come to describe a release that never broke anything.
  • docs: qpi-driver/py/README.md introduced itself as "The Go SDK".
  • docs: The root README.md told the reader to pip install ./qpi-driver[cli], a directory with no pyproject.toml in it, and pointed -o quantify_device_config at a quantify.device.example.json that has never existed — the file is YAML, and both example configs live under qpi-driver/py/.
  • docs: docs/driver/operations.md closed with make test-e2e-driver-framework, a target that existed only in the Makefile's .PHONY list. Removed the phantom from .PHONY and named the real target.
  • qpi-ui: [BREAKING] Registering a driver whose kind the chosen language's SDK does not ship is now a 400 naming what that SDK does ship. POST /api/op/drivers/create accepted any kind×language pairing, and drivers.Snippets rendered setup commands for all of them — so kind=mock, language=go returned a --device mock against a Go binary with no process device at all, a command that exits 1 the first time it is pasted and says nothing about why. The dashboard's language selector now disables the languages a kind is not available in, rather than offering a choice the server rejects. A QPU in Go or TypeScript is a custom driver, which is registerable in every language by definition.
  • qpi-ui: The catalog drift check reads one fixture per SDK (testdata/catalog.{python,go,typescript}.json) instead of the Python one alone, and make sync-driver-catalog regenerates all three. Which SDK ships which device is a fact only the SDKs know and qpi-ui now records (drivers.Spec.Languages) — exactly the kind of copy that rots quietly, and the reason the bug above went unnoticed. catalog.json is now catalog.python.json.
  • qpi-driver/js: catalog --json no longer exits 1. Every README spells the command that way and the Go and Python CLIs both accept the flag — as a no-op, JSON being the default — but the TypeScript one had only --text, so the documented command failed on the one SDK of the three where interchangeability is the point.
  • repo: render_catalog_table.py moved from qpi-driver/ to a new top-level scripts/. It is documentation tooling for this repository — make sync-driver-catalog runs it — and sitting at the root of qpi-driver/ it read as part of a published SDK. qpi-driver/py/tests/half_imported_device.py moved to tests/fixtures/ for the same reason: it is not a test module but an input to one, a module that raises ImportError on purpose.
  • qpi-driver/py: Fixed the custom-executor example in qpi-driver/py/README.md, which passed a custom_executor= keyword that no function accepted and would have failed with Unknown executor name 'custom'.

Adds RFC 0003, which makes the driver backend set open to third parties:
an operation becomes a closed enum (it needs a QPI-UI handler), a device
becomes an open, data-described thing, and the CLI collapses to one verb
`start --operation <op> --device <dev>`. Devices are registered built-in,
via a `qpi_driver.devices` entry point, or by import path.

Also reverts the WIP extension points explored on this branch, which did
not build. A `pydantic.ImportString` annotation on a Typer option raises
`RuntimeError: Type not yet supported` while Typer constructs the command
tree, taking down every command including `version` and bare `--help`;
and a `**kwargs` parameter on a Typer command silently becomes a required
positional argument, so `qpi-driver process -d qblox` exited 2 with
`Missing argument 'kwargs'`. Both are superseded by RFC 0003.

`test_every_command_builds` guards both failure modes: it asserts the
whole command tree is constructible and that no operation command grew a
positional argument. Verified to fail on the reverted commit.

Kept from the WIP: the `recv_timeout_ms` move into the kwargs dict, the
deleted commented-out logging setup, and the README import cleanup.

Also registers RFCs 0002 and 0003 in the mkdocs nav; 0002 was missing.
Implements RFC 0003 §5-§7 for the Python SDK. The two hard-coded
PROCESS_DRIVERS/MONITOR_DRIVERS dicts and the `registry` parameter
threaded through the CLI are replaced by one registry of data-only
specs, and device builders now return an unstarted driver that the
caller starts.

An operation (process, monitor) stays a closed enum, because QPI-UI
must have a handler for each. A device is the open half: it describes
itself with a DeviceSpec declared next to its own code, so a device and
its description cannot drift apart, and adding one needs no CLI change.

Returning rather than running is what makes a QPU assertable without a
server — tests/test_qpu.py now checks every -o option lands on the
driver with no NNG socket and no worker subprocess involved.

BREAKING CHANGE: run_driver, qpu.run_process, bluefors_gen1.run_monitor,
PROCESS_DRIVERS, MONITOR_DRIVERS, DriverRunner, custom_executors (both
on QpuDriver and in resolve_executor), and the unread OPERATION class
attributes are removed. CHANGELOG.md carries the removed -> replacement
table. The driver-authoring surface — QpiDriver, handle_event, emit,
every, Event, EventType, Executor — is untouched, as is the CLI grammar.

Also fixes the README custom-executor example, which passed a
`custom_executor=` keyword no function accepted.
An -o key was documented by hand and validated nowhere: a typo such as
`-o data_dirr=/data` was silently ignored, so a driver ran with a default
nobody chose, and finding out what a device accepted meant reading its
builder. Each OptionSpec now carries a parser, so a device's schema is
what checks and converts its values — in one place, rather than each
builder hand-rolling int(), boolean-ish strings and path validation.

That schema is enough to generate everything else:

- `process --help` / `monitor --help` list every device and every -o key
  with its type, default, example and requiredness, built at import from
  the registry. A device whose install target is not fully installed is
  marked `unavailable: pip install "..."`, resolved from package metadata
  rather than by importing it — discovering what exists must never
  require having installed all of it.
- `qpi-driver devices [--operation OP]` is the same catalog as text.
- `qpi-driver catalog --json` is it as JSON, with a schema_version and a
  documented shape, for QPI-UI and the Go/TS SDKs to check against.

Breaking: an unknown -o key exits 1 naming the valid keys, and a builder
now receives options already parsed by DeviceSpec.parse_options rather
than raw strings. Both are in the CHANGELOG.

Also fixes the `use_sdk` process option in both READMEs, which has not
existed for some time.
Adding a device meant editing the SDK. Two routes now do not, both
landing in the same registry so nothing downstream can tell them apart:

- A distribution advertises devices under the `qpi_driver.devices`
  entry-point group. `pip install mylab-devices` and its devices are in
  --help, in `catalog --json` and known to --device. An entry point that
  will not import, resolves to something other than a DeviceSpec, or
  reuses a registered name is logged and skipped — a third party's
  mistake must not stop the CLI from starting.
- `--device mylab.devices:PrestoV2` imports the device instead, with no
  packaging at all. `module.attr` works too, per Pydantic's ImportString.
  For `process` it must be an Executor; for `monitor` a builder; either
  takes a DeviceSpec, which is how a custom device earns a declared
  option schema and a --help entry.

Both routes run code the operator chose, as `python -m` does, so the
obligations are about failing cleanly: a bad path exits 1 with one
sentence, and the filesystem path Python volunteers in an ImportError is
stripped out of it rather than going to the journal. The safe-path check
on data_dir still applies on the import route, because the synthesized
spec carries the QPU's declared options.

resolve_device lives in builtins/discovery.py rather than in
registry.resolve: adapting an imported object needs to know what an
Executor is, and qpu imports registry, so the registry would have ended
up depending on a device module.

Also adds examples/custom_device/ (an Executor, a DeviceSpec, the
entry-point stanza) and exports JobPayload/CircuitPayload from
qpi_driver, which writing an executor needs.
`qpi-driver process …` and `qpi-driver monitor …` become
`qpi-driver start --operation process|monitor …`.

The operation was the subcommand, which put a QPI-UI-owned concept into
the grammar: everything about launching a driver is identical whichever
operation it is, and a third party can add a device but only QPI-UI can
add an operation. So the operation is an argument, and the CLI has one
verb for running a driver.

--operation is required, reads QPI_OPERATION, and has no short form (-o
is --option; -O beside it is a hazard in a line usually written once into
a unit file). --device and --name now fall back to the operation's own
defaults rather than the subcommand's.

All three languages at once, because the dashboard's snippet generator
emits the launch command per language — changing the grammar one language
at a time would have meant branching that generator and unbranching it
later. Go and TypeScript also stop claiming a `mock` process device they
never had: `start --operation process` there now says the SDK ships no
process devices and where to find one, instead of reporting an unknown
device with an empty list of known ones.

Moved with it: the snippet templates (Spec.subcommand() deleted in favour
of the Operation/Device fields already in snippetData), all three
install-systemd.sh installers, e2e/lib.sh and e2e/verify.py, and the
READMEs. The installers' OPERATION environment variable is unchanged.

The five snippet assertions in drivers_test.go had to be rewritten, not
just kept: "start --operation process --device qblox" contains "process
--device qblox", so they all still passed while no longer discriminating.
Mutation-tested afterwards — restoring the old template fails exactly
those five.

make test-e2e-systemd needs a Docker daemon and was not run here.
The device table was an unexported map[string]deviceRunner inside
package main, so nothing downstream could add a device, and
qpi-driver/go/qpi-driver had no test file at all.

Now there is an importable `devices` package — Operation, OperationSpec,
OptionSpec, DeviceSpec, Options, Registry, Register/Devices/Resolve,
Catalog — mirroring the Python SDK. Builders return an unstarted driver
and qpidriver.Run runs it, so a device can be built and asserted on with
no server; the bluefors monitor describes itself as a spec beside its own
code, with its five options typed, replacing optOr/secondsOr.

Go has no runtime import by name, so extension is compile-time, and the
SDK now makes that pleasant instead of pretending otherwise: the cobra
wiring moved out of package main into an importable `cli` package, so a
binary of your own registers a device, calls cli.Execute, and gets the
same start/devices/catalog commands, generated help and option
validation. main.go is exactly those two steps. The README example was
compiled as a real downstream module and run, which is how the two
findings below turned up.

`catalog --json` emits the shape Phase 2 froze: bluefors_gen1 is
byte-identical to Python's, apart from `extra`, which names a Python
install target and is empty where a device is compiled in.

Two things fixed along the way:

- Python declared `api_key` with `default=""` where Go declares none, so
  the two catalogs disagreed about a device they share. Python now
  declares no default and reads it with a fallback.
- --help and `devices` advertised an operation's default device even in a
  build without it, and an omitted --device then failed over a device the
  operator never typed. Which devices a Go binary has is settled at
  compile time, so DefaultDevice is a fact about the operation, not the
  build.

make test-e2e-systemd needs a Docker daemon and was not run here;
make test-e2e-driver EXECUTOR=mock passes, Go driver included.
The TypeScript SDK now has what the other two do: a `qpi-driver/devices`
entry point with Operation/DeviceSpec/OptionSpec/Options,
registerDevice/devices/resolve, and a `qpi-driver/catalog` producing the
frozen `catalog --json` shape. A device is described as data, so
registering one earns it a --help line, a catalog entry, and -o values
checked and converted by its own schema instead of by the builder.

A device can also be named by import path: `--device ./dist/my.js#Export`.
The separator is `#`, not Python's `:`, because `:` is a URL scheme
separator in a JS module specifier — a name without `#` is still a
registered name, so a typo still gets the known-devices error.

Breaking, and the important one: **a CA fingerprint is now required and no
code path connects without verifying the pin.** verifyFingerprint used to
return quietly on an empty fingerprint, so an unpinned connection was
reachable by leaving an argument out — which a copy-pasted command hits by
accident. Three test call sites constructed drivers without one; that they
stopped compiling is the change working.

Also breaking: DeviceRunner is DeviceBuilder and takes parsed Options; an
unknown -o key is an error naming the valid ones rather than being
ignored; and --recv-timeout-ms is gone. That flag was parsed and thrown
away, and it names a polling interval this SDK does not have — the
transport is event-driven, so wiring it would have meant inventing a
behaviour to justify a flag. --ca-file, the other dead flag, is honoured
instead: the CA is written there after it verifies.

src/builtins/cli.ts has a test file for the first time; 81 tests across
the CLI, the registry and the catalog. One of them caught a bug in the
path scrubber written for the §10 obligation — it was eating `file://`
prefixes and relative paths, turning "Cannot find module '/a/b.cjs'
imported from /src/devices.ts" into "'filb.cjs' from 'srcdevices.ts'".

make test-e2e-systemd needs a Docker daemon and was not run here;
make test-e2e-driver EXECUTOR=mock passes, TypeScript monitor included.
qpi-ui keeps its own copy of which driver backends exist, to render setup
snippets. Nothing stopped it from disagreeing with the SDKs, and it
already did: `processSpec` declared no options at all, while every process
device reads five.

The check follows the direction of truth. Devices and their options belong
to the driver, so the server is tested against a checked-in
testdata/catalog.json generated by `qpi-driver catalog --json`. Operations
belong to the server — an operation is the set of event types it has
handlers for — so the SDKs' operation enum is tested against this
package's. Regenerating the fixture is then the deliberate act of
recording a catalog change, and `make sync-driver-catalog` is named in
every failure message.

Five directions, each mutation-tested rather than assumed: removing
`qblox` from catalog.go, adding a `rigetti` kind, misspelling `data_dir`,
dropping `is_dummy`, and inventing a `telemetry` operation in the fixture
each fail exactly the intended test.

drivers.Option grows Help/Required/Default to mirror the SDKs' option
schema, plus InSnippet to separate "the catalog knows this option" from
"a copy-pasted command should set it". No process option is pre-filled —
they all have working defaults, and setting data_dir in a snippet invites
an operator to change something they have no reason to. bluefors_gen1
pre-fills channels and base_url and keeps the other three out.

make test-go and make test-e2e-dashboard (110 specs) pass.
The root README still described qpi-driver as "a Python daemon that runs
alongside quantum hardware" — a QPU driver with extras, which is the
framing RFC 0003 §14 called out. It now describes the framework: an
operation is what a driver does and a contract the server implements, a
device is the backend implementing it and the open half, and a QPU is one
device of one operation.

- docs/driver/operations.md gains a runbook section on adding a
  third-party device on a production node, and on what the four -o
  validation errors look like — quoted from the CLI, not paraphrased, and
  checked byte-for-byte against it.
- The -o tables in the Python README are generated from `catalog --json`
  by `make sync-driver-catalog`, grouped by schema so the five process
  devices do not repeat the same five options five times.
- Each SDK README states what it actually ships, since only the Python
  SDK has a process device, plus its own extension story: entry points
  and import paths for Python, compile-time Register for Go, `#`-suffixed
  specifiers for TypeScript.
- One consolidated migration table in the CHANGELOG, linked from all
  three SDK READMEs, covering the CLI grammar, both broken APIs, and the
  behaviour that changed without a rename.
- RFC 0003 is Implemented; RFC 0001 §2 points at it rather than being
  rewritten.

Two fixes found by doing rather than reading. The custom-executor snippet
still did not run — Phase 1 fixed its bogus keyword, but it defines only
execute() and Executor has a second abstract method, so the class could
not be instantiated. Every Python snippet in a changed document is now
executed against the real SDK. And the architecture diagrams claimed a
"Result Sender Process"; results are pumped by a thread in the main
process, and the whole worker arrangement belongs to `process` — a
monitor has none of it.

mkdocs build --strict is clean, which needed absolute GitHub URLs in the
SDK READMEs (they are symlinked into docs/ and also published to PyPI and
npm, so relative links resolve nowhere) and the removal of one
pre-existing dead link to the gitignored .agents/ROADMAP.md.
Nothing in this repo measured coverage: the Python targets ran pytest with
no --cov, Go used -cover with no floor, and jest.config.js set no
threshold. All three now produce a number and enforce one, and each gate
was checked by making coverage drop and watching it fail rather than by
assuming it works.

- Python: 96% over the framework modules (achieved 99%, branch coverage
  on). qpu.py went 40% → 100% and sdk.py 55% → 100%, which meant writing
  the tests the e2e suite cannot reach: an executor that will not resolve,
  a job that raises, a malformed payload, a socket that times out, a
  handler that raises, a CA fingerprint that does not match.
- Go: 94% over ./devices/... and ./cli/... (achieved 97.8%). 94 and not 96
  because cli.Execute calls os.Exit and cannot be covered in-process — a
  floor of 96 there would have meant either a fake test or an untrue
  number.
- TypeScript: per-file thresholds on devices.ts, catalog.ts, cli.ts, ca.ts
  and index.ts (achieved 92–100%), with `global` set just under what the
  transport reaches, so it cannot regress either.

What is excluded is excluded for a stated reason, recorded in the roadmap:
the hardware executors need real instruments, the NNG transport needs a
live server (and is covered by make test-e2e-driver, which runs all three
languages' drivers against one), and process entry points cannot be called
by an in-process test. Abstract method bodies became `...` so the coverage
config can name them honestly instead of excluding a bare `pass`.

Writing the tests found a dead branch: qpu.job_worker checked whether
data_dir was already among the executor options, which it never could be —
data_dir is a parameter of that same function, so **executor_options can
never carry it.

Verified: make test-py-base, test-py-cli, test-go-driver, test-js-driver,
test-go, and test-e2e-driver EXECUTOR=mock.
The TypeScript SDK set no timeout on any network call it makes, where
Python passes requests(timeout=10) and Go uses http.Client{Timeout: 10 *
time.Second}. Node's fetch falls back to undici's defaults, which are
minutes rather than seconds, and tls.connect has none at all beyond the
OS TCP timeout, so a handshake that stalls after TCP connect waits
indefinitely. Behind a firewall that drops packets a driver therefore
hung inside run() instead of failing, and under systemd's
Restart=on-failure such a unit is never restarted, because it never
fails. Same class of problem as the CA-pinning gap closed in RFC 0003
Phase 6: a silent hang rather than an error.

All three sites now share the same hard-coded 10s the other two SDKs use.
No CLI flag: --recv-timeout-ms was just removed for naming a polling
interval this event-driven transport does not have, and a new flag would
re-litigate that. The only knobs are an internal DialOptions.timeoutMs
and the fetch helper's parameter, both defaulting to 10s, neither
reachable from index.ts or package.json exports — only the tests pass a
shorter one, so the suite does not wait out the real deadline.

- The two HTTP calls go through fetchWithDeadline, which reads the body
  inside the deadline as well, so a server that sends headers and then
  stalls is caught too. AbortSignal.timeout's "This operation was
  aborted" says neither what timed out nor against what, so the message
  is rebuilt: "the drivers/connect handshake timed out after 10000ms
  against http://…/api/op/drivers/connect". Detection matches on `name`
  down the cause chain rather than instanceof Error — the abort is
  constructed inside Node and fails instanceof across jest's realm
  boundary, which is a real failure this hit, not a hypothetical.
- dial() puts TCP connect, the TLS handshake and the SP header exchange
  under one deadline, and unifies its three settle paths so the timer is
  cleared once the dial ends. Nothing stays armed afterwards: idling is
  normal here, and a live timer would destroy a working socket. The
  message names the role, the address and the stage that stalled.
- connect() reads a string body now, so a non-JSON one (a proxy error
  page) gets its own line instead of a bare SyntaxError.

Tests cover the case nothing reached before: a peer that accepts the
connection and goes quiet, in both its forms — a plain TCP listener that
never speaks TLS, and a TLS server that never sends its SP header. Plus a
successful dial that idles past the deadline and still receives, run()
against a server that never answers at the real 10s (the point of the fix
is that it rejects at all), a stalled response body, and a refused
connection passed through untouched rather than dressed up as a timeout.

driver.ts and nng.ts are now per-file gated at what the suite reaches, 82
and 95 statements against 78 and 91 before, and 70 and 84 branches
against 40 and 77. Gating them moves them out of `global`, which covers
only events.ts and the Bluefors monitor now, so its floors rise too.

Verified: make lint-js-driver, make test-js-driver, and make
test-e2e-driver EXECUTOR=mock, which runs the built TypeScript monitor
against a live server and so proves the deadlines do not fire on the
happy path.
The SDK READMEs used "the pinned root CA" and "the catalog" as if a reader
already knew both. Each now says what it is where it first appears: pinning
is refusing any root certificate whose SHA-256 is not the fingerprint the
operator was handed out of band, and the catalog is the list of operations,
devices and -o options a build knows about, sourced only from the DeviceSpecs.

Along the way, the things that were plainly wrong:

- The Go README's "Running a built-in as a systemd service" heading had
  nothing under it, and the JS README's manual procedure was inside an HTML
  comment while its installer example asked for --device qblox, a process
  device neither SDK ships. Both now document the monitor devices they have.
- The "Upgrading?" note linked CHANGELOG.md#migration, an anchor that slides
  to whichever release most recently had a migration section. It now names
  the release boundary and links the change log itself.
- The Python README introduced itself as "The Go SDK".
- The root README said pip install ./qpi-driver[cli] — a directory with no
  pyproject.toml — and pointed -o quantify_device_config at a
  quantify.device.example.json that has never existed; the file is YAML.
- docs/driver/operations.md closed with make test-e2e-driver-framework, a
  target that existed only in the Makefile's .PHONY list.
…ng their own runtime

The Go and TypeScript install-systemd.sh were copy-pasted from the Python one
and never re-read: both prompted with the Python device list and defaulted to
OPERATION=process, DEVICE=mock. Pressing return through the prompts wrote and
enabled a unit whose ExecStart can never succeed — neither SDK has a process
device — and Restart=on-failure then crash-looped it. Both now prompt with and
default to monitor/bluefors_gen1, the one device each actually ships.

e2e/test_systemd_install.sh passes monitor/bluefors_gen1 explicitly, which is
why nothing caught it.

The JS installer also stopped halfway to tell the operator to install Node and
run it again, which is not what a one-command installer is for. It now installs
Node with nvm as the target user, and — because qpi-driver is a script with a
'#!/usr/bin/env node' shebang and systemd's default PATH has no nvm install on
it — writes a PATH into the unit that contains that node. Without it the unit
started only where Node happened to be system-wide.

While here: the Python installer asked for DRIVER_OPTIONS only for a monitor,
answering its own FIXME the wrong way round. A process device reads -o keys
too; only the data dir and the quantify paths are the installer's to fill in.

The QPI_DATA_DIR the Go and JS installers create is for the downloaded root
CA, not for a 'data_dir' option neither SDK has a device to read. Said so.
…ere they belong

Neither file was where its purpose says it should be.

tests/half_imported_device.py is not a test module. It exists to raise a real
ImportError so discovery._without_paths can be checked against Python's own
message rather than a hand-written one (RFC 0003 §10) — an input to a test, so
it goes in tests/fixtures/ alongside the quantify configs. That directory held
only data until now, hence the new __init__.py: a fixture that is a module has
to be importable by name.

qpi-driver/render_catalog_table.py is documentation tooling for this repository,
run by make sync-driver-catalog to regenerate the <!-- catalog:begin --> table
in the Python README. At the root of qpi-driver/ it read as part of a published
SDK. It moves to a new top-level scripts/, and its docstring now says what it is
for and how it is run instead of leaving that to be inferred.
Registering kind=mock with language=go succeeded and handed the operator setup
commands for '--device mock' against a Go binary that has no process device at
all. drivers.Snippets rendered any kind x language pairing, and nothing in
internal/drivers knew that only the Python SDK ships a process device — so the
pasted command exited 1 saying 'unknown process device', with nothing to say
the registration itself was the mistake.

Spec.Languages records which SDKs ship each device. handleDriverCreate rejects
a pairing that does not exist, naming what that SDK does ship; Snippets falls
back to the install command alone rather than rendering a broken one; and the
dashboard disables the languages a kind is unavailable in instead of offering a
choice the server will refuse. Custom stays registerable everywhere — it is
code the operator writes against the SDK, so the SDK not having the device is
the whole point.

Spec.Languages is qpi-ui's copy of something only the SDKs know, which is how
this drifted in the first place, so it is checked: make sync-driver-catalog now
writes one fixture per SDK and TestSpecLanguagesMatchTheSdks compares the two
directions. catalog.json becomes catalog.python.json.

Generating the TypeScript fixture turned up that its CLI had no 'catalog
--json' — JSON is the default there and only --text existed, while the Go and
Python CLIs accept the flag and all three READMEs spell the command with it.
Added, as the no-op the other two have.

TestSnippetsOfficialGoUsesGoInstall asserted the bug (Snippets(Qblox, Go)
rendering '--device qblox'), so it is now the test that the pairing renders
nothing runnable. TestSnippetsBlueforsPerLanguage already covered what it was
really for: that an official Go or TypeScript driver resolves per-language run
snippets.
The token is a driver's whole identity: it is what handleDriverConnect looks
the record up by and, transitively, what says which QPU the driver belongs to
(RFC 0001 §8). 'name' is a cosmetic, non-unique display label an admin types
into the dashboard — nothing looks a driver up by it, and no unique index
exists. Yet the connect handler wrote it straight from the request body on
every connect, so a --name in a unit file nobody re-reads silently overwrote
what the admin chose, every restart.

So the direction reverses. drivers/connect returns 'name'; the driver reads it
after connecting (driver.name in Python and TypeScript, DriverName() in Go) and
uses it where it always did — the outbound envelope's 'driver' field and its own
logs. Removed, all three SDKs: --name/-n, QPI_DRIVER_NAME, the Name/name config
field, and default_name/DefaultName/defaultName on the operation specs, an
operation having no name to default. Name is gone from DriverConnectRequest; an
older driver that still sends one is not rejected, the field is just not read.

Two things fell out of it:

- qpu._sanitize_name existed because QpuDriver overrode the executor's name
  with its own, and a display label is not an identifier — so a cryostat called
  'lab-1' produced datasets whose 'backend' attribute said 'lab_1'. The
  executor keeps its own name now, which is what that attribute is for, and the
  sanitiser has nothing left to sanitise. The executor tests already asserted
  the new behaviour; only production disagreed with them.
- The installers' QPU_NAME becomes SERVICE_NAME. It never named a QPU or a
  driver: it names the unit file, its SyslogIdentifier and its data directory.

Host and Version stay accepted on DriverConnectRequest and are flagged as dead
on the wire — no SDK has ever sent either.

Also documented, in RFC 0001 §6, the asymmetry a reader of the envelope needs:
the server parses an inbound 'driver' field and then ignores it, keying
everything off the socket the event arrived on, and fills the same field with
the driver's ID on the way out. A driver does not get to claim who it is there
either.
The Python README offered three co-equal ways to run a backend the SDK does not
ship — pass an executor, name one by import path, register a device — and left
the reader to work out which was theirs. The Go and TypeScript READMEs each tell
one story ("Adding a device of your own"), and this one now does too: the device
first, its entry point second, the import path as the try-it-out route, and
QpuDriver(executor=…) as a note explaining when you would reach past all of it.
No mechanism changed.

Writing the worked example out properly turned up that it does not work.
qpi_driver.builtins called load_installed_devices() at its own import time, and
qpi_driver/__init__.py imports it on the way to binding Executor, JobPayload and
OptionSpec — so a device written the documented way, `from qpi_driver import
Executor`, was loaded against a half-initialised package and skipped:

  WARNING skipping device entry point 'quantum_x': ImportError: cannot import
  name 'Executor' from partially initialized module 'qpi_driver'

That is the SDK's whole story for shipping a device, and it worked for nobody.
Discovery moves to the end of qpi_driver/__init__.py, which is the first moment
the package a device imports is complete; importing any qpi_driver submodule
runs that file first, so no route into the registry skips it. The existing tests
missed it by mocking importlib.metadata.entry_points — installing the example
for real is what found it, and is what make test-docs will do.

The example itself: ThermometerExecutor becomes QuantumXExecutor and
probe_count becomes qubit_count, since it is a process device (a QPU) and a
thermometer is a monitor. Its execute() returned a dict where Executor declares
xr.Dataset — the contract process_result() and the driver's dataset writing both
depend on — so it returns a dataset. And its pyproject.toml asks for
qpi-driver[cli] rather than the bare SDK (without the extra there is no typer
and no qpi-driver script, so the README's next command could not run), resolved
from the sibling source tree so a test installs this checkout.
Nothing checked the documentation before a merge: docs.yml only ever ran on a
v* tag. So every claim a document makes about this repository was checked by
whoever last read it, and documentation drifts in one direction only — nobody
re-runs the command they copied a block from. This release alone shipped a make
target that existed only in .PHONY, a `pip install` path with no pyproject.toml
at it, a config file named with the wrong extension, a TypeScript block that
did not type-check, an executor example that could not be instantiated, and an
entry-point device that no installed distribution could ever have registered.

`make test-docs`, in a new CI job on every pull request, in five steps so a
failure says which kind of claim broke:

- **static** (scripts/check_docs.py) — every `make` target, repository path,
  markdown link and `qpi-driver` flag a document names. The flags are read from
  each SDK's own --help, and each document is checked against the SDK it belongs
  to, so a removed flag cannot linger in a README.
- **snippets** — the Python blocks executed against the real SDK with `run()`
  stubbed; the Go and TypeScript blocks extracted and compiled against this
  checkout (scripts/check_doc_snippets.sh); and the four error transcripts in
  docs/driver/operations.md compared with what the CLI actually prints.
- **example** — examples/custom_device installed against the local SDK and then
  asked for via `catalog --json`. The entry-point route had only ever been
  tested with importlib.metadata.entry_points mocked.
- **catalog** — sync-driver-catalog re-run and diffed against a snapshot rather
  than against git, which cannot tell a stale generated table from any other
  uncommitted work. The snapshot is restored either way, so a failing run
  reports the drift instead of quietly fixing it.
- **site** — mkdocs build --strict, which is what catches a dead nav entry or
  internal link.

A block that is meant to fail opts out with `<!-- docs-check: skip -->`; a
compiled block opts in with `<!-- docs-check: compile=<name> -->`, because a
three-line struct literal is illustration, not a program. CHANGELOG.md is exempt
altogether: it records commands that no longer work on purpose.

What it found, beyond the entry-point bug already fixed:

- The TypeScript registerDevice example did not type-check — `build`'s two
  parameters were implicitly `any` for want of a `: DeviceSpec` annotation, and
  the driver it constructed passed a property its options type did not have.
- `qpi-driver --help` and `--version` exited 1 on the TypeScript SDK, where the
  Go and Python CLIs exit 0. exitOverride() turns help into a rejected promise
  and the bin reported it as a failure, so a `set -e` script that asks a CLI
  what it can do died on the answer.
- The Go README's Config literals still carried a Name field, and the Bluefors
  and custom-device blocks would not have compiled with it.
- The Python README's "Universal options" reference omitted --recv-timeout-ms.

Also here, since it is the same "an installer should bring its own runtime"
fix as the JS one and the FIXME asking for it is in the file: the Go
install-systemd.sh downloads and unpacks the Go toolchain into /usr/local/go
when the node has none, rather than exiting with a link. GO_VERSION overrides
which; an architecture with no prebuilt tarball still exits with the link.

`make lint-py` now covers scripts/ as well as the SDK — it holds the two Python
tools this repository runs on itself.
@Tinitto
Tinitto self-requested a review July 28, 2026 18:21

@Tinitto Tinitto left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good save for the failing tests.

`make lint-dashboard` was failing on two accumulated eslint errors, and
no CI job ran it, so nothing objected as they piled up.

- ThemeEditorModal: drop the unused `catch` binding.
- ThemeContext: split the active-theme fetch into `loadTheme`, which
  touches no state before the request, so the mount effect no longer
  sets state synchronously. `refreshTheme` keeps flipping `isLoading`
  for its callers. Note that `await` is not a boundary as far as
  react-hooks/set-state-in-effect is concerned; promise callbacks are.
- ThemeContext: add the missing `theme.updated` dependency to the
  CSS/JS effect. The effect already cache-busts on `updated`, but only
  re-ran when `id` changed, so editing a theme in place left the old
  custom CSS and JS applied.

Add a lint-dashboard step to test-dashboard-cypress, which already sets
up Node against the dashboard's package-lock.
@ch-ahindura
ch-ahindura force-pushed the hotfix/make-qpi-driver-py-more-extensible branch from a2c046e to 04d0328 Compare July 29, 2026 08:52
RFC 0003 §5 had a device describe itself: an option schema with types,
defaults, examples and required flags, an install target, a summary. That
description was then rendered as `--help`, as `qpi-driver devices` and as
`catalog --json`, and the last of those existed so QPI-UI could be checked
against it — because QPI-UI holds the same description, for the dashboard's
registration form.

Two descriptions of one device, reconciled by a script. Delete ours.

`DeviceSpec` is now a name, an operation and a builder. `OptionSpec`,
`OperationSpec` and `builtins/catalog.py` are gone, and with them the
`catalog` subcommand and the generated `--help` epilog. `qpi-driver devices`
lists names — which is the one question the driver is the authority on, since
it depends on the extras installed and the entry points present.

`-o` values reach a builder as the strings they were typed as, in an `Options`
whose accessors each take the fallback:

    data_dir = options.get_dir("data_dir", "./bin/data")
    channels = parse_channels(options.require("channels", "mapper.bf.tmc:K"))

The default now lives in the code that acts on it rather than as a string in a
schema something else parses. And `Options` remembers the reads, so an `-o` key
nothing looked at is reported after the build — which keeps a typo an error
without a declared list to check it against. That check earns its place: every
built-in executor's constructor takes `**kwargs`, so an unread key would
otherwise vanish silently, which is the failure the schema was there to stop.

`--device` is now required. Its default came from `OperationSpec`, and QPI-UI
generates the command that launches a driver — it always names a device, so a
value the SDK filled in could only be a guess.
The Go half of the same reduction. `devices/catalog.go` is gone, along with
`OptionSpec`, `OperationSpec`, the schema fields of `DeviceSpec`, the standalone
parsers (`AsInt`, `AsBool`, `AsFloat`, `AsString`) and the `catalog` subcommand.

`Options` now holds the raw `-o` strings. Its accessors each take the fallback
and accumulate any conversion failure, so a builder stays a flat run of
statements and checks once at the end:

    options := Options{
        BaseURL:      opts.String("base_url", DefaultBaseURL),
        PollInterval: opts.Seconds("poll_interval", DefaultPollInterval),
    }
    if err := opts.Err(); err != nil {
        return nil, err
    }

`Unread()` reports the keys nothing read, which is what the CLI turns into
`unknown option "base_urll" for monitor device "bluefors_gen1"`.

`ParseChannels` stays in the bluefors package rather than moving to `devices`:
a channel map is that device's idea, not a generic option type.

`--device` is required, and when the operation has no devices at all the
message is still `Resolve`'s — "this build ships no process devices" and where
to find one — rather than a request to pick from an empty list.
The TypeScript half. `src/catalog.ts` and the `qpi-driver/catalog` entry point
are gone, along with `OptionSpec`, `OperationSpec`, `parseOptions`, the
standalone parsers and the `catalog` subcommand.

`Options` holds the raw `-o` strings; `str`, `int`, `num` and `bool` each take
the fallback and throw naming the option, `require` throws for the one kind of
option nothing can make up a value for, and `unread()` reports the keys nothing
read.

`lookupOperation` becomes `knownOperation`, since there is no longer an
operation spec to look up — only a closed pair of names.
Nothing on the server ever read `catalog --json` at runtime. The only consumer
was a test: three checked-in `testdata/catalog.*.json` fixtures, a drift test
over them, and `make sync-driver-catalog` to regenerate the fixtures plus a
table in the Python README. All of it deleted, along with
`scripts/render_catalog_table.py` and the `test-docs-catalog` step.

What is left is `qpi-ui/internal/drivers`, unchanged in substance and now
described for what it is: the catalog. It is where an operator picks a device
and fills in its options, and where the command that launches one is generated.
`Spec.Languages` stays — which SDK ships which device is a fact the server needs
in order to refuse a pairing no SDK can honour — but it is now maintained here
rather than checked against fixtures. `qpi-driver devices` on the node is what
confirms it.

`test-docs-example` asks `qpi-driver devices` whether the installed example
arrived, where it used to grep `catalog --json`. Same claim, same bug caught.
RFC 0003 §9 said the catalog was split: operations to the server, devices and
their options to the driver, reconciled by a fixture-based drift check "until a
driver can advertise its catalog on the wire". §9 now records the reversal and
why — the split described one device twice and held the copies together with a
`make` target somebody had to remember to run, and the driver is not another
client of QPI-UI, so the answer was never a request in the other direction.

§5 is rewritten around what a device now is; §4, §6, §8 and the decision list
follow. The three SDK READMEs, the root README and the operations runbook say
where a device's options are documented — the dashboard's registration form —
and quote the errors the CLI actually prints now.
Each README had one example, and a different one: Python wrapped an executor,
Go and TypeScript wrote a driver from nothing. So neither question a reader
actually arrives with — "can I reuse the monitor you ship?" and "how do I write
one from scratch?" — was answered in more than one place.

Both are now in all three, with the same names throughout: `bluefors_gen2`
reuses the shipped monitor, `thermometer` is written from scratch. Python keeps
its executor example as a third case, because a `process` device genuinely is an
executor and no monitor example can show that — and because what it reuses is
substantial: the worker subprocess, the result pump, the JobResult event.

Making the reuse example true needed a change to the monitor itself. The part
specific to Gen. 1 is the HTTP call that reads one channel; everything else —
the timer, one bad channel not losing the tick, the CryostatReading event — is
the same whatever is being read. So the driver now takes that call as an
argument rather than owning it outright.

By composition, not inheritance. I first made it an overridable method in Python
and TypeScript and a function field in Go, which had the three SDKs disagreeing
on mechanism and coupled a subclass to the base class's internals for no reason.
`QpuDriver(executor=…)` was already the right shape and had been all along: the
reusable driver holds the replaceable part as a value. All three now do that.

Also a plain-language pass. `operation` and `device` are defined where they
first appear rather than assumed; `run()` says what it actually does; "universal
options" — a category name with nothing in it — says what the flags have in
common instead. Two FIXMEs on the TypeScript README are answered in the text:
what an import path resolving to a bare builder means for a device's name and
operation, and *when* an unread `-o` key is reported. It is after the device is
constructed, which reads like "too late" but is not: constructing one touches no
network, so it is still before anything connects.
A sweep for what the catalog removal orphaned. Four things, and one of them
predates this branch's design rather than this branch's deletions:

- `devices.Registry.Has` (Go) — its only caller was the default-device
  resolution in the CLI, which went when default devices did.
- `Options.Has` (Go), `Options.__contains__` (Python), `Options.has`
  (TypeScript, now private) — "was this key given?". That was worth asking when
  `Options` held values already parsed against a schema, because a caller had no
  other way to tell an absent option from one set to a zero value. Every
  accessor now takes its own fallback, so the question does not come up: no
  device asked it, in any of the three SDKs. TypeScript keeps the lookup as an
  internal detail of `ms()`, which returns undefined for an absent key so a
  driver's own default still applies.

Everything else checked out. `Registry.Kinds` on the server looked orphaned by
the drift test's removal but is what `TestCatalogEventsAreKnownTypes` iterates;
`Options.Bool` and `Options.Float` have no caller in the Go tree but are how a Go
device would read a boolean or decimal option, which the Python QPU does today —
a gap, not dead weight. The `compat` stubs, the Makefile variables and every
`.PHONY` entry are live.
Typer forces Rich's colour on whenever GITHUB_ACTIONS is set, so the CLI
tests only failed on CI. Rich renders a flag as several coloured runs, which
makes `--device` arrive as `-` then `-device` with escape codes between it,
and puts codes ahead of the `Usage:` a line was expected to start with; five
assertions found neither.

Run the CliRunner with TERM=dumb, which turns the colour system off entirely
and leaves the text behind. NO_COLOR is not enough: it drops the colours but
keeps bold and dim, so the flags stay split. This is what scripts/check_docs.py
already does when it reads --help for the same reason.
…bash

The documented non-interactive install pipes the script into `bash`, which puts
the script itself on stdin. Every prompt the environment had not already answered
therefore read EOF, returned non-zero, and ended a `set -e` script on the spot —
no unit file, no service, and no output saying why. The README's own example
reaches it: it sets every variable except DRIVER_OPTIONS.

Prompting is now guarded by `[ -t 0 ]`, so it happens only where it can be
answered. Piped, a value with a default takes its default; one without says which
variable to set rather than exiting in silence. `add_opt` returns 0 on an empty
field too, which was the same wordless exit for a DRIVER_OPTIONS with a stray
semicolon.

test-e2e-systemd caught this because 4ac99c0 moved the Python installer's
DRIVER_OPTIONS prompt out of its `OPERATION = monitor` branch, and that test
installs a process device. The monitor path could always reach it.
The Cypress binary is not part of node_modules — it lives in a per-user cache,
and the cypress package's postinstall script is the only thing that fetches it.
build-dashboard, lint-dashboard and lint-format-dashboard all install with
CYPRESS_INSTALL_BINARY=0, since none of them run tests and the download is
~100MB. Once the package is in node_modules, though, the `npm install` at the top
of this script has nothing to reinstall, so that postinstall never runs: a job
that lints or builds the dashboard first arrives here with the package and no
binary, and `npx cypress run` refuses to start.

'npm ci' instead of 'npm install --no-package-lock' ensures cypress is reinstalled
@Tinitto
Tinitto merged commit a38390e into sopherapps:main Jul 29, 2026
37 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants