Hotfix/make qpi driver py more extensible - #34
Merged
Tinitto merged 31 commits intoJul 29, 2026
Merged
Conversation
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
self-requested a review
July 28, 2026 18:21
Tinitto
reviewed
Jul 28, 2026
Tinitto
left a comment
Collaborator
There was a problem hiding this comment.
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
force-pushed
the
hotfix/make-qpi-driver-py-more-extensible
branch
from
July 29, 2026 08:52
a2c046e to
04d0328
Compare
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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: Addedmake test-docs, and a CI job that runs it on every pull request —docs.ymlonly ever ran on av*tag, so nothing checked the documentation before a merge. Five steps, so a failure says which kind of claim broke: static (everymaketarget, repository path, markdown link andqpi-driverflag 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 indocs/driver/operations.mdcompared with what the CLI prints), example (examples/custom_deviceinstalled against the local SDK, then asked for viacatalog --json), catalog (sync-driver-catalogre-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.mdis exempt: it records commands that no longer work on purpose.qpi-driver/py: Added the device catalog (RFC 0003 §5) —Operation,OperationSpec,DeviceSpec,OptionSpecandDeviceBuilderinqpi_driver.builtins.registry, withregister(),operations(),devices()andresolve(). A device now describes itself as data next to its own code, so--deviceaccepts it without any change to the CLI.qpi-driver/py: Addedqpi_driver.builtins.qpu.device_spec()for describing aprocessdevice that runs a given executor, including one the SDK does not ship.qpi-driver/py:process --helpandmonitor --helpnow list every device the operation can run and every-okey 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 asqblox — unavailable: pip install "qpi-driver[cli,qblox]", read from package metadata without importing anything, so--helpworks when a device does not.qpi-driver/py: Addedqpi-driver devices [--operation OP], the same catalog as readable text for every operation at once.qpi-driver/py: Addedqpi-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 aschema_version, and is documented in full onqpi_driver.builtins.catalog.catalog_dict(RFC 0003 §9).qpi-driver/py: AddedDeviceSpec.parse_options(),OptionSpec.parse/OptionSpec.type_nameandqpi_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-rollingint(...), boolean-ish string parsing and path validation.qpi-driver/py: A distribution can now advertise devices through theqpi_driver.devicesentry-point group (RFC 0003 §6).pip install mylab-devicesand its devices appear in--help, incatalog --jsonand 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 aDeviceSpecis logged and skipped, never fatal.qpi-driver/py: A device can now be named by import path —--device mylab.devices:PrestoV2, ormylab.devices.PrestoV2, following Pydantic'sImportStringconvention. Forprocessit must import to anExecutorsubclass or instance, formonitorto a device builder; either accepts aDeviceSpec, which is how a custom device gets a declared option schema and a--helpentry. Having no declared schema, an import-path device's undeclared-ooptions are passed through as strings — its declared ones,data_dirincluded, 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: Addedqpu.device_spec(options=...)for a custom process device that wants to declare its executor's own-ooptions, andDeviceSpec.accepts_any_optionfor one that cannot.qpi-driver/py: Addedqpi-driver/py/examples/custom_device/— anExecutor, aDeviceSpec, and thepyproject.tomlentry-point stanza, with a README covering all three ways to run it.qpi-driver/py:JobPayloadandCircuitPayloadare now exported fromqpi_driveritself, which is what writing an executor needs.qpi-driver/go: Added the importabledevicespackage —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 unexportedmap[string]deviceRunnerinpackage main, so nothing downstream could extend it.qpi-driver/go: Added the importableclipackage withNewRootCmdandExecute. Go has no runtime import by name, so extension is compile-time: a build of your own callsdevices.Registerand thencli.Execute, and gets the samestart/devices/catalogcommands, the same generated help and the same option validation as the shipped binary.qpi-driver/go/qpi-driver/main.gois 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: Addedqpi-driver devices [--operation OP]andqpi-driver catalog --json, emitting the same document as the Python SDK — byte-identical for the sharedbluefors_gen1device, apart fromextra, which names a Python install target and is empty where a device is compiled in.qpi-driver/go: Thebluefors_gen1monitor now describes itself as aDeviceSpecbeside its own code, with its five-ooptions typed.qpi-driver/go/cliandqpi-driver/go/deviceshave tests whereqpi-driver/go/qpi-driverhad none at all.qpi-driver/js: Added theqpi-driver/devicesandqpi-driver/catalogentry 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, acatalog --jsonentry, and-ovalues 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 aDeviceSpecor 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: Addedqpi-driver devices [--operation OP]andqpi-driver catalog --json, in the same frozen shape as the Python and Go SDKs.qpi-driver/js:src/builtins/cli.tshas a test file, andsrc/devices.ts/src/catalog.tsare covered too — 81 tests where the CLI had none.qpi-driver/js:--ca-fileis 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-clienforces 96% over the Python framework modules (achieved 99%),make test-go-driverenforces 94% over the Godevicesandclipackages (achieved 97.8%), andnpm testenforces 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 bymake test-e2e-driver—.agents/ROADMAP-0003-driver-extensibility.mdrecords 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-ovalidation errors looks like, quoted from the CLI rather than paraphrased.docs: The-ooption tables inqpi-driver/py/README.mdare now generated fromcatalog --jsonbymake sync-driver-catalog, between<!-- catalog:begin -->markers, so they cannot fall behind the code.docs: Addedmake 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/driversnow tests itself against a checked-intestdata/catalog.jsongenerated byqpi-driver catalog --json, and fails when the two disagree about which devices exist, which install target ships them, or which-okeys each reads — and the other way, when the SDKs know an operation this server has no handler for. Regenerate the fixture withmake sync-driver-catalog, which every failure message names; doing so is the deliberate act of recording a catalog change.qpi-ui:drivers.Optionnow carriesHelp,Required,DefaultandInSnippetalongsideExample, mirroring the SDKs' option schema. Theprocessdevices' five-okeys are filled in, whereprocessSpecpreviously declared none — they have working defaults, so none of them is pre-filled in a rendered snippet, but the catalog now says they exist.InSnippetis what decides that:bluefors_gen1pre-fillschannelsandbase_urland keepsapi_key,poll_intervalandtimeoutout of a copy-pasted command.Changed
qpi-driver: [BREAKING] Theprocessandmonitorsubcommands are replaced by onestartverb 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.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 …--operationis required, has no short form (-ois--option, and-Obeside it would be a hazard), and readsQPI_OPERATION.--deviceand--name, when omitted, now come from the operation's own defaults. The dashboard's setup snippets, all threeinstall-systemd.shinstallers and the e2e harness render the new grammar; theOPERATIONenvironment variable the installers take is unchanged.qpi-driver/go,qpi-driver/js: [BREAKING]start --operation processnow says that the SDK ships no process devices and where to find one, instead ofunknown 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 whencaFingerprintwas 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.caFingerprintis now required, andverifyFingerprintthrows on an empty one rather than returning quietly.qpi-driver/js: [BREAKING]DeviceRunneris nowDeviceBuilder, and a builder receives(config, options)whereoptionsis anOptionscarrying values already checked and converted against the device's own schema, rather than aRecord<string, string>. An-okey 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-msis 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-okey 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=/dataused to mean a driver running with a default nobody chose; it now exits 1. Anything that passed an unrecognised-okey deliberately, expecting it to reach the executor, must declare it in aDeviceSpec(seeqpu.device_spec()).qpi-driver/py: [BREAKING] A device builder is now handed options that have already been checked and coerced against its ownDeviceSpec—build_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.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_DRIVERSbuiltins.devices(operation)/builtins.resolve(operation, device)builtins.DriverRunnerbuiltins.DeviceBuilder(returns a driver rather than blocking)resolve_executor(executor, custom_executors, ...)resolve_executor(executor, ...)— pass the class or instance itselfQpuDriver(custom_executors={"name": Cls})QpuDriver(executor=Cls)QpuDriver.OPERATION,BlueforsGen1Driver.OPERATIONDeviceSpec.operationqpu.execute_job()qpu.job_worker()qpi-driver/py: The driver-authoring surface is unchanged —QpiDriver,handle_event(),emit(),every(),Event,EventTypeandExecutorkeep 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 insiderun()instead of failing — Node'sfetchfalls back to undici's defaults, which are minutes rather than seconds, andtls.connecthas none at all beyond the OS TCP timeout, so a TLS handshake that stalls after TCP connect waited indefinitely. Under systemd'sRestart=on-failuresuch a unit is never restarted, because it never fails. Thedrivers/connecthandshake, 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-mswas removed rather than repurposed.qpi-driver/go:--helpanddevicesno longer advertise an operation's default device in a build that does not have it, and omitting--devicein 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, theName/nameconfig field in all three SDKs anddefault_name/DefaultName/defaultNameon the operation specs are all removed, andhandleDriverConnectno 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 — whilenameis 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--nameever achieved was to overwrite what the admin chose, on every connect, from a unit file nobody re-reads.POST /api/op/drivers/connectnow returnsname, so a driver learns its label instead of asserting one: readdriver.name(Python, TypeScript) orDriverName()(Go) after connecting.Nameis gone fromDriverConnectRequest; an older driver that still sends one is not rejected, the field is simply not read.HostandVersionstay accepted and are flagged as dead on the wire — no SDK has ever sent either.qpi-driver/py: [BREAKING] Aprocessdevice's datasets record the executor's own name as theirbackendattribute (mock,qblox, …) rather than the driver's display label.QpuDriverused to override the executor's name with its own, which is the only reason a_sanitize_nameexisted: a driver calledlab-1produced datasets claiming a backend oflab_1._sanitize_nameis gone with it.qpi-driver: [BREAKING]install-systemd.shreadsSERVICE_NAMEwhere it readQPU_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, itsSyslogIdentifierand its data directory. Existing invocations must be updated; there is no alias.qpi-driver/js:qpi-driver --helpand--versionexit 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 aset -escript that asks a CLI what it can do before using it died on the answer.qpi-driver/go:install-systemd.shdownloads and unpacks the Go toolchain into/usr/local/gowhen the node has none, instead of exiting with instructions to install it and run the script again.GO_VERSIONoverrides which; an architecture with no prebuilt tarball still exits with the link, andQPI_SKIP_INSTALL=1still skips the whole step.qpi-driver/py: A device installed through theqpi_driver.devicesentry point is no longer skipped.qpi_driver.builtinsranload_installed_devices()at its own import time, and it is imported byqpi_driver/__init__.pyon the way to bindingExecutor,JobPayloadandOptionSpec— 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 ofqpi_driver/__init__.py, the first moment the package a device imports is complete; importing anyqpi_driversubmodule runs that file first, so no route into the registry skips it. It went unnoticed because the only tests of this route mockedimportlib.metadata.entry_points;make test-docsnow installsexamples/custom_deviceagainst the local SDK and asserts the device is incatalog --json.qpi-driver/py: Removed a dead branch inqpu.job_worker, which tested whetherdata_dirwas 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.shno 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 toOPERATION=process,DEVICE=mock. Pressing return through the prompts wrote and enabled a unit whoseExecStartcan never succeed — "this build ships no process devices" — and, withRestart=on-failure, crash-looped it. Both now prompt with and default tomonitor/bluefors_gen1, the one device each actually has. AnOPERATION=processpassed 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.shinstalls 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 aPATHinto the unit file that contains thatnode:qpi-driveris a script with a#!/usr/bin/env nodeshebang, and systemd's defaultPATHhas no nvm install on it, so a unit written without this started only for operators whose Node happened to be system-wide.NVM_VERSIONandNODE_VERSIONoverride what it installs, andQPI_SKIP_INSTALL=1still skips the whole step.qpi-driver/py:install-systemd.shprompts forDRIVER_OPTIONSwhatever the operation. It asked only for amonitor, on the reasoning that aprocessdevice's options are all defaulted — but a process device reads-okeys 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 rootREADME.mddescribedqpi-driveras 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 aprocessdevice.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 theprocessoperation — amonitorhas none of it.docs: Fixed the custom-executor example inqpi-driver/py/README.mda second time: it defined onlyexecute(), soExecutor's other abstract method made it impossible to instantiate. Every Python snippet in the driver documentation is now executed against the real SDK bymake test-docs, so a third occasion is a failing build.docs: RFC 0003 isImplemented, 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 adevice_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_devicerenamed itsThermometerExecutortoQuantumXExecutorandprobe_counttoqubit_count. It is aprocessdevice — a QPU — and a thermometer is a monitor, so the example named itself after the wrong operation. Itsexecute()also returned adictwhereExecutordeclaresxr.Dataset, which is the contractprocess_result()and the driver's own dataset writing depend on; it returns a dataset now.pyproject.tomldepends onqpi-driver[cli]rather than the bare SDK — without the extra there is notyperand noqpi-driverscript, 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-ooptions a build knows about, with theDeviceSpecs as its only source.docs:qpi-driver/go/README.mdhad an empty "Running a built-in as a systemd service" heading, andqpi-driver/js/README.mdhad its manual instructions commented out and its installer example asking for--operation monitor --device qblox— aprocessdevice that neither SDK ships. Both now document themonitordevices they actually have, by installer and by hand.docs: The "Upgrading?" note in each SDK README promised a migration table atCHANGELOG.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.mdintroduced itself as "The Go SDK".docs: The rootREADME.mdtold the reader topip install ./qpi-driver[cli], a directory with nopyproject.tomlin it, and pointed-o quantify_device_configat aquantify.device.example.jsonthat has never existed — the file is YAML, and both example configs live underqpi-driver/py/.docs:docs/driver/operations.mdclosed withmake test-e2e-driver-framework, a target that existed only in the Makefile's.PHONYlist. Removed the phantom from.PHONYand 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/createaccepted any kind×language pairing, anddrivers.Snippetsrendered setup commands for all of them — sokind=mock, language=goreturned a--device mockagainst 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 acustomdriver, 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, andmake sync-driver-catalogregenerates 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.jsonis nowcatalog.python.json.qpi-driver/js:catalog --jsonno 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.pymoved fromqpi-driver/to a new top-levelscripts/. It is documentation tooling for this repository —make sync-driver-catalogruns it — and sitting at the root ofqpi-driver/it read as part of a published SDK.qpi-driver/py/tests/half_imported_device.pymoved totests/fixtures/for the same reason: it is not a test module but an input to one, a module that raisesImportErroron purpose.qpi-driver/py: Fixed the custom-executor example inqpi-driver/py/README.md, which passed acustom_executor=keyword that no function accepted and would have failed withUnknown executor name 'custom'.