Roadmap
Milestones gate on demonstrable behavior, not effort. Specs referenced
must be review+ before implementation starts
(workflow). Milestone numbers are stable
anchors used across docs, ADRs and open questions; scope moves between them
by revision (this document’s history), never by renumbering.
Status
Section titled “Status”| Milestone | Status | Notes |
|---|---|---|
| M0 Docs & environment | done 2026-09-15 | doctor 0 failures, 3 tiers + 3 demos build, site builds |
| M0.5 Public foundations | done 2026-09-16 | CI green, fjarr.io + fjarr.dev live, registrations, prior-art anonymization policy |
| M0.6 Private development | planned, soon | Erik, 2026-10-15: the repo private with CI on self-hosted runners only, the docs behind a Cloudflare login, the landing page and the packages public; public customer docs later (below) |
| M1 Core + extension API (camera video) | done 2026-09-25 | slices 0–2 done and reviewed, 2.9 (baseline bump, ADR-0022) done 2026-09-18, 3a (browser lab) 2026-09-19, 3b (agent core) 2026-09-20, 3c (introspection + memory ladder) 2026-09-20, 4 (fjarr.camera on real sources) 2026-09-20, 5a/5b (blob frames, fjarr.introspect, the served viewer) 2026-09-21, 6a/6b (repair + rate control, passthrough) 2026-09-22, 7a/7b (the remaining fault rows, the latency harness) 2026-09-23/24 — every slice landed; the gate review found the ADR-0015 webhook half had never run and that standard-form TURN URLs were silently ignored by the agent, and fixed both. Gate met 2026-09-25: CI green and the first fully green nightly on the self-hosted runner, which also recorded the first trustworthy latency numbers — clean 56/79 ms, lossy 82/100, relay 69/102 against budgets of 120/200, 200/350 and 250/450, at 46–52 decoded fps |
M2 Desktop spikes + fjarr.terminal | done 2026-09-27 | 2a terminal and 2c module loader 2026-09-25; 2b the desktop spikes on a dedicated Ryzen mini-PC, ADR-0006 accepted with numbers (E for GNOME, C second, A for X11 kiosks, forced connectors for headless robots) |
| M2.6 Encoder families | planned | added 2026-09-24 (ADR-0025): nvcodec and nvv4l2 behind the existing adapter; closes open question #2 |
| M4.5 Network tunnel | done 2026-09-27 (gate review) | added 2026-09-23 after the ROS 2 tunnel spike (docs/27, ADR-0023, ADR-0024); depends only on M2.5, so it can be pulled earlier if a design partner asks |
| M2 – M8 | planned | revised 2026-09-17 after the slice-3 planning (docs/23–26, ADR-0019–0022, the webrtcbin spike, the planning review): new M2.5 packaging milestone; M3 lightened |
M0 — Documentation & environment (done)
Section titled “M0 — Documentation & environment (done)”Docs 00–19 at review; ADRs 0001–0016 filed; devcontainer + compose +
robot-sim + doctor working; buildable skeletons for all three libraries,
three demos, website.
M0.5 — Public foundations (done)
Section titled “M0.5 — Public foundations (done)”CI (lint, build, docs gates), website deployed via Cloudflare Workers
Builds, registrations (fjarr.io + fjarr.dev, npm org @fjarr, GitHub org
fjarrio connected to crates.io). Prior-art sources anonymized across the
tree, history and site (docs/11).
M0.6 — Private development {#m06}
Section titled “M0.6 — Private development {#m06}”Why. Erik takes Fjarr private while he settles the licence and the business model (2026-10-15): the repository and the developer docs leave public view, and what robots and integrators install stays public. Done soon, not now; nothing is switched before this milestone runs.
| Surface | After M0.6 |
|---|---|
GitHub fjarrio/fjarr | private |
| CI | self-hosted runners only, none of GitHub’s hosted runners |
| fjarr.io landing page | public |
docs/ on the website | behind Cloudflare Access: the internal developer docs |
apt.fjarr.io, get.fjarr.io, ghcr.io/fjarrio/fjarr-agent | public |
npm @fjarr, crates.io | may be public; nothing is published before the licence decision (below) |
| demo.fjarr.io, signal.fjarr.io | unchanged (already protected) |
What the switch touches was swept on 2026-10-15 (four sweeps and a
completeness review over the workflows, tools/ci, the website, packaging,
shipped strings and every doc). The slices follow from it.
| Slice | Scope | Done when |
|---|---|---|
| 0.6a Runner pool | at least four isolated amd64 slots (4 vCPU, 16 GB, 80–100 GB each; one ephemeral VM and Docker daemon per job, because the stack jobs bind fixed ports and tun-up creates fjarr0 on the host); one native arm64 runner that is always online (the arm64 packages are on every push, and ADR-0031’s “never emulated” holds); an ephemeral release runner that holds the apt key and never runs anything else; a provisioning script (Ubuntu 26.04, Docker and buildx, gh and aws CLIs, rustup, the TSan vm.mmap_rnd_bits sysctl, the tun module, one fixed runner uid, runner-agent updates and a monthly template rebuild); Docker Hub login or a pull-through mirror; timeout-minutes on every job and a concurrency rule for back-to-back pushes; the labels in .github/actionlint.yaml | ci.yml runs green on the pool, side by side with the hosted runners, for a week, within docs/30’s budget; e2e repeated on the pool shows its timing races no worse |
| 0.6b Release on a private repo | check the org’s GitHub plan: on Free, a private repository has no environment secrets and no required reviewers, so release.yml would lose the apt key’s scoping and the manual promotion to stable (Team, or a release path that keeps both); artifact storage (Free allows about 500 MB, CI keeps about 1.6 GB of .debs): a shorter retention or the .debs in a private R2 prefix, with release.yml and tools/ci/ci-run.sh reading from there; fjarr-ci images private (they hold closed code) within the plan’s package storage; fjarr-agent stays public and anonymous pulls keep working; keyless image signing writes the repository and workflow name to the public Rekor log, which is accepted or replaced by a key; secret scanning and push protection, lost on a private Free repository, replaced by a local or CI secret scan; GitHub’s announced charge for self-hosted minutes in private repositories, checked when the switch runs | a release from the private repository signs and publishes to apt testing, a person promotes it to stable, and the public image and install script work anonymously |
| 0.6c Docs behind a login | one boundary for the docs (a separate host such as docs.fjarr.io is the cleanest; a /docs/ prefix also works), with the Starlight search index behind the same Access application; a noindex header, a landing-only sitemap and robots.txt; workers.dev and preview URLs off or protected; fjarr.dev pointed at the landing page; Cloudflare’s GitHub app granted the private repository; the Access application created by deploy/demo/cloudflare.py generalised (same login, a team allowlist); an ADR amending ADR-0014 | an anonymous visitor reaches the landing page and nothing of docs/ on any host, preview or search index; a team member logs in and reads the docs; pushing main still deploys |
| 0.6d Public surfaces | the landing page: no GitHub links, no links into the internal docs, no “the threat model / business plan / specs are public” claims, and no licence or open-core wording while the licence is open (the meta description, the hero, section 10); a public install page with the apt key fingerprint, which install.sh names; /.well-known/security.txt with a security contact; the Documentation= URLs in the shipped systemd units pointed at public pages (in a release); debian/copyright without the private source link, and third-party notices for what the binaries bundle (the MIT and Apache Rust crates, the viewer’s JavaScript), which their licences require whatever Fjarr’s licence becomes | every link in a public artifact (the landing page, the packages, the image, install.sh) opens without a login |
| 0.6e The switch | the docs and agent guides restated for a private repository and self-hosted CI (docs/12, 13, 19, 26, 30, 04, 15, CLAUDE.md, README.md, CONTRIBUTING.md); a new ADR (private repository, CI entirely on self-hosted runners) amending ADR-0033; every runs-on moved to the pool’s labels; then the repository flipped to private, forking disabled at the org, third-party actions pinned by SHA | the repository is private, ci.yml, the nightly, the lab workflows and a release all run green on self-hosted runners, and the public surfaces in the table above answer anonymously |
Order: 0.6a and 0.6b first, while the repository is still public and CI can run in both places; 0.6c and 0.6d next, since they need nothing private; the flip last. Everything already public stays public: anyone who copied a version while it was public keeps the licence it carried, and archives may hold the website.
Customer documentation, later {#m06-customer-docs}
Section titled “Customer documentation, later {#m06-customer-docs}”docs/ stays the team’s internal documentation. A separate, streamlined documentation for
customers and users is public: its own tree outside docs/ and its own
Starlight build on a public host, written from the internal docs without
forking their facts, with a build guard that fails on a link into the
internal docs. It takes over what customers read today: the install guide
(the public half of docs/26), the driver
catalog pages, the tunnel how-tos, the web libraries’ guide, the protocol
schemas at their fjarr.io/protocol/ URLs, and the per-release
versioning (open question 14,
docs/19). Every shipped string that names an internal doc (the agent’s hints,
fjarr-setup’s and fjarr-connect’s help and prompts, the shipped
configuration comments, @fjarr/core’s errors) is pointed at it, and
docs/00’s “integrates in a week from the
docs” means these docs. Its own spec, written when it starts, says the
hosts, the licence of its text and the support channel.
The licence decision {#m06-licence}
Section titled “The licence decision {#m06-licence}”Open question #13. Until Erik decides, nothing new is published to npm or crates.io: a
published version’s licence and repository link are permanent, and a
crate publishes its source, which would make crates.io the one public copy
of the Rust code. The licence files and fields (LICENSE,
debian/copyright, Cargo.toml, the package.jsons, the image’s licence
label), ADR-0011,
ADR-0038’s framing, the
open-core split in docs/03 and
docs/32, and CONTRIBUTING.md’s CLA all follow
the decision, in one change with a superseding ADR. The no-GPL rule for
shipped artifacts stays under any licence.
M1 — Core + extension API, proven by fjarr.camera (done)
Section titled “M1 — Core + extension API, proven by fjarr.camera (done)”Scope. Agent core (session lifecycle, FrameHub, DC router, reconnect
ladder; libsoup-3 signaling per ADR-0017); Rust signaling with grants,
webhooks, TURN minting (done, slice 1); the web core per
docs/21: client-owned multi-session
handles, the three subscription modes, the publish side (send / request /
newest-wins publishers with deadman / ordered channels / bulk), demand-driven
track delivery with visibility, the stats & health sampler, and the
capability-specific core hooks docs/22
requires (page-level focus registry, preference/latencyMode acquire
options, fjarr.core/time-sync). Camera capability as the reference
implementation; the ADR-0007 webrtcbin-vs-webrtcsink spike; baseline
adaptive bitrate. Extension API paper-validated — done:
M1 API-fit review.
Slices (each lands on main, reviewed retrospectively — docs/20):
0 protocol + review ✔ · 1 Rust signaling ✔ · 2 web core + React ✔
(review) ·
2.9 baseline bump ✔ — dev container, CI and images on Ubuntu 26.04 /
GStreamer 1.28 (ADR-0022),
doctor re-verified, the webrtcbin spike re-run on 1.28 (track removal
settled on inactive; answerers set reuse-source-pads) ·
3 in three increments (docs/23):
3a ✔ the browser lab (2026-09-19: browser
service, @fjarr/e2e, wire tap, LoopbackAgent, frame stamp,
fjarr-lab, CI job; the Chromium-answerer spike report on ADR-0007) ·
3b ✔ the agent core
(2026-09-20: libfjarr core loop, signaling client, session state machine,
DataChannel router, FrameHub fan-out, producers/consumers on webrtcbin,
the built-in fjarr.test, fjarr-opsim, the introspection walker and
minimal endpoint (docs/24), real HS256
grants in the demo backend, demo-robot streaming the test pattern to the
demo dashboard, the ASan and RAII gates) · 3c ✔ introspection
completeness and the memory-safety ladder (2026-09-20: /events,
/stats, /memory + checkpoints, /log, the diagnostics bundle,
fjarr-lab introspect; TSan, the leaks-tracer bracketing and the soak as
gates, valgrind/heaptrack/netem nightly on a runner prepared for a GPU
machine; it found an upstream webrtcbin stats leak on day one —
review) ·
4 ✔ fjarr.camera (2026-09-20: tracks from config through the
SourceFactory, the gst/test/v4l2/rtsp types, hot-plug by
renegotiation, per-track failure that never rebuilds the plane,
--probe-source for every type, the demo robot on a pattern, the lab’s
RTSP simulator and the host webcam —
review) · 5a ✔ demo wiring, protocol
half (2026-09-21, docs/23:
docs/08 blob frames on both tiers, fjarr.introspect, the pipeline
feeds, <PipelineGraph> + the dashboard Diagnostics tab, the demo
backend’s operator/developer roles —
review) · 5b ✔ demo wiring, delivery half
(2026-09-21: the viewer served from introspect.viewer_dir, make introspect opening it with the demo’s token, the CI-built fjarr-agent
image running the demo robot under the lab smoke — unpublished until M2.5;
review) ·
6a ✔ repair and rate control (2026-09-22, docs/23:
ADR-0007 closed on webrtcbin with the agent’s own TWCC estimator;
NACK/RTX and keyframe feedback in the offer; banded shared-encoder
targets with per-viewer tier switching; the lab gates under the netem
profiles — review) · 6b ✔ passthrough (2026-09-22:
a camera’s own H.264 untouched — no encoder for the track — with its
substream as the thumbnail tier and adaptive: false without one;
review) ·
7 in two increments (docs/23):
7a ✔ (2026-09-23) the four robot-lifecycle fault rows nothing exercised
— a killed agent, a wedged core loop against a fake notify-socket
supervisor, a pipeline error through the escalation ladder, and an expired
or skewed grant — plus a signaling-server restart under the real agent; it
found and fixed a tier-framerate bug that stopped any camera below 30 fps
from streaming at all;
7b ✔ (2026-09-24) the glass-to-glass harness on the existing frame
stamp: coturn in the lab profile so the relay column exists and is
asserted to be genuinely relayed, p50/p95 and time-to-first-frame under
clean/lossy/relay, a loose ceiling in CI with the docs/16 budgets behind
E2E_LATENCY_STRICT for the prepared runner, and make latency recording
a labelled row that carries its own decoded fps so a CPU-limited machine
cannot be mistaken for a slow network. Input-to-photon needs a robot-side
input path and lands with M3.
Gate: the three-demo stack end-to-end — demo-robot (embedding libfjarr)
streams 2 tracks to 3 browsers through the fjarr-server sidecar, the TS
demo-backend minting grants and receiving webhooks per the ADR-0015
contract; reconnect + ICE restart demonstrated under fault injection;
latency harness reporting against budgets;
TS types conform to the golden fixtures (closes open question #15);
written API-fit review for the remaining capabilities.
M2 — Desktop backend spikes + fjarr.terminal
Section titled “M2 — Desktop backend spikes + fjarr.terminal”The five spikes per docs/07 close ADR-0006 with measurements — unattended access is the hard gate; cursor metadata, desktop-audio capture and monitor hot-plug events are measured per backend for M3. The winning backends are built as the runtime modules of ADR-0021 (the module loader lands here). In parallel, the terminal capability — the deliberately media-free second consumer of the extension API (two unrelated capabilities = minimum bar for “generic”) and the first user of the bulk byte channel. Slices (planned 2026-09-25; the three deliverables are unusually independent, so they land in this order for the reason each gives):
- 2a ✔ (2026-09-25)
fjarr.terminal. The pty over the bulk byte channel in both directions (docs/08), the xterm component, the ownership lease, the audit, and the config that makes it exist at all (docs/06, docs/10). It did what it was scheduled to do: the extension API needed one amendment,watch_readable, because a pty is a file descriptor and nothing in the interface could serve one without breaking the single-loop rule (docs/09). Measured: an interactive round trip of 11 ms against a 150 ms budget. First because it needs no hardware and no display server, and because an extension API with one consumer is not proven generic — it is camera video with extra structure. If the API is wrong, this is the cheapest moment to find out. Gate: interactive round trip < 150 ms on LAN; a disconnect leaves no orphan shell; every open and close audited; no extension-API change was needed, or the API is amended and the fit review re-run. - 2b — the desktop spikes and ADR-0006. Two phases
(docs/07): unattended access
for all four combinations first, since it eliminates; then the full
criteria table for the survivors. On the
gpu-desktoprunner, because a rebooted machine with nobody logged in is the one thing a container cannot simulate. Phase 1 is specified to the point of being startable: a dedicatedfjarr-spikeaccount, auto-login toggled between runs, both the appliance and login-screen sub-cases, and an injection oracle that records what actually arrived rather than trusting a return value. Its one recorded caveat is that Wayland findings on an NVIDIA machine are provisional until seen on Intel. Gate: ADR-0006 accepted with numbers, not adjectives, and a go/no-go on unattended access naming the exact mechanism. - 2b is blocked on hardware (2026-09-25): the machine it needed is no longer available, and its question — what a real machine does after a real reboot — is precisely the one no container answers. The protocol is written and startable; it waits on a host, not on a decision. 2c runs first instead, which changes nothing about it: the loader is backend-agnostic, so it does not depend on which combination wins.
- 2b ✔ (2026-09-27) on a dedicated spike machine: a Ryzen mini-PC on Ubuntu 26.04.1, which also answered the Intel/NVIDIA caveat because it runs Mesa. Stock 26.04 turned out to have no X11 session, which added a fifth candidate, E (mutter’s own interfaces). Phase 1 passed every combination for the appliance case in its own session type; none reaches GNOME’s login screen. Phase 2 measured latency, cost, cursor, audio, failure modes, three monitors, cable hot-plug and headless robots. Gate met: ADR-0006 accepted with numbers. E for stock Ubuntu, C second, A for X11 kiosks, and a forced connector for robots without a display.
- 2c ✔ (2026-09-25) the module loader. A backend loads at runtime
from a separate package
(ADR-0021) and the
always-present
fjarr.desktopcapability reportsunavailablewith the package to install when none matches — a robot with no desktop still passes--check, because that is the normal case. The seam’s version is in the exported symbol’s name, so a module built against an older one is not found rather than loaded and misread. Gate met: the loader creates a backend from a separate.soon the dev stack, and the doctor prints a desktop row either way.
Gate: ADR-0006 accepted with data; terminal accepted per docs/06; no extension-API changes needed for it (or the API amended + re-reviewed); a desktop backend loads as a module from a separate package on the dev stack.
M2.5 — Packaging & install (done) {#m25—packaging—install}
Section titled “M2.5 — Packaging & install (done) {#m25—packaging—install}”Everything a robot needs to receive the product, so M3 can ship
features into it rather than build the delivery: the apt repository and
signing, the fjarr-agent package with its systemd unit
(ADR-0019 addendum), fjarr-desktop-x11 / fjarr-desktop-wayland /
fjarr-inputd / fjarr-tools, the container image split (base +
per-vendor layers, arm64 — the core image itself is built and tested in
CI from slice 5b and first published here, with the release process),
the install script, fjarr-agent setup / --check
/ drivers, and the driver catalog format with the built-in entries
(test, v4l2, rtsp, desktop backends) — docs/26.
Vendor camera packages themselves are M3 (chosen by the design partner’s
hardware). Also here: the browser lab profiling
scenarios and docs/16 web budgets promoted to CI gates, and release
process + docs versioning (open question #14). Once the agent runs on the
spike machine, also try two things over fjarr.net and record the results in
docs/27 (noted 2026-09-27,
unverified): an RDP client to the robot’s own gnome-remote-desktop, and
Nautilus mounting the robot over sftp (sftp://<user>@<tunnel address>/).
Decided 2026-09-28 (ADR-0031,
docs/26): apt and containers now,
built once and packaged twice; Pixi, Nix and Yocto per customer as renderings
of the same system profile; debhelper packages in a signed static repository on
Cloudflare R2; one version for every artifact; tag → testing → a manual
promotion to stable without a rebuild.
Progress 2026-09-28: the .debs, libfjarr-dev, the system profile and
--check, the image from the .debs, and the setup tool’s first commands:
fjarr-agent net setup, net up (fjarr-net.service) and setup --undo net
(docs/26), proven on
the spike machine across a reboot. Later the same day: fjarr-agent setup
(the first run: detection, server, device id and token, cameras as tracks,
terminal, the agent started and online, --undo) and drivers list|detect|install
with the built-in catalog (packaging/catalog.toml), proven on the spike
machine against the lab server and in the package install test
(docs/26). Then the
signed repository on R2, install.sh behind get.fjarr.io, one version
everywhere and the release workflow (0.1.1 on 2026-09-29, 0.1.2 on
2026-09-30), and the reference compose file with the image’s own tunnel
entrypoint, proven by make compose-gate in CI with host networking.
Closed 2026-09-30. The gate, met: the mini-PC reinstalled with Ubuntu
26.04 went from wget -qO- https://get.fjarr.io | sudo sh (0.1.2) to the
test pattern in the demo dashboard with no hand-written config, sudo fjarr-agent --check all green; the compose robot passes setup, --check,
ROS ordered on carrier, an unprivileged agent and the tunnel scenario on every
push; packages and images for amd64 and arm64 in CI; tag → testing →
stable twice. The fresh install found a real bug — /run/fjarr missing
until the next boot, the package ordering tmpfiles before sysusers — fixed for
0.1.3 with an install test that now catches it. The two trials over
fjarr.net worked: RDP with Remmina to the robot’s gnome-remote-desktop, and
Nautilus over sftp:// (docs/27).
Docs versioning (/vX.Y/ snapshots, docs/19) is postponed until the roadmap
settles, by decision: the pages change too fast now for a snapshot to help.
Moved to M3 (2026-09-28): the desktop packages and setup desktop. They
configure the session helper and backend M3 builds; in M2.5 they would set up
an auto-login for a helper that does not exist yet.
Gate: a fresh Ubuntu 26.04 machine (the spike machine, reinstalled) goes
from wget … | sudo sh to a test pattern in the dashboard with no hand-written
config; a containerized robot runs from the reference compose file, tunnel
included;
--check reports a correct profile on both; the package set and images build
for amd64 and arm64 in CI; a release goes tag → testing → stable without a
rebuild.
M2.6 — The encoder beyond Intel
Section titled “M2.6 — The encoder beyond Intel”EncoderAdapter’s second and third implementations per
ADR-0025: the nvcodec family for discrete
NVIDIA cards and nvv4l2 for Jetson, with the auto preference order, the
memory-path rule, and the live-bitrate declaration that keeps rate control
honest on a family that cannot take one. Plus the doctor probe per family,
the platform matrix’s runner column, and the container plumbing that gives
the dev image a GPU (NVIDIA_DRIVER_CAPABILITIES, which is why
gst-inspect-1.0 nvcodec reported zero features the first time it was
tried).
Here rather than post-M6 for two reasons. A seam with one hardware
implementation is not proven generic, and this is the cheapest moment to
find that out. And M2.5 already builds arm64 images and owns the driver
catalog a Jetson package belongs in, so doing this next avoids opening the
same boxes twice. nvcodec goes first because the hardware exists and
everything below Fjarr is verified on it; nvv4l2 follows when a board does.
Gate: a camera streams through nvcodec on the gpu-desktop runner
with no CPU colour conversion in the pipeline graph, rate control moves its
bitrate while playing, auto picks VA-API on a machine that has both, the
doctor names the family it chose and why, and the nightly runs the media
suites on that runner. Jetson’s row in the matrix stays untested until a
board is in CI, per ADR-0025.
M3 — The robot’s desktop {#m3}
Section titled “M3 — The robot’s desktop {#m3}”fjarr.desktop: an operator sees the robot’s screens in the browser and uses
them as their own, on a robot that nobody attends. Planned 2026-09-30 from an
inventory of what exists. What exists is the backend module seam
(ADR-0021), a fjarr.desktop that
reports only its availability, the session core’s control domains and
release_all_input, and the web client’s slice-2 primitives (focus registry,
monitors, latencyMode, preference). The backends, the helper and the
desktop view exist only as M2 spikes. Scope
(docs/22, docs/07,
ADR-0006,
ADR-0028):
- Backend E (mutter, GNOME on Wayland) first, then A (X11 kiosk). Backend C (the portals, for non-GNOME Wayland) is deferred until a robot needs it: E covers stock Ubuntu and A covers kiosks, and a third backend without a user is a third to keep working.
- The session helper in C++ in the agent’s tree, owning D-Bus and PipeWire narrowing. Backend module E owns the stream, the cursor reader and libei (ADR-0028, addendum 2026-09-30).
- The web client:
DesktopInputin@fjarr/core,<DesktopView>and its hooks in@fjarr/react, and a Desktop panel in the demo dashboard (docs/22). - Ghost screens for headless robots, beside real monitors (ADR-0032, docs/26).
- The test lab: headless mutter in CI, and the mini-PC as a shared nightly
runner under
fjarr-lab(ADR-0033).
Slices, in dependency order. Each ends in something demonstrable and tested:
| Slice | What | Done when |
|---|---|---|
| 3.0 Lab foundation | A throwaway spike first: headless mutter in a container with no GPU gives RemoteDesktop, ScreenCast, a PipeWire frame and EIS input (ADR-0033’s assumption; if not, its fallback). Then the fixture in CI (docs/15), and fjarr-lab written and tested without a real runner (docs/12). Code only: no lab machine is needed | CI captures a frame from a mutter desktop and sees injected input in its test window, on every push; fjarr-lab’s window, reservation and grace logic pass their tests |
| 3.1 See | fjarr-desktop-session and backend module E for one monitor: the helper hands over the PipeWire descriptor, and the agent turns it into a video track that repeats its last frame on a static screen. <DesktopView> shows it | the fixture’s desktop is live in the demo dashboard, and the fixture asserts frames from a static screen |
| 3.2 Control | DesktopInput and <DesktopView>’s input: pointer, wheel, keys by code, no auto-repeat, composition, reserved shortcuts and Keyboard Lock, release-all. libei injection in module E. The desktop control domain wired to it. An input-to-photon harness | keys and Shift-level text reach the fixture’s test window and Alt+Tab reaches its compositor, and a character its US keymap lacks is refused by name; åäö type against a Swedish keymap (unit test; real typing on the mini-PC in 3.L); disconnecting mid-keydown leaves no key held (docs/15 safety); the input-to-photon harness runs against the fixture |
| 3.L Lab machines (with Erik) | The manual steps, done when the slices need hardware and not before: ssh and sudo on the mini-PC for development, registering it as a runner (a one-time token from the repository’s settings), installing fjarr-lab with its labels and CI window, and the first nightly on it. The GPU desktop joins the same way when it suits, as it is needed only for the nightly media jobs | the mini-PC runs a nightly job inside its window and fjarr-lab reserve holds it; 3.1 and 3.2 shown on real hardware: its screen in the dashboard, and input-to-photon within docs/16 |
| 3.3 Appliance | The helper as a systemd user unit, with SO_PEERCRED and PipeWire narrowing (proven first; the gating item). fjarr-desktop-wayland and setup desktop / --undo desktop: auto-login account, GDM watchdog, helper, ghost screens and fjarr-agent display. The unattended-access test | apt install fjarr-desktop-wayland plus setup desktop gives remote desktop after a reboot with nobody at the machine, nightly; --undo restores the machine; the agent cannot open the desktop user’s microphone |
| 3.4 Multi-monitor | One track per monitor, diffed by wire id (docs/23); the monitors event before each re-offer; mode changes in place; zero monitors. The helper sends monitor-gone (mutter does not), and keeps a ghost from being primary while a real monitor is connected (docs/26). <DesktopLayout> and a monitor picker in the demo. Hot-plug in CI with mutter’s virtual monitors (desktop-hotplug) | desktop-hotplug in CI; the docs/06 hot-plug criteria on the mini-PC with a real monitor, the DisplayPort chain and two ghosts, each with its own stable id |
| 3.5 Comfort | Local cursor through Fjarr’s own PipeWire reader (docs/22); clipboard both ways (mutter’s interface); sharpness in the agent; per-session virtual monitors | the cursor is drawn locally with no lag; copy and paste both ways in the fixture and on the mini-PC |
| 3.6 Separate windows | The portal-vs-route spike (open question #19, closed: the portal), then “Open in separate window”: a robot monitor in a window of its own, fullscreen there (docs/22) | a robot monitor opens in a separate window with one click, streams there, takes input and goes fullscreen with Keyboard Lock (Alt+Tab reaches the robot); closing the dashboard tab closes it; the same in Firefox |
| 3.7 X11 kiosk | Backend A and fjarr-desktop-x11: the xhost grant and the layout helper. robot-sim becomes its CI fixture, with scripted xrandr changes | an X11 kiosk robot works from the package, tested on every push |
| 3.8 Containers | A containerized robot’s desktop through the reference compose file, with the helper’s socket bind-mounted (docs/26) | the compose gate extended to the desktop |
Gate: the fjarr.desktop acceptance criteria (docs/06);
input-to-photon within budget on the mini-PC; apt install fjarr-desktop-wayland plus setup desktop gives remote desktop on the mini-PC,
unattended after a reboot, and --undo desktop restores it; the mini-PC headless
with two ghost screens plus a real monitor, all streaming with distinct ids; an
X11 kiosk from fjarr-desktop-x11; a containerized robot’s desktop through the
reference compose file. No release until M3 closes (decided 2026-09-30).
Progress 2026-10-01: 3.0 done 2026-09-30 (the fixture and fjarr-lab in
CI). 3.1 done: fjarr-desktop-session hands module E the PipeWire
descriptor over fjarr-desktop-1
(docs/23); the agent
offers desk-virtual-1, and pipewiresrc keepalive-time keeps a still screen
producing frames. make desktop-e2e asserts both in CI through opsim. In the
lab browser, the demo dashboard’s Desktop panel (<DesktopView>) decodes the
fixture’s screen (web/e2e/tests/stack/dashboard.spec.ts).
3.2, agent side done: module E is the libei sender on the EIS socket the
helper hands over, the agent maps KeyboardEvent.code to evdev, and text is
typed through the robot’s keymap with xkbcommon. A spike showed no Unicode path
on mutter 50 and libei 1.5, so docs/08 now says so. make desktop-e2e drives the
fixture in CI. Clicks, keys, text, Alt+Tab and an untypable “å” all check against
the window’s log, and the Shift held at session end is released. Input-to-photon
is p50 21 / p95 24 ms on the dev machine, within the container. 3.2 done:
DesktopInput in @fjarr/core and <DesktopView>’s input surface, with
focus, Esc, IME through text, pointer capture, the non-passive wheel and the
control domain. The demo’s Desktop panel has take-control, Ctrl+Alt+Del,
Alt+Tab and fullscreen. In the lab browser real Chromium types into the
fixture, and Shift held through an Esc is released on the robot
(dashboard.spec.ts). 3.3 started; its gating item is proven: the
helper narrows each handed PipeWire connection to the core, the stream’s node
and the client-node factory
(spikes/pipewire-narrowing).
fixture-helper-check shows on every push that a live microphone, which an
unnarrowed connection captures, yields nothing through the handed one. Then
fjarr-desktop-wayland (the module, the helper and its user unit, the group,
the watchdog), setup desktop / --undo desktop and fjarr-agent display
with generated ghost EDIDs. All are tested in containers and the package
install test, and display list was read on real hardware. What remains of 3.3
needs the mini-PC (3.L): a reboot into the automatic login, ghosts on real
connectors, the watchdog bringing a killed session back, and the nightly
unattended test.
3.L on the mini-PC, 2026-10-01 (Ryzen 7 5700U, Ubuntu 26.04.1, kernel
7.0, GNOME 50, Swedish layout, three Dells on one DisplayPort chain):
fjarr-desktop-wayland and setup desktop --ghost-screens 1, then a reboot.
desktop logged in by itself, the helper connected 3 s after the agent
started, and mutter reported 4 monitors: the chain’s three and “Fjarr Ghost 1”
on HDMI-A-1, whose EDID was ours. desktop-see 5/5 and desktop-control 12/12
ran from the dev machine over the LAN: åäö and AltGr’s ”@” typed through the
robot’s own layout, a click landed on its pixel at 1920×1080, and
input-to-photon was p50 52 ms, p95 59 ms (budget 150/250). The watchdog
brought a killed session back in 51 s (two checks, then a GDM restart), with
the helper, capture and input back a second later. Two bugs that only this
hardware showed were fixed the same evening (0ce5475): the watchdog would have
logged out a person at the machine, and amdgpu’s chain root was not recognised.
Then, the same evening, #37 was answered: a real monitor on a ghost connector is
found over DDC, and display list and --check fail on it. The mini-PC was
registered as a runner (fjarr-lab, amd64, desktop, gnome, vaapi) under
fjarr-lab with the 00:00–06:00 window. The unattended-access test is
lab-desktop-prepare at 00:15 and lab-desktop-verify at 00:45, and its first
run by hand passed every step from reboot to input (docs/15). 3.L done. One
question goes to 3.4: a desktop account created by setup desktop has no saved
monitor layout, and mutter made the ghost its primary monitor.
3.4, agent side done (2026-10-01): one track per monitor, diffed by wire id,
with the monitors event before each re-offer. Two spikes first: a started
RemoteDesktop session takes no new streams, and EIS’s absolute pointer covers
only linked monitors. So the helper keeps one unlinked RemoteDesktop session
for input and one ScreenCast session per capture. It sends monitor-gone
itself, and makes a real monitor primary when mutter picks a ghost. The
fixture plugs mutter virtual monitors, and desktop-hotplug passes 12/12. The
new track was offered 101 ms after the plug. The first monitor’s longest gap was
117 ms, which is its keepalive, so nothing was dropped. The unplugged track left
21 ms after the unplug, and came back as the same track_id. The web side
followed the same day: <DesktopLayout> arranges every monitor by its geometry,
reflows on hot-plug, and hides exact mirrors. The demo’s Desktop panel has a
monitor picker (primary, all, or one by stable id). In the lab browser, a
monitor plugged into the fixture appeared in the layout streaming, and left it
again when unplugged. Remaining in 3.4: the mini-PC run, with a real monitor,
the DisplayPort chain and two ghosts.
3.5, the clipboard both ways (2026-10-03). A spike first
(spikes/desktop-comfort): mutter’s
clipboard works on the helper’s unlinked input session, both ways, given every
text name an application may ask for, an answer to every transfer, blocking
descriptors and a keyboard on the seat. The same spike showed the cursor
arriving as SPA_META_Cursor on every pointer move, with its shape once per
change, which is what the local cursor is built on next. Built: clipboard-offer,
-read and -write (docs/08), the
helper’s relay and module E’s ClipboardHandle
(docs/23),
DesktopClipboard (one per session) and <DesktopView>’s held-back paste
chord (docs/22). In CI, opsim’s
desktop-clipboard reads “robot says åäö” copied on the fixture and pastes
“operator says ÅÄÖ” there, 7/7. In the lab browser, the demo’s Desktop panel
does the same through the real browser clipboard and a real Ctrl+V.
The local cursor (2026-10-03). Module E now reads each capture’s stream
itself, frames and the cursor, as the node’s only consumer: a second
consumer beside pipewiresrc lost either the first frame of a still screen
or the shape (both measured). The robot’s shape becomes the surface’s CSS
cursor under the operator’s own pointer, and an overlay at the robot’s
position everywhere else. In CI, opsim’s desktop-cursor receives mutter’s
24×24 arrow with its pixels and the pointer where its input put it. In the
lab browser, the demo’s Desktop panel shows the same arrow both ways.
Found on the way: control envelopes sent before the control channel opened
were dropped, which lost the cursor’s shape and the clipboard offer for every
session but the first; they are now held until it opens.
sharpness and per-session virtual monitors (2026-10-03). A producer
that any viewer asks to keep sharp lowers its frame rate as the bitrate
falls, instead of each frame’s quality
(docs/16). add-monitor gives the operator a
screen of their own on the robot, sized to their window: the helper’s
RecordVirtual stream is that monitor’s capture, it streams like any other,
and it goes with remove-monitor or the session
(docs/22). In CI,
opsim’s desktop-virtual adds a 1024×768 screen (answered in 19 ms),
receives its frames, and removes it, 6/6. In the lab browser, the demo’s
“Add a screen” shows the new screen streaming and removes it.
On the mini-PC (2026-10-03). The cursor, sharpness and virtual
monitors worked on the real desktop. Copying from the robot did not: module
E asked mutter for the clipboard under the message’s own name, which the
fixture’s wl-copy answers anyway and a GNOME app does not, and an empty
blob was never sent; both fixed, and the helper now refuses a type the
robot does not offer, so CI catches that class. GNOME’s screen-sharing stop
left the desktop gone until the agent restarted: the agent now reports
sharing: stopped (keyed on the input session closing, since an unplug
also ends a capture), and any session may resume-sharing; the dashboard
chooses to ask or resume at once (onRobotStop). Resuming found one more:
a track returning to a live session reused its SSRC under a payloader
that restarted its sequence numbers, and SRTP refused them as replays,
which also hit a re-plugged monitor about half the time. Stop, resume and
auto-resume verified on the mini-PC. Real monitors (2026-10-04). With the
three Dells on the DisplayPort MST chain, two of them (DP-4, DP-8) streamed
no frame in metadata mode and fell back to the cursor in their video, so the
embedded fallback is needed on real hardware, not only for unpainted
virtual monitors. Unplugging the chain crashed GNOME Shell itself (SIGSEGV
after “Page flip failed: drmModeAtomicCommit: Invalid argument”, an upstream
mutter/KMS bug on this MST chain, as on X11 in 2b); the desktop session
restarted, the helper reconnected after 54 s, and the dashboard showed the
desktop again with no one at the robot. PNG images on the clipboard both
ways came the same day. 3.5 is done.
3.6, separate windows (2026-10-04). The spike
(spikes/presentation-mode) closed open
question #19 for the portal: an empty popup stays in its opener’s browsing
context group under every COOP, where a popup loading its own page does not,
and two windows render one stream. On a real two-screen Chrome it found one
popup per click even with the Window Management permission, Keyboard Lock
catching Alt+Tab and Super per fullscreen window, and ~15 fps per window with
two fullscreen windows on two screens under X11 (a presentation cost, not the
design’s). The user then chose the simplest form: “Open in separate window”,
one click per window, fullscreen its own second click, no placement or
permission. Built: useSeparateWindows and <DesktopView> using its own
window (docs/22); a browser
test opens a window, streams, clicks into the robot from it, goes fullscreen
on its button and closes with the dashboard; verified by the user on two
screens with the Mini PC on demo.fjarr.io, and in Firefox. 3.6 is done.
3.7, the X11 kiosk (2026-10-05). Backend A runs in the agent’s own
process (docs/23), with
robot-sim in fixture mode as its CI fixture (make x11-e2e: see, drive,
hot-plug, clipboard, cursor). The fixture found four things: Xvfb’s root
cannot grow, so it starts 3840 wide and carves monitors out of it; it sends
no RandR event for those monitors, so the module re-reads them every 500 ms
(a 2 s timer missed the 2 s hot-plug budget); it resets when its last client
leaves, which dropped the grant (-noreset); and ximagesrc needs the X
server’s IPC namespace or captures stale frames. The package
fjarr-desktop-x11 carries fjarr-x11-session (the grant and the output
layout, from the kiosk session’s autostart entry), and setup desktop --x11
links that entry and points the agent at the display, leaving the display
manager to the operator; the install test runs both and the undo. The
clipboard (3.7c) uses X selections with INCR above 256 KiB; its first runs
stalled transfers until their timeout, because events that a round trip
outside the event handler had read sat in Xlib’s queue, where a descriptor
watch never sees them; the module’s event source now checks that queue too.
The fixture also stages an owner that never answers and text over the limit.
On the mini-PC (2026-10-05) with LightDM’s automatic login into
Openbox, from the packages and setup desktop --x11: the three Dells on the
DisplayPort chain and the ghost laid out left to right, every monitor
streaming with the same wire id it has under GNOME, input with åäö, the
local cursor, the clipboard with text and images both ways, and an unplug
and re-plug laid out again. It found five things, all fixed: an unplugged
Dell reported as a nameless monitor for ~3 s (its output kept its CRTC
without its EDID); Xorg making the ghost primary (fjarr-x11-session now
applies docs/26’s rule); the ghost, moved into the gap, restarting its
capture and re-offering 1 ms after the previous answer (a same-size move now
keeps its capture); the dashboard then answering no offer again until it was
reloaded (one stuck offer held every later one; each new peer starts fresh);
and the hosted demo’s operators dropped every 2 min 5 s by Cloudflare’s idle
limit on a quiet signaling socket (the server pings every 30 s). The X
server itself stalls ~4 s while the chain re-probes, a driver limit as in
ADR-0006. Restoring GNOME found three setup bugs, fixed the same evening.
3.7 is done.
3.8, a desktop with the agent in a container (2026-10-06). Erik chose a
host package: the desktop’s host side moved into fjarr-desktop-session
(helper, user unit, group, GDM watchdog; no agent), the setup tool into
fjarr-setup, and fjarr-desktop-wayland keeps the module and recommends
the session package, so a robot’s apt install is unchanged. On the host,
fjarr-setup setup desktop --container does the host’s part and prints the
desktop uid and group gid; in the container, setup desktop --helper-uid --helper-gid writes them (docs/26).
The image has a desktop-wayland stage. make compose-gate now ends with a
desktop beside the reference file’s agent: the host’s helper connects through
the bind-mounted socket and opsim’s desktop-see passes. The gate found the
module needed pipewire-bin’s client.conf, a package that brings GPL
readline: the module now ships its own client configuration over
libpipewire-0.3-modules. A second install test proves a container host
(the session package alone). An X11 kiosk with the agent in a container is
specified and not built. 3.8 is done; every M3 slice is.
The nightly red, explained (2026-10-17). The unattended-access nightly
on the mini-PC was red every night from 10-08. Two harness bugs: opsim ran
the X11-only mode change without a plug and failed by construction, and
input-to-photon counted presses from a frozen picture as 0 ms samples, a
false pass on 10-17 (both fixed in 5b864a3). Ubuntu’s upgrade wizard opened
on every boot (8735c52), verify judged a late prepare stale, and nothing
reported a red night (91a550d). Under them was a real defect, found on the
machine the same day: since the local cursor (232dbbf), mutter 50.1 scans a
fullscreen GPU window directly out and records nothing into the shared-memory
stream the agent reads, so the operator saw a frozen picture behind green
counters. Erik chose DMA-BUF capture without a copy into VA
(ADR-0041); mutter fixed the shared-memory
side in 50.5, which Ubuntu 26.04 does not have
(open question #49). The M3 gate waits for that
change and a green night with the new scanout check
(docs/15). The change is built
(2026-10-17): the import list, module E’s DMA-BUF reader with SHM as the
fallback, the device-memory head with one importing vapostproc, the frame
counts in /sources, the hidden pointer, and fjarr-opsim desktop-scanout in
the unattended verify. CI covers the SHM path and the unit tests, a VA
workstation the DMA-BUF chain into vah264enc; mutter’s DMA-BUF stream inside
the agent is first exercised on the mini-PC.
Verified on the mini-PC (2026-10-18, by hand, build c5dba78). The agent
negotiated DMA-BUF AR24:0x0200000000000901, into the encoder without a copy
through the device-memory head, and the unattended-access test passed whole
for the first time since 10-03: desktop-scanout followed 10 of 10 changes,
9 of them under direct scanout; a window hiding the pointer gave a hidden
shape; input-to-photon took 10 real samples (p50 200, p95 202 ms on the robot,
not a number of record). From the dev machine over the LAN, the same build:
p50 49 / p95 73 ms over 50 presses (docs/16,
M3 gate review). The night’s own prepare had failed on a truncated
artifact download over the machine’s Wi-Fi (fixed in 91455a3), and
NetworkManager on the mini-PC now ignores the ZED Box’s USB network, whose
“Connection failed” banners stopped scanout.
M3.5 — Hear the robot {#m35}
Section titled “M3.5 — Hear the robot {#m35}”fjarr.audio (robot microphone downlink, push-to-talk uplink through the
pre-allocated transceiver, a hard volume cap in the agent’s config, uplink only
when the grant says talk, audited) and desktop audio on fjarr.desktop
(the sink monitor, measured unattended in the M2 spikes). The web side exists
already: usePushToTalk, useAudioTrack, <AudioSink>, the audited
audio-uplink event and the answer direction taken from the offer. What is left
is the agent: the capability, the Opus pipeline, the recvonly transceiver and
the output. Open questions #3 and #17 were settled on 2026-10-11 (Erik):
hearing the robot’s microphones needs params.listen in the grant and
talking params.talk; push-to-talk is the default and open mic the
operator’s choice, under the same grant.
Gate: the fjarr.audio acceptance criteria (docs/06).
M3.5 goes before M4 (Erik, 2026-10-11). Slices, planned the same day:
| Slice | What | Done when |
|---|---|---|
| 3.5.1 Hear the robot | fjarr.audio’s downlink: a configured microphone (an ALSA device, docs/23; a test tone in CI), Opus, an audio track offered on demand, only to a session granted listen | the dashboard plays the fixture’s sound within the docs/16 budget, in CI; a session without listen is offered none |
| 3.5.2 Desktop audio | the robot desktop’s sound as a track on fjarr.desktop (docs/23): on GNOME the session helper hands the agent a PipeWire connection narrowed to the default output, as it hands the screen, and the capture runs only while someone listens; on X11 the kiosk’s session program hands it over the same way (ADR-0034) | a sound played on the fixture’s desktop is heard in the dashboard; a change of default output keeps it playing (the stop with the sharing: 3.5.4) |
| 3.5.3 Talk to the robot | the agent’s pre-allocated recvonly audio transceiver, decoded and played on a configured output with a hard volume cap; talk enforced by the agent; talk-start/talk-stop gate it (docs/08, docs/23) | push-to-talk reaches the fixture’s sink; releasing it stops the audio within 200 ms; audit events recorded; a session without talk gets no uplink |
| 3.5.4 On the mini-PC (with Erik) | the webcam’s microphone, a speaker for push-to-talk, and the robot desktop’s sound (the YouTube test of 2026-10-04) | each heard or played on the real machine; the desktop’s sound stops with GNOME’s stop-sharing and returns on resume |
3.5.1, hearing the robot (2026-10-11). The agent’s side is e419f71: a
microphone in fjarr.audio’s config (alsa or tone), Opus at 32 kbps in
20 ms frames with in-band FEC, one tier, and the tracks added in
session_attached only for a session whose grant says listen, so the
filter costs one check per session. opsim’s audio scenario decodes the tone
(RMS above silence) and audio-unlistened proves no track is offered. The
demo dashboard’s Microphones panel (53ed27d) mounts an <AudioSink> while
Listen is ticked; its lab browser test requires the audio clock to advance and
the track to stop streaming when unticked. Not measured: audio latency, as
docs/16 has no audio budget yet; 3.5.3’s
push-to-talk sets the first one (200 ms to silence). 3.5.1 is done.
3.5.2, the desktop’s sound (2026-10-12). On GNOME the session helper
watches the default output and hands the agent a PipeWire connection
narrowed to it (a4a70c7 spec, 33fd7d5); measured first in
spikes/desktop-audio, the connection hears the speakers and never the
microphone, and fixture-helper-check keeps proving it. On an X11 kiosk the
session program does the same, as the kiosk’s own account
(ADR-0034, Erik’s choice
over a shared PulseAudio socket; f63855b), and setup desktop --x11 records
that account. The capture runs only while the producer’s pipeline runs. opsim’s
desktop-audio passes on both fixtures: silence, then the tone about 300 ms
after it starts, still heard across a change of output, no capture before
enable and none after the grace. The dashboard’s Sound toggle plays it in the
lab browser. Building the scenario found two defects in the tests themselves (a
release check that could pass on a capture not yet open, and a helper check
that could close the descriptor it tested), both fixed. The stop with GNOME’s
stop-sharing cannot be staged in the fixture (no GNOME Shell) and moves to
3.5.4 on the mini-PC. The mini-PC’s nightly then found what the clean-install
tests could not: moving the PipeWire client configuration into fjarr-agent
broke the upgrade over an earlier build of fjarr-desktop-wayland, which
still owned the file; fjarr-agent now Replaces it, and the upgrade was
replayed in a container. 3.5.2 is done.
3.5.3, talking to the robot (2026-10-12). The agent offers a session
granted talk one recvonly audio m-section (e43f7df spec; 756b2e3 agent,
968d68c web). The core decodes it per session through a new
SessionContext::add_inbound_audio, and fjarr.audio plays one session at
a time through a speaker pipeline that exists only between talk-start and
talk-stop, under a fixed volume cap, sending the talker talk-level; every
start and stop is an audited audio-uplink session event. opsim’s
audio-talk against the demo robot: nothing of the audio sent before
talk-start is played, 50 frames a second after it, a full-scale source
peaks at −6.0 dBFS under the 0.5 cap, and the release is answered in about
2 ms with nothing played after it; audio-untalked gets no uplink and
capability-denied. The dashboard’s “Hold to talk” plays the lab browser’s
fake microphone at the robot. Building it found that an answer naming no SSRC
for its uplink lets webrtcbin route the stream to the BUNDLE’s first
transceiver and stall the session; an unmatched stream is now sunk. Not
measured: mouth-to-ear latency (docs/16, < 250 ms) and echo cancellation
with a real speaker and microphone, both for 3.5.4. 3.5.3 is done.
3.5.4 on the mini-PC, first session (2026-10-12, with Erik), paused.
- The desktop’s sound: heard, but muffled and speeding up and slowing
down. Fixed (b46f26e): opusenc carried each PipeWire buffer’s arrival time
into the RTP clock (about 60 of 150 packets uneven in the fixture, none
after
perfect-timestamp), and voice tuning at 32 kbps suits no music, so a source now declaresAudioContent::Music(64 kbps). Not yet re-heard. - The webcam’s microphone: not working, open. Two problems found.
(1) The desktop session’s PipeWire holds the ALSA capture device whenever
a desktop application records (GNOME Settings’ input meter did), and the
agent then gets “busy”: the 3.5.1 design (ALSA directly) and a desktop on
the same machine compete for one device, which needs a decision (the
microphone through the session helper as the desktop’s sound is, or a
WirePlumber rule reserving it for the agent). (2) With the device free,
the agent’s
alsasrcstill fails with “No such file or directory” onplughw:CARD=Webcam,DEV=0, whilearecordwith the same device as the agent’s account works: something in the agent’s process (environment, ALSA configuration, the card having moved from 1 to 2 at a reboot) to debug next. - Stop sharing and talking (no speaker attached: the default output is
auto_null) are not tested yet.
Vendor cameras and the design-partner demo {#vendor-cameras}
Section titled “Vendor cameras and the design-partner demo {#vendor-cameras}”The first vendor camera packages (ADR-0020),
delivered through the repository and the driver catalog, and the first
design-partner demo (docs/03 GTM). Scheduled when the design partner’s
hardware is known, rather than holding the desktop gate. The lab’s planned
Raspberry Pi 4/5 (Pi cameras) and Jetson (ZED) machines are the likely first
targets (docs/12).
Gate: the design partner installs from the repository with setup, adds
their camera with drivers install, and writes no config by hand.
M4 — Files, telemetry, logs, sensors
Section titled “M4 — Files, telemetry, logs, sensors”fjarr.files (chunked/backpressure/resume — greenfield); fjarr.telemetry
with the ROS 2 TelemetrySource adapter and store-and-forward;
fjarr.logs peer mode (live tailing with server-side filters and
backpressure — the support tool design partners will ask for); the
sensor-transport spike on a simulated depth/lidar source that settles
depth-as-video vs stream class per the docs/06 matrix
(the ADR-0018 chunker/reassembler is implemented only when a capability
actually picks the stream class — docs/13 KISS); the cheap conventions:
camera/snapshot, telemetry/alert, files/list, haptic events.
Gate: 1 GB resume-after-kill with verified hash; bulk/interactive
isolation within budget; telemetry replay after outage; logs tail from
robot-sim with a filter; spike report attached to open question #16.
fjarr.files is designed for the drive of
ADR-0029 from the start: listings
with metadata, stat, byte-range reads, and mkdir, delete and rename. After
M4’s gate, fjarr-connect serves the robot’s allowed paths as a WebDAV
folder. Its gate: Nautilus, Finder and Explorer mount it, list a directory,
open a file and save it back, and are refused outside the allow-lists.
Acting as another account (open question #42):
the terminal and the files act as the agent’s own unprivileged account today,
which can barely do anything on a robot (mini-PC, 2026-10-04). M4 starts with
a spike on the mini-PC and an ADR: a shell and file access as a configured
account (robot) without making the agent privileged. The leading option is
systemd’s own path (run0 --user= or machinectl shell) allowed by a polkit
rule the package ships, limited to the agent’s account and the configured
target; the spike settles whether polkit can restrict the target account.
Its gate: the dashboard’s terminal opens a login shell as robot on the
mini-PC, fjarr.files reads and writes as robot, and the agent still runs
as fjarr with no new privileges of its own.
M4.5 — Direct access: the network tunnel
Section titled “M4.5 — Direct access: the network tunnel”fjarr.net per docs/27: the persistent tunnel
interface created by fjarr-agent setup, the packet pump on
fjarr:stream:fjarr.net, derived addressing, the two policy rules that make
isolation structural, and the audit trail. Plus fjarr-connect
(ADR-0024), Fjarr’s first non-browser
operator and its first macOS artifact, shipped in fjarr-tools. Plus the
documented Cyclone DDS configuration and setup’s offer to write it. Plus
the discovery half, without which the CLI is a debugging tool rather than a
product: the optional operator API on the
customer’s backend, the FjarrCliLogin handoff component in @fjarr/react,
and both implemented in demo-backend and demo-dashboard as the
reference.
Its only hard dependency is the M2.5 packaging and installer, because the interface must be created before the robot’s software starts — it may be pulled ahead of M3 or M4 if a design partner needs field debugging sooner, and the spike already de-risked the unknowns.
Pulled forward (planned 2026-09-25, re-split 2026-09-26). The installer is
the only part that needs packaging; in the lab, compose creates the device the
same way the installer will, so everything else is buildable and testable
now. The plan was two slices; after 4.5a landed, what remained was too large
for one — six gate items, a third WebRTC implementation, and a lab that cannot
yet run ssh, scp or ros2 at all. Split into five, each sized for one
session and ordered so every slice is provable when it lands:
-
4.5a — the agent side. Done 2026-09-25. The core could not route inbound stream-class data at all: it assumed every binary channel was a bulk one, so a tunnel packet was cut at the wrong offset, matched no capability, and was dropped and counted. That came first — one parse of the label for both classes, a sender for the stream class, and registration refusing a
framedstream channel so ADR-0018’s chunker stays unbuilt until something needs it. Thenfjarr.net: attaching to the persistent device with noCAP_NET_ADMIN, the packet pump, derived addressing, the two policy rules, tail-drop, andlink-stats— which needed the extension API’s second amendment,SessionContext::every, because the event is specified per second on an idle link too.make tun-upplays the installer in the lab, andfjarr-opsim --scenario tunnelcarries an HTTP request to the robot’s own introspection endpoint over the link. Gate met:GET http://<robot tunnel address>:7381/stats -> 200over a real data channel; packets addressed into the robot’s LAN refused and counted; tail-drop unit-tested against a refusing channel, including that what was dropped is gone rather than queued. -
4.5b — gates worth trusting. Not tunnel work: CI’s lab job is red on
mainand two suites are additionally red on the development machine, and the next slices measure media. In CI,opsim toggleintermittently failstoggle-keyframe: 19 of 20 enables produced a keyframe and a decoded frame, worst 2912 ms on 2e5759f, while the same assertion passed on e84ef64. One enable in twenty missing its budget is a tail-latency question, not a broken feature, so the slice has to decide whether the budget or the assertion is wrong rather than hunt for a bug. Reading that job’s log also turned up a GLib critical on every session close that had opened a pty — an fd watch released twice, fixed in cd81195 with the regression test the seam never had; it was a passenger in a job failing for another reason, which is what a red gate costs.opsim hotplugfails deterministically when it runs aftersmokeinopsim-alland passes standalone — determinism that specific is usually findable.ratecontrol.spec.tsreports 1.1 Mbps on a clean link where it asserts 3, with VA-API active and no qdisc left behind. Both reproduce with 4.5a’s changes stashed, so neither is the tunnel’s. All three come first because question #23 — whether ascpstarves the camera — is answered by reading local media numbers, and numbers from a suite that fails for unknown reasons answer nothing. Gate: both green locally, or the cause understood and the assertion made honest about it — the precedent isopsim netem wifi-ok, which records loss rather than asserting it because the receiver over-reports under jitter. And a standing question for the slice: a critical in a robot’s log should fail the run that produced it, not wait for someone to read it.In progress 2026-09-26. The local rows are answered and neither was the agent’s.
hotplugfails only under CPU load — reproduced with 20 spinners and the identical signature, while smoke-then-hotplug is green on an idle machine, so “deterministic after smoke” was the load of my own builds; the frame assertions now report the robot’s view of the tracks on failure (why_no_frames), which is the evidence that was missing. The three-viewer rate-control test had three defects, all in the rig: it impaired the direct path before the viewer connected, so ICE moved to the relay and the session was never congested (14 MB to coturn against 3 packets impaired); it compared steady state against the encoder’s opening overshoot, calling convergence to the 4 Mbps target a 15–20 % regression; and its demotion budget came from a design estimate of 8–9 s against a measured 11.9–20.4 s. Fixed by choosing the impaired path (relay + netem toward coturn), judging the clean viewers against the configured target, and setting budgets from measurement: 5 of 5 runs green, and the fullloopback+stacksuite 52/52. Measurements folded into docs/16 and the harness rule into docs/25. CI then failed on a third thing, which is the slice’s own subject:opsim-all’s retry for a post-verdict SIGSEGV in the simulator’s teardown had never once fired, because a nested$(MAKE)reports its own exit code 2 and the guard tested for 139 — measured directly, 139 fromdocker compose execagainst 2 through make. The loop now runs the command itself, and it retries only when the scenario printed a clean verdict before dying; a crash before the verdict is not retried.The standing question is answered:
make agent-log-gatefails a run whose robot logged a GLib or GStreamer critical, andagent-log-gate-selftestproves it can fire — validated the hard way by re-introducing the cd81195 double release, watching the terminal suite pass 4 of 4, and watching the gate refuse the run with the three criticals CI had been carrying (docs/15).CI’s
toggle-keyframeis answered too, and it was not a defect: keyframe requests are throttled to one per second per producer tier and deferred inside that window (docs/23), whiletogglecycles faster than that by design, so an enable can wait out the throttle and then a GOP. Worst per run measured at 757-949 ms here against ~2.9 s on CI’s 2-core software encoder, which put one enable in twenty over the scenario’s 4 s patience. The patience now follows the mechanism, the assertion stays categorical, and the floor is recorded in docs/16. All three rows closed; every defect was in a gate or a rig, none in the agent. Split again 2026-09-26, before starting. What was one 4.5c carried the whole rig plus four gate items; reading the specs through, the rig andssh/scpare one provable increment and ROS 2 is another, with its own spec work (the Cyclone file is promised in docs/27 but has never been written down). Two slices, and the letters after them shift: -
4.5c — the robot’s services, and a link something else can use. The lab carries packets and nothing else: no
sshd, one robot, and a tunnel that onlyfjarr-opsim’s own assertions ever touch. None of this needsfjarr-connect— the simulator already pumps a tunnel, so the robot side can be proven before the Rust client exists, as 4.5a proved the capability before the client. The services the tunnel is for live in sidecars sharing the robot’s network namespace rather than in the agent’s image: that is what a real robot looks like from the far end of a link (sshd and ROS 2 are separate processes on the same network stack, not part offjarr-agent), and it keeps ROS 2 and an ssh daemon out of a container whose job is to be the agent. Plus a second robot with its own id, ready for 4.5e’s isolation regression. And the piece that turns a link into something usable:fjarr-opsim --scenario tunnel --exec <cmd>runs a command with the link up andFJARR_ADDRin its environment, which is a rehearsal offjarr-connect robot -- ssh robot@$FJARR_ADDR(docs/27). Gate:sshlogin and a hash-verified 1 GBscpover the link, and question #23 measured — a camera streaming while thescpruns, which is the first time anything has pushed enough through a link to disturb the video beside it.Done 2026-09-26, with one item blocked.
sshreaches a shell on the robot in ~130 ms, reliably; a 1 GiBscptransfers and verifies its sha256 end to end at 338 Mbps alone and 190-236 Mbps beside a streaming camera. #23 is closed: the camera is unaffected by a saturating transfer (31-33 fps, no lost frames, longest gap 55-70 ms against a 48-51 ms baseline), and the transfer pays about 35 % of its throughput, so no separate PeerConnection for bulk is needed on these grounds. What is blocked: that transfer stalls in roughly 40 % of attempts, at the onset of the flow, with every drop counter at zero on both ends — new question #28, with video, transfer size, the MTU check, the channel watermark and the operator’s device writes all ruled out. CI asserts the shell and records the transfer, because a gate that is red two runs in five teaches people to ignore red. The M4.5 gate cannot claim a reliablescpuntil #28 is settled, and settling it needs SCTP-level evidence rather than more black-box runs. -
4.5d — ROS 2 over the link. ROS 2 as sidecars on each end’s namespace,
ros2 topic listagainst the robot with Fast DDS unconfigured, the Cyclone DDS file written into docs/27 for the first time (the spike measured it; the spec only promises it) andfjarr-agent net setup’s offer to write it, and the three ordering facts of docs/27 as a regression — a participant created while the agent is detached does not advertise the tunnel address, one created while attached does, and it keeps advertising across an agent restart. Those three are the measured facts the whole lifecycle rests on, and nothing has ever re-checked them.In progress 2026-09-26.
ros2 topic listandros2 topic echoboth cross the link (make tunnel-ros), from a ROS 2 sidecar on each end’s namespace, withdocker/lab/dds-isolate.shremoving every direct path first — without that the two ends discover each other over the lab’s own bridge and the test proves nothing, which is the trap the spike fell into. Getting there needed ADR-0026: docs/27’s isolation rule said a packet’s destination must be this end’s own tunnel address, which a multicast destination never is, so the tunnel dropped every DDS discovery announcement and ROS 2 over the link could not work as specified. The rule now admits multicast on the strength of its source, the source rule carries the isolation guarantee alone, and docs/08, docs/10 and docs/27 say so. Stock Fast DDS then needs no configuration, exactly as claimed. The three ordering facts are now a regression (make tunnel-ros-ordering): a participant created while the agent is attached reaches the peer, one created while it is detached never does, and a working one survives an agent restart. The lab agent runs under a supervisor so that third fact is testable at all — restarting the container would take the interface with it, which a real robot does not do.The Cyclone file now exists (
docker/lab/cyclonedds-tunnel.sh, and the exact XML is in docs/27, which had promised it since the spike) and its effect is measured: stock Cyclone leavesros2 topic listhanging with no output, the file fixes that, and discovery across the link still does not complete — 78 discovery packets reach the robot and its Cyclone does not answer usefully. Cyclone is therefore documented as unverified over a real link rather than claimed as supported on the spike’s word, and Fast DDS is the verified path.fjarr-agent net setupis deliberately not built yet: its job here is to offer to install that file, and offering an unverified one is worse than offering nothing. Both now sit in question #29 rather than holding the slice open: what is left is Cyclone’s own locator handling on a point-to-point interface, which a design partner who needs Cyclone is the right trigger to spend more on. 4.5d is otherwise done 2026-09-26. -
4.5e —
fjarr-protocolandfjarr-connect(ADR-0024). The shared signaling types move into their own crate here, when a second consumer exists: the protocol module is 217 lines needing serde and one UUIDv7 call, while the signaling crate carries axum, hyper and the HMAC stack that an operator CLI has no business linking. (This plan said “only serde”; extracting it turned up theuuidcall, which is equally runtime-free.) Then the client itself — signaling, one webrtc-rs peer connection, one stream channel, no media at all, the TUN/utun device with a /32 route per attached robot,--grant <jwt>, a link that lives in the terminal that started it, and-- <command>withFJARR_ADDRin its environment. Several robots at once is what the single-interface design exists for, so address collision detection and the two-robot isolation regression (docs/15 safety class, never removed) land with it rather than before. Gate: the 4.5c end-to-end repeated throughfjarr-connectinstead offjarr-opsim; two robots attached at once provably unable to reach each other in either direction; a colliding pair refused by name with theaddressline that fixes it.Started 2026-09-26.
fjarr-protocolis extracted, andfjarr-connectexists as far as signaling: against the real server it takes a--grant, is issued a session with TURN credentials, and reads the robot’s offer — and an offline robot fails with the server’s ownrobot-offlinerather than a timeout, which is what docs/27 asks for.--dry-runis the only mode it has, and it says so rather than pretending: the peer connection, the TUN device and-- <command>are next.docker/lab/mint-grant.shmints a lab grant so nothing needs Node to get one.The link carries IP traffic, 2026-09-27.
fjarr-connect <robot> -- <command>answers the offer, opens both channels, asksfjarr.netfor the link, attaches the operator’s interface with a /32 route for that robot, pumps packets under the same two policy rules the agent applies, and tears the route down when the command exits:ssh, an HTTP request and a hash-verified 1 GiBscpall run over it. Two defects in already-reviewed code came out of it, both in the slice-4.5e review: the client’s ICE had to trickle in both directions before any link came up at all, and the agent had been aborting inside glibc’s allocator in every saturating transfer because the packet pump’s fd-watch callback fell off the end of a-> boolfunction — one cause of #28, fixed, measured A/B, and now a build error rather than a warning nobody read.Gate met 2026-09-27, with macOS honest rather than claimed.
make tunnel-isolationis the docs/15 safety-class regression: two robots on one interface, each reachable from the operator, neither reachable from the other with a route forced into its tunnel and verified there — written the obvious way it had passed while measuring the lab’s own bridge, which is slice 4.5d’s mistake repeated.make tunnel-collisionpins the second robot to the first’s address and the operator refuses the pair by name. The client moves 1 GiB at 246-253 Mbps against opsim’s 264. Two more spec promises turned out to be unkeepable and were fixed rather than reworded:setcapcould never have worked while the client shelled out toip(a file capability is not inherited by a child), so the interface, its address and its routes are now set over netlink in-process; and “several robots at once” needed--grantper robot, since a grant names one robot. macOS is written, type-checked foraarch64-apple-darwinin CI, and recorded in docs/04 as never having run on macOS hardware. Follow-up, 2026-09-27, before 4.5f: warnings are errors on every shipped target; the ROS ordering regression runs nightly;link-statscarriesabandonedfrom a log hook on GStreamer’ssctpassociationcategory; and #28 is narrowed, not closed —fjarr-connectmoves 1 GiB 5 of 5 beside a 30 fps viewer where opsim is red about half the time, so what wedges is a usrsctp-to-usrsctp association and the failing operator is a lab tool. Decision: 4.5f starts now; reading both ends of a wedged association — which says whether it is opsim’s bug or the robot’s — comes after it, and the M4.5 gate’sscpclaim is made throughfjarr-connect, the product, and says so. The slice-4.5e review carries the three lab fixtures that had to be made to fail first. -
4.5f — discovery and login. Without this the CLI is a debugging tool rather than a product: the optional operator API on the customer’s backend, the
FjarrCliLoginhandoff component in@fjarr/react(docs/21), both implemented indemo-backendanddemo-dashboardas the reference, and the CLI’s ownlogin,list, picker,--ssh-configand the short-code path for a host with no browser. Gate:fjarr-connect loginthrough the demo dashboard, then a list, a pick and a connect with nobody typing a robot id; the same on a host with no browser; every open and close in the audit log assession.started/session.endedwithfjarr.netamong the capabilities.Gate met 2026-09-27, for the code shape end to end and the loopback shape in halves (docs/15). The code path needed a spec first — three operator endpoints docs/09 did not have — and the slice-4.5f review records what the rest found: a login URL derived from the wrong host, two component tests passing on a matcher that did not exist, and
session.endednever naming its capabilities.
macOS is ADR-0024’s committed second platform and there is no macOS runner, so 4.5e ships it cross-compiled and hand-checked, with the matrix honest about that until M5’s packaging work says otherwise.
Gate: docs/06 fjarr.net criteria —
ssh and a hash-verified 1 GB scp to a robot behind carrier NAT; ros2 topic list against it with Fast DDS unconfigured and with the documented Cyclone
file (amended out as #29 at the gate, and met the same
day once the check stopped racing the ROS 2 daemon); fjarr-connect login through the demo dashboard followed by a
list, a pick and a connect without anyone typing a robot id, and the same on
a host with no browser; two robots attached at once provably unable to reach
each other; the agent upgraded without restarting the robot’s ROS stack; every
open and close in the audit log.
Met 2026-09-27 — make m45-gate, every claim through fjarr-connect
relay-only with the direct path removed; the evidence, criterion by criterion,
is the M4.5 gate review. Carried out of the
milestone: #28 for the lab’s usrsctp-to-usrsctp operator,
and #29 for Cyclone — closed the same day.
Next for the native client (decided 2026-09-27): fjarr-connect shell, which
attaches the operator’s own terminal to the robot’s fjarr.terminal pty with no
tunnel and no privilege (docs/27). It is small:
the terminal protocol is unchanged, and it only needs raw mode, resize and a
grant that carries the terminal. Gate: an interactive shell, a resize seen
by the robot’s stty size, the shell’s exit status as the client’s, and the
local terminal restored after a killed link. Met 2026-09-28 (make connect-shell, from a binary with no file capability): all four, plus the
terminal restored after a normal exit and a SIGTERM, and a grant without the
terminal refused as capability-denied. The operator API gained an optional
capabilities narrowing so login’s grant carries the terminal alone
(docs/09). macOS and Windows are type-checked,
not run (docs/04).
M5 — Hardening + Fjarr Cloud alpha
Section titled “M5 — Hardening + Fjarr Cloud alpha”Device enrollment/identity, multi-tenancy, ephemeral TURN at scale plus
TURNS/TCP-443 fallback, usage metering, the first OpenAPI-generated SDK
(TS), multi-operator presence (session-peers from the ownership
leases), access governance for the tunnel (time-boxed and
approval-gated net grants, per-user policy, audit export — the paid half
of docs/03), Cloud alpha
implementing the same ADR-0015 contract as the sidecar.
Gate: a design partner integrates against Cloud using only public docs;
security review of docs/10 items; metering visible in webhooks; two
operators on one robot see each other and hand over control.
M6 — Extension API stabilization
Section titled “M6 — Extension API stabilization”Third-party plugin authoring docs + template repo; ABI/compat promises (docs/05); clipboard images/files; multi-monitor polish. Gate: an external developer (not us) builds a toy capability from docs alone.
M7 — Fleet observability (flagship paid tier)
Section titled “M7 — Fleet observability (flagship paid tier)”Runs after M8 (Erik, 2026-10-12): M8 reports each robot’s release version and update outcomes, and M7’s metrics and errors are keyed on that release version (ADR-0036).
Versions, host + user metrics, error rates; ingest + aggregation; fleet
views in @fjarr/react; alerting in Cloud; fjarr.logs backend mode
feeding the same store.
Gate: docs/06 observability criteria; 10-robot sim
fleet demo.
M8 — Fleet OTA
Section titled “M8 — Fleet OTA”Designed 2026-10-12 with Erik (docs/32, ADRs
0035,
0036 and
0037; ADR-0016 superseded).
M8 runs before M7 (Erik): it reports just enough of M7 itself, each
robot’s release version and its update outcomes, and M7’s statistics are
keyed on that version from the start. One signed release version pins the
whole robot; fjarr-update orchestrates fetch, readiness, prepare, install,
health, peripherals and commit; content travels as HTTPS chunks under a
budget per time interval; campaigns are promoted stage by stage by a person.
Each slice is usable on its own and tested on the lab’s hardware: a Stereolabs ZED Box Mini (Jetson Orin NX/Nano, NVMe) and the x86 mini-PC.
| Slice | What | Done when |
|---|---|---|
| 8.0 Hardware spikes | on the ZED Box Mini, a meta-tegra A/B image with the bootloader capsule and a jetpack-nixos A/B image; on x86, a minimal Ubuntu A/B image and boot counting with systemd-boot or GRUB; SWUpdate vs RAUC vs systemd-sysupdate per recipe; the chunking parameters and real delta sizes for typical updates (kernel, CUDA, an ML model), by chunks and by precomputed binary diffs (xdelta, bsdiff); the licences of Stereolabs’ board files (#45) | a power cut mid-install boots the old slot on each image; the measurements choose each recipe’s applier, the chunk parameters and the scope of 8.3b (recorded in docs/32) |
| 8.1 Release format | the manifest (components, versions, requires, rollback classes, hardware variants), TUF-style signing with company-held and Fjarr-managed keys, the release counter and downgrade authorization; fjarr bundle, check, push; the agent’s registration carries its release and version vector (docs/08) | fjarr bundle → push → verification on a robot succeeds; a flipped byte in a chunk or the manifest, an unknown or expired key, and a lower release without authorization are each refused with a named error; the dashboard shows each robot’s release and vector |
| 8.2 The updater | fjarr-update and its state machine, the run state on /data, run logs (steps, statuses, streamed output, the local journal and backfill after a reboot; the messages in docs/08), install from a local bundle or USB, the apt applier for today’s Ubuntu robots | a release installs on an existing Ubuntu robot and its run log reads step by step, live and after a reboot; a release from USB installs offline and its log uploads on reconnect |
| 8.3 Transport | HTTPS chunk fetch with resume, the budget per interval in the site’s time zone, the rate cap from the agent’s bandwidth estimate, space checks, cache cleanup | on a link shaped to 5 Mb/s with drops every few minutes, a 1 GB release arrives across 3 days within a 400 MB daily budget, while a 2 Mb/s video session keeps ≥ 90 % of its bitrate with no freeze over 1 s |
| 8.3b Precomputed deltas | for app components (container images, binaries, packages), the server computes file-by-file diffs (bsdiff or zstd --patch-from per changed file, chosen per file) from each source release still in the fleet; the robot fetches the diffs matching its release and falls back to chunks per file; the file list travels as a diff against the robot’s own; download prediction counts diffs. OS slots and models stay with chunks (8.0’s measurements) | an application container’s patch release fetches about what 8.0 measured (1.5 MB instead of 21 MB by docker pull for the Caddy update), the robot reconstructs the identical content, and the bytes fetched match the prediction |
| 8.4 Yocto and NixOS on the ZED Box Mini | one ZED Box Mini board package; meta-fjarr and the jetpack-nixos flake built on it, side by side; their appliers; health (reachability, probes, the confirmation window); fjarr provision | nightly in the lab, for both: an update, a failed health check that rolls back, a power cut mid-write that recovers; a trial kernel that hangs before userspace recovers without a person (Erik, 2026-10-17: Q8, #48); neither is done until both pass |
| 8.5a Ubuntu A/B on x86 | the mini-PC board package, the x86 layout, the Ubuntu A/B image and its applier: SWUpdate and EFI Boot Guard (ADR-0040), from 8.0’s spikes/ota-x86/image-ebg, with a trimmed SWUpdate build and the loader pinned until Ubuntu ships EBG 0.20 or later | the same nightly tests on the second mini-PC; the image carries a GNOME desktop that the M3 desktop tests pass on |
| 8.5b Yocto and NixOS on x86 | the Yocto layer and the NixOS flake on UEFI x86 | the same nightly tests for both |
| 8.6 Readiness | the local API (facts, holds, prepare actions, probes, on-site confirmation), the conditions and their reasons, the update hold on motion, not-ready-to-move, hook phases, /data snapshots, ceilings and deadlines | an update waits for the charger and says why; prepare times out cleanly; take-control is refused during an install; a rollback restores migrated data |
| 8.7 Peripherals | the handler contract; u-blox configuration, a CAN flasher, and router configuration with a router-side revert, on the bench, MIT-licensed (ADR-0038) | one release updates the OS, the GNSS configuration and an MCU in order; a router configuration that cuts the uplink reverts by itself |
| 8.8 Campaigns (Cloud/paid) | stages and promotion (optionally spread across hardware variants), batching, abort; the fleet’s policy (auto, ask-end-user), overridable per campaign, and deadlines; the security channel; campaign YAML; the run grid; each stage against the rest of the fleet (success, rollbacks, reboots, disconnects); <UpdatePrompt> | one robot, then several, then the rest across simulated robots and the box; an abort midway; a robot’s owner schedules tonight in the demo dashboard |
| 8.9 Build caches (Cloud/paid, candidate 2026-10-17) | a Fjarr-hosted Yocto shared-state mirror and Nix binary cache for the board packages (ZED Box Mini, x86) and each pinned release, so a customer’s first OS build downloads the toolchain and the board’s kernel instead of compiling them (docs/03); after 8.4 and 8.5b | a fresh CI machine builds the ZED Box Mini image from the cache in minutes, compiling only the customer’s own layers; the recipes still build without it |
Preconditions, before 8.0. The ZED Box Mini and a second x86 mini-PC (Erik’s, 2026-10-12) join the lab as nightly runners, the Jetson with USB recovery access. The x86 recipes are reflashed nightly on the second mini-PC, so the first keeps its M3 desktop nightly untouched. Every A/B machine is powered through a network-controlled socket with a local API (a Shelly Gen3 plug, Plug S or Plug M, switched over HTTP from the runner, password-protected, its draw read back to confirm the cut) and set in its firmware to power on when power returns, so power cuts are tested unattended.
8.0 status (2026-10-17). Started on x86, off the machine: the Ubuntu A/B
test image, its applier, the rescue system and the hands-off reflash pass every
case in QEMU except a real power cut, and two builds of one release are
byte-identical (docs/32). The first
week-apart updates are measured (docs/32): 64
KiB chunks, and no case yet for precomputed diffs on Ubuntu. The NUC (the
second x86 machine, 192.168.10.177) has its Shelly plug: a power cut brings it
back in 33 s, the rescue UKI boots once by BootNext and returns (three of
three), its firmware watchdog resets a hung kernel by itself (53 s), and the
rescue’s dead-man returns it unclaimed. With Erik’s go it was rewritten to the
A/B layout over the network, and updates, rollback and a failed health check
work on it as in QEMU. Sixty power cuts during the install, after set-next
and during the trial boot all came back by themselves on the expected slot
(2026-10-15): the gate is met for the Ubuntu A/B image on x86
(docs/32), and the ESP is now mounted only on
demand and the rewrite runs eight times faster. The applier is chosen:
systemd-sysupdate with systemd-boot, RAUC with GRUB compared on paper
(ADR-0039). Container,
CUDA and model updates are measured with file-by-file diffs
(docs/32): 64 KiB chunks hold, and 8.3b is
scoped to file-by-file diffs for app components. SWUpdate with EFI Boot Guard was then built and
tested the same way (docs/32): one try instead of three, but
the only design that recovers by itself from a kernel that hangs early. Erik chose it for one
engine across the Jetson and x86 recipes
(ADR-0040, superseding ADR-0039).
The Jetson half, on the ZED Box Mini, started on 2026-10-17 (docs/32). Erik accepted decisions Q1–Q11 that day: L4T 36.5.2 for both Jetson recipes, Yocto first, the factory image restorable, early-hang protection a gate for 8.4 (#48), and a relay or a person on site for the steps that write the bootloader flash. Phase A, off the box, has started: A1 has read both BSPs and the A/B layout, and Stereolabs’ bootloader changes carry over to 36.5.2 unchanged. The read-only checks that open phase B come next; flashing waits for a USB 2.0 hub, which Erik is getting. #45 is read: Stereolabs’ driver package is fetched at build time, pinned to 1.4.4 for 36.5.2; the carrier’s bootloader and device-tree changes are Fjarr’s own; and Erik asked Stereolabs about the rest on 2026-10-17, including the modules’ source the cameras need on Fjarr’s kernels (#47).
Gate: docs/06 OTA criteria. On every recipe (Yocto and NixOS on the ZED Box Mini; Yocto, NixOS and Ubuntu A/B on the second x86 mini-PC): a power cut mid-write boots the old slot, and a failed health check rolls back and shows in the run log. Once, across the fleet: a staged campaign with an abort, a multi-day budgeted download, and an owner scheduling an update.
Later: depot chunk sharing, the Syslogic AGX Orin board package, statistical release comparison (M7), more peripheral handlers.
Explicitly later
Section titled “Explicitly later”Session recording/replay, mobile operator apps, clipboard sync on focus (#41) — open questions.