Skip to content

Hotfix/make qpi driver py more extensible - #33

Closed
Tinitto wants to merge 19 commits into
sopherapps:mainfrom
ch-ahindura:hotfix/make-qpi-driver-py-more-extensible
Closed

Hotfix/make qpi driver py more extensible#33
Tinitto wants to merge 19 commits into
sopherapps:mainfrom
ch-ahindura:hotfix/make-qpi-driver-py-more-extensible

Conversation

@Tinitto

@Tinitto Tinitto commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

The idea

RFC 0001 made "a driver" a first-class thing: an external process that exchanges
typed events with QPI-UI, built against an SDK. It delivered two kinds of driver —
a QPU that runs jobs (process) and a cryostat monitor that reports upward
(monitor) — and a CLI that launches either.

What it did not deliver is a way for anyone outside this repository to add one.
Today the set of runnable backends is two dictionaries compiled into the Python
SDK. A lab with a cryostat we have never heard of, or a QPU control stack we do not
ship an executor for, has exactly one route: fork the SDK, or write a bespoke
program and give up the CLI, the systemd installer, and the dashboard's setup
snippets along with it.

This RFC makes the backend set open, and does it without adding a second way to
do anything. Three ideas carry the whole design:

  1. An operation is a contract; a device is an implementation. The operation
    (process, monitor) is a fixed enum, because QPI-UI must have a handler for
    the events it involves. The device — which backend implements that contract — is
    open-ended, and that is where extension happens.
  2. A device is described by data, and built by a function that returns a driver.
    A DeviceSpec says what a device is called, what -o options it takes, and how
    to build it. Nothing about a device is knowable only by running it — so
    --help can list every option, and every device is unit-testable without a
    server.
  3. One CLI verb. qpi-driver start --operation <op> --device <dev>. Adding an
    operation stops meaning "add a subcommand", and adding a device stops meaning
    "edit the SDK".

Changelog

Added

  • 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 verifying the pin. The SDK skipped verification 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/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.
  • 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 a changed document is now executed against the real SDK, which is how this was found.
  • docs: RFC 0003 is Implemented, and RFC 0001 §2 now points at it for the operation/device layer rather than being edited in place.
  • 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-assigned this Jul 28, 2026
@Tinitto Tinitto closed this Jul 28, 2026
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