Reports › Contract docs › release-channels
RobotDiagnostics' Stable / Preview channels
2026-10-23 (Zach: "Please add the stable preview switch"). The channels come from the shared runtime,
~/stable_preview_switcher (its docs/CONTRACT.md is the interface; its skill/SKILL.md is how to use it).
This file holds the WHY for stable-preview.json, which JSON cannot carry.
What there is
| Where | What it runs | |
|---|---|---|
| Stable | http://127.0.0.1:8820/ | the runtime's Stable server: a pinned copy of io_diagnostics/web/ from the last Make Stable, /api/ forwarded to the release's own cli.py tray --port 8822 — the dashboard backend AND the top-bar chip, one process |
| Preview | http://127.0.0.1:8821/ | cli.py serve straight from the main checkout (main as it is now), restarted by the runtime whenever its Python or a plugin declaration changes |
| Agent lanes | sps dev in a worktree |
the same serve from that worktree, on a lane port (5400–5499) |
- The floating chip (bottom-right, just above the status bar) on every channel: Stable shows Open Preview and Roll Stable Back; Preview shows Make Stable.
- The dock icon (
~/.local/share/applications/robotdiagnostics.desktop, written bysps install-desktop): click = Stable; right-click = Open Preview (live — what agents are changing), Make Preview Stable, Roll Stable Back. - The tray belongs to Stable: it is Stable's
appprocess, so Make Stable and Roll Stable Back swap it with the page. Its Open dashboard runsgtk-launch robotdiagnostics(tray.pyas_installed), i.e. the dock icon's click. - At login
~/.config/systemd/user/robotdiagnostics.servicerunssps open --no-openonce (below).
Operate it from anywhere (the dock and the chip do the same):
sps --root ~/io-diagnostics status
sps --root ~/io-diagnostics promote
sps --root ~/io-diagnostics rollback (press again to roll forward) and sps --root ~/io-diagnostics
stop --all change what Zach uses: Zach's calls, never an agent's.
stable-preview.json, key by key
app: "robotdiagnostics": the id the old desktop entry, icon and WM class already used, so the dock icon and the icon theme entry stay the same.icon: brand/logos/r3-01-big-sticker.svg: the RobotDx logo (Zach, 2026-10-24: "final logo selection is the original Big Sticker Heart"), installed asrobotdiagnosticsbysps install-desktop. The tray draws the same mark fromcontract/topbar/(the symbolic icon and its states, copied there because a release snapshot leaves outbrand/), with the heart coloured by status.stable.port: 8820: Zach's muscle memory (the old service's port). The page's origin is unchanged, so its browser-stored settings key off the same origin.stable.buildcopiesio_diagnostics/webto.sps-site(output): the page is static files; the Stable server serves that copy and the release'ssource/keeps its ownweb/for the backend./bin/cpby absolute path, so the build does not depend on PATH.stable.spa: false: the app's own server answers a missing path with 404, not with the page; Stable does the same.stable.processes[app]:/usr/bin/python3 {source}/io_diagnostics/cli.py tray --port 8822. The system python, because the tray needs itsgi(GTK + AyatanaAppIndicator3); from{source}, so the dashboard backend, the plugin declarations (plugins.d/) and the tray all run the pinned release. ONE process for tray and API, because two live hosts would run every driver plugin twice against the same rack.stable.proxy: {"/api/": "app"}: the page calls/api/v1/*same-origin, as it does on the app's own server.IO_DIAGNOSTICS_PAGE_ORIGINS(on theappprocess): the Stable server forwards/api/withHostrewritten to 127.0.0.1:8822, so a page write (POST /api/v1/act | settings | expect …) carriesOrigin: http://127.0.0.1:8820, which no longer equalshttp://<Host>.server.py_from_pagealso accepts the origins named here; nothing else sets it, andSec-Fetch-Site: same-originplus the page header are still required.PATHinstable.envandpreview.env: the driver plugins runuv run …(uvis in~/.local/bin), and a process started by the dock or by systemd does not get the shell's PATH (the old service set the same value). It is set on both channels because the runtime removes every key a config names from what it passes down (CONTRACT §9.20): set only on Stable, Preview would start with no PATH at all.PYTHONUNBUFFERED=1: the process logs (~/.local/state/stable-preview-switcher/robotdiagnostics/run/*/*.log) get each line as it is printed.preview.command:/usr/bin/python3 {checkout}/io_diagnostics/cli.py serve --port {port}, the app's own server, which does the three duties of a non-Vite Preview (CONTRACT §10.7) inio_diagnostics/release_channels.py:/__sps/*tosps api, the runtime'schip.js, the chip tag in each page. Only when the runtime runs it as Preview or a lane (SPS_CHANNELpreview / dev); run by hand it is exactly the old server. The system python, like the tray, so both channels judge with the same interpreter.preview.health(and the process's):/api/v1/health, the host's cheap liveness answer.preview.watch:io_diagnostics/**/*.py,io_facts/**/*.py,contract/**/*.py,plugins.d/*.toml. The runtime restarts Preview when an agent's merge changes the host or a plugin declaration (CONTRACT §10.11; added to the runtime for this app). The page (web/) is read from disk on every request and needs no restart;plugins/code runs as a fresh subprocess each diagnose. Anchored patterns, so the watcher never walksreports/or.venv.snapshot.exclude:docs/,reports/,tests/,prototypes/,brand/,out/: nothing the running app reads, so a docs or report commit does not make the chip say "Stable is behind", andreports/(≈190 MB of media) never goes into a release.chip.position: bottom-rightwithchip.avoid: ["footer.statusbar"](Zach, 2026-10-23: "move the stable preview switching to the bottom right corner"). WHY avoid: at bottom-right the chip sat on the Prototype selects (#detail-layout,#utility-menu) and the skin bar;avoidmakes it float just above the status bar (§10.10). The earlier pick was top-left (measured: no control under it), replaced at his request.
The login service
The tray used to be the whole service (cli.py tray --port 8820 from a pinned detached worktree). Now the runtime owns
Stable, tray included, and the service only brings Stable up at login:
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStartPre=/bin/sh -c 'while ss -ltnH "sport = :8820" | grep -q . && ! curl -sf -o /dev/null http://127.0.0.1:8820/__sps/health; do sleep 15; done'
ExecStart=/usr/bin/python3 ~/.local/share/stable-preview-switcher/runtime/sps.py --root ~/io-diagnostics open --no-open
ExecStop=/usr/bin/python3 ~/.local/share/stable-preview-switcher/runtime/sps.py --root ~/io-diagnostics stop
TimeoutStartSec=infinity
Restart=on-failure
RestartSec=15
- WHY the runtime owns the tray rather than systemd: the tray is also Stable's API backend, and only the runtime knows which release is Stable. A unit pointed at "the current release" would need its own copy of that pointer, would not follow Make Stable or Roll Stable Back, and the runtime refuses to proxy to a port it did not start.
- WHY the wait before
sps open: port 8820 may be held by a dashboard started by hand.sps openwould start the tray (8822) and only then find 8820 taken and fail, and a restart loop would flash a tray icon every 15 s. The loop waits until 8820 is free or already answers as this app's Stable, so Stable takes over once the hand-started one stops. - WHY no
SPS_NO_WINDOW: everything the service starts inherits its environment, including the tray, whose Open dashboard runssps openthrough gtk-launch; with it set, that click would open no window. RemainAfterExit: the started processes live on in the unit's cgroup; stopping the unit (logout) runssps stop. A tray that crashes stays down until the next dock click orsps open(the old unit restarted it).
Proof (2026-10-23)
In a scratch clone (passive plugins only, SPS_STATE_HOME / SPS_PORTS scratch, SPS_NO_WINDOW=1, the tray on a
private Xvfb display and session bus): headless Chrome read the chip in its shadow root on Stable (match, grey Open
Preview), committed on main (behind, blue Open Preview), pressed Open Preview (this window moved to Preview: Make
Stable), pressed Make Stable (match), pressed Roll Stable Back ("Stable was rolled back", Reload, then behind); the
app's API answered through Stable's /api/ proxy (26 devices) and a page write passed the page gate there; a watched
.py edit restarted Preview; sps dev served a lane with the chip. Server side: tests/test_release_channels.py.