# Jetstream *Design and architecture decision, 2026-10-11 (oxygenflow#38). Builds on spec §11, runner capabilities and placement (#36). Crate and CLI: `jet`.* **Flow** says what to run: a pipeline of hermetic jobs, each in a sandbox, with its outputs, reports and signed receipt. **Jet** carries each job to where it should run: a fleet of runners on your own machines, on Kubernetes or in the cloud, that measure themselves, explain every choice they make, and fetch what they need on their own. ## 1. The decision **Context.** Today OxygenHub holds the job queue and talks to runners directly (`/api/v1/flow/claim`, logs, outputs, finish). Runners are configured by hand: images are copied onto each machine, labels steer jobs, and a job that cannot run anywhere simply waits. Every self-hosted CI system has the same shape and the same problems (§2), and none of it is specific to Oxygen. **Decision.** The fleet becomes a service of its own, **Jetstream**: - Jetstream owns the queue, the runners and their measured facts, placement and its explanations, pools (hosts, Kubernetes, cloud providers) and images. It knows nothing about repositories. - The **forge** owns pipelines, runs, checks, receipts, artifacts, reports and the UI. It hands Jetstream jobs and receives their events. - **OxygenHub embeds Jetstream** in its own process by default, so nothing changes for anyone who never configures it, and can instead point at a standalone `jet server` shared by several forges. - **Sources are pluggable**: an Oxygen repository is mounted lazily through flow-casfs; a Git repository is checked out. - **`flow run`** runs a pipeline on your own machine through an in-process Jetstream with one local runner: the same code path as CI. **Consequences.** - Jetstream can be reused by others, and could later run jobs for forges that are not OxygenHub (§12) without a rewrite. - One more boundary to keep stable: the forge contract (§4) is versioned. - Oxygen's advantage becomes visible: the same fleet runs both kinds of source, and an Oxygen job starts without a checkout (§5). ```mermaid flowchart LR subgraph Forges OH["OxygenHub
pipelines · runs · checks
receipts · reports"] OTHER["another forge
(an adapter, later)"] end subgraph JS["Jetstream (jet)"] Q["queue"] P["placement
+ why"] F["runner facts"] I["images"] D["demand"] end subgraph Pools H["hosts
earth-1 · plex-1 · narvi-1"] K["Kubernetes
controller, pod per job"] C["cloud
GCP · AWS · Azure"] end OH -- "jobs" --> Q OTHER -.->|jobs| Q Q -- "events: logs, outputs,
finish, receipt" --> OH Q --> P F --> P H -- "claim + facts" --> P K -- "claim + facts" --> P C -- "claim + facts" --> P I -- "by hash" --> H I -- "by hash" --> K I -- "by hash" --> C D -- "how many, of what" --> K D -- "how many, of what" --> C ``` ## 2. What it fixes Research into self-hosted runners (GitHub Actions, GitLab, Buildkite, Jenkins, Forgejo; 2023–2026) found the same complaints everywhere. Most of them are fleet problems, not pipeline problems. | What goes wrong elsewhere | Jetstream | |---|---| | Persistent runners leak state; fork PRs and compromised actions reach other jobs' secrets | A fresh microVM per job (or the syscall tier); nothing survives a job | | A job waits for hours, then fails, with no reason given | Every waiting job says why, in terms of facts (§7) | | Disk fills mid-job; an OOM kill shows up as a "lost runner" | Disk and memory checked on every claim against what the job needed last time; OOM reported as OOM | | Images copied to each machine by hand; slow pulls | Images served by hash, fetched by the runner on demand, chunked so a rebuild moves only what changed (§8) | | Exact-match labels that sprawl | Facts measured by the runner; labels only steer (§7) | | Lost runners and spot reclaims leave jobs hanging | Requeue on a lost runner or a reclaim notice (§11) | | Forced runner upgrades with a cut-off date | Runners update themselves between jobs; the contract is versioned | | Ephemeral runners start cold, 30–60 s and no caches | Firecracker boots in about a second; images and caches stay warm by hash | | Multi-arch builds through QEMU, 5–30× slower; no RISC-V runner at all | Native runners per architecture, RISC-V included (narvi) | | No queue, utilisation or cost figures | Queue time, utilisation and cost by job, pool and region, built in (§11) | ## 3. Responsibilities | | Jetstream | The forge (OxygenHub) | |---|---|---| | Pipeline from the repository | | ✔ | | Runs, jobs, `needs`, the run page | | ✔ | | Queue, placement, why a job waits | ✔ | shows it | | Runners, their facts and tokens | ✔ | shows them | | Pools: hosts, Kubernetes, cloud | ✔ | | | Images | ✔ (by hash) | | | Secrets and variables | carries them to the job | ✔ (stores them) | | Job identity (OIDC) | carries the token | ✔ (issues it) | | Logs | streams them | ✔ (keeps them) | | Outputs, reports, receipts | carries them | ✔ (keeps, checks, shows) | | Checks on a change | | ✔ | ## 4. The contracts Two contracts, both HTTPS and JSON, versioned from the start. **Forge ↔ Jetstream.** The forge submits a job and follows it. - `submit`: the job's spec (sandbox, image hash per architecture, exec, outputs, time limit), its **source** (§5), its secrets and OIDC token, the forge's run and job ids, and optional steering (`runs-on`, `prefer`). - `events`: a stream per forge of `placed` (where, and why), `started`, `log`, `output` (a file or blob), `finished` (exit code, peak disk and memory, duration), `receipt` (signed by the runner), and `waiting` (why, when nothing fits). - `cancel`, and `queue` (what is waiting, for the forge's pages). **Runner ↔ Jetstream.** What runners already do with the hub today, moved: `claim` (with its facts, §6), `log`, `output`, `finish`, `decline` (with a reason). A runner talks only to Jetstream, never to a forge: moving from embedded to external Jetstream is a URL change for it. ```mermaid sequenceDiagram participant Forge as OxygenHub participant Jet as Jetstream participant R as earth-1 Forge->>Jet: submit(job, source, secrets, token) Note over Jet: queued: needs x86_64, microvm,
~6 GiB disk (from its last run) R->>Jet: claim(facts: idle, 41 GiB free, holds image) Jet->>Forge: placed on earth-1 (holds the image, idle, score 100) Jet->>R: job R->>Jet: log, log, output Jet->>Forge: log, log, output R->>Jet: finish(exit 0, peak 5.2 GiB disk, 3.1 GiB memory) Jet->>Forge: finished + receipt ``` ## 5. Sources: mounted, or checked out A job's source says how a runner gets its files. - **Oxygen** (`oxy://HUB/OWNER/NAME@STATE`): flow-casfs mounts the state read-only and reads objects as the job touches them (spec §5.1). Nothing is checked out; a job that reads 50 files of a 100,000-file repository fetches 50 files. Jobs whose scope a change does not touch keep their previous result (spec §6), and receipts name the exact state. - **Git** (`git+https://…@COMMIT`): the runner clones or fetches the commit into the job's disk before it starts, as every CI system does. Both run through the same fleet, so the difference is measured, not claimed: the job page shows how long the source took. ```mermaid flowchart TB subgraph O["Oxygen source"] O1["job starts"] --> O2["mount state (ms)"] --> O3["read files as used"] end subgraph G["Git source"] G1["clone / fetch commit"] --> G2["check out every file"] --> G3["job starts"] end ``` ## 6. Facts: runners know what they are A runner measures itself, and sends what it measured **with every claim**. Idle runners claim every few seconds, so Jetstream always holds every idle runner's facts from moments ago; there is no separate audit channel to go stale. | Fact | Measured by | When | |---|---|---| | Architecture, CPU model, cores | `uname`, `/proc/cpuinfo` | at start | | Tiers: microvm (KVM opened, not assumed), syscall (Landlock ABI), wasm | probing | at start, then hourly | | Memory free, disk free where jobs run | `/proc/meminfo`, `statvfs` | every claim | | Load, jobs running | `/proc/loadavg`, its own state | every claim | | Images held | its image store | every claim | | Speed score | a fixed few-second benchmark (hashing, compression, a small compile), normalised so earth-1 ≈ 100 | at start, then about every 15 minutes while idle | | Where it runs | the pool: host, cluster and node, or cloud, region, instance type, spot | at start | The score is checked against reality: Jetstream compares each runner's claimed speed with how fast its jobs actually ran, and ranks a runner whose real timings drift (thermal throttling, noisy neighbours) by what it achieved. ## 7. Placement: requirements, ranking, and why **A job's requirements are mostly derived**, never typed by the user: - architecture from its image; - tier from its sandbox; - disk and memory **learned from its own past runs** (peak plus headroom); a new job goes to the roomiest runner the first time; - an explicit floor when the pipeline gives one. **Placement** filters, then ranks: 1. **Filter on hard facts:** architecture, tier, enough free disk and memory now, steering that requires a pool or label. 2. **Rank on soft ones:** already holds the image (warm), idle, speed score, the shortest estimated finish. 3. **A short hold for the best fit:** when the runner asking is a poor fit and a better one claimed seconds ago, the job is held a few seconds for it, then goes to whoever is next. 4. **The runner has the last word:** it checks again on accept and declines with a reason if its disk filled meanwhile. **Every decision explains itself**, on the job and through the API: > ran on **earth-1**: holds the image, idle, score 100, estimated 4 min > **waiting**: needs ~12 GiB disk (its last run used 10.4); plex-1 has 9; > earth-1 is busy until about 11:40 **Labels only steer.** `runs-on "plex"` pins, `prefer "fast"` biases. A user who never writes one never needs to. ## 8. Images - **Content-addressed.** An image is an ext4 root file system named by its BLAKE3 hash (`cas://blob/…`), as today. A sandbox may name one per architecture. - **Served by Jetstream.** A runner that lacks an image fetches it by hash and verifies it; nobody copies images between machines. Images are chunked, so a rebuilt image moves only its changed chunks. - **Built by Flow.** An image is a job's output: a project's `.flow/image/` becomes a job, its image is kept and served, and the sandbox's pin follows it (a proposal: "image changed: af3c… → 91be…"). - **Architecture recorded at build**, so an x86 image never reaches a RISC-V runner. ## 9. Pools A **pool** is one place runners come from. All pools report the same facts and follow the same placement. ```mermaid flowchart TB J["Jetstream"] subgraph Hosts["Host pool"] E["earth-1
x86_64 · microvm"] PL["plex-1
x86_64 · microvm"] N["narvi-1
riscv64 · syscall"] end subgraph K8s["Kubernetes pool"] CTL["jet controller"] KP1["KVM node pool
Firecracker in pod"] KP2["Kata / gVisor
RuntimeClass"] KP3["ordinary pods
syscall tier"] CTL --> KP1 CTL --> KP2 CTL --> KP3 end subgraph Cloud["Cloud pool (europe-west2)"] PRV["jet provider"] VM1["warm VM, nested KVM
several microVMs"] VM2["VM per job
syscall tier"] PRV --> VM1 PRV --> VM2 end J --> Hosts J --> CTL J --> PRV ``` **Hosts.** Machines you own run `jet runner` (today's `flow runner`). **Kubernetes.** `jet controller` runs in the cluster, claims for it, and creates **one pod per job**, the pattern every major CI system uses on Kubernetes. The tier follows what each node pool offers: | Node pool offers | Tier | |---|---| | `/dev/kvm` through a device plugin (a tainted pool) | microvm: Firecracker inside the pod | | A Kata or gVisor RuntimeClass (AKS Pod Sandboxing, GKE Sandbox) | syscall, inside the platform's sandbox | | Nothing special (GKE Autopilot included) | syscall, in an unprivileged pod | Dedicated KVM hosts beside a cluster stay first class: that is how microVM CI is run in practice. **Cloud providers.** `jet provider` creates and removes VMs in a cloud account, keyless through OIDC (OxygenHub is already an issuer, OxygenHub spec §13.5): - **Packing first:** a warm 8–16 vCPU VM with nested KVM runs several microVM jobs, saving the 30–60 s boot, the cold disk and per-VM minimum charges. Nested KVM: GCP Intel types and N4D, AWS C7i/C8i and newer (since 2026), Azure Dsv5. - **One VM per job** with the syscall tier inside is the fallback where nested KVM is not offered. - **Regions are pinned** per pool and never fall back outside the set: for the UK, `europe-west2`, `uksouth`, `eu-west-2`. Every job records where it ran. - **Spot**, with on-demand fallback; a reclaim notice requeues the job. - **Scale-in** only of idle VMs near the end of their billing increment. - Build order: GCP, then AWS, then Azure. The provider interface: `create(n, spec)`, `delete(ids)`, `list()`, `health(id)`, `capabilities()`, `quota()`. ## 10. Demand and autoscaling Jetstream answers **demand by pull**, so nothing needs a way into your network: - `GET /demand?pool=…` (long-poll): waiting jobs grouped by architecture, tier and size, and the age of the oldest. Kubernetes pools scale on it through a KEDA scaler; cloud providers through their own loop. - **One-shot runner tokens** for ephemeral runners and pods: issued for one start, never long-lived. ## 11. Reliability and running it - **Lost runner:** its jobs are requeued after a short grace; a spot reclaim notice requeues at once. - **OOM** is reported as OOM, with the job's peak, and raises its learned memory need. - **Updates:** a runner told of a newer version updates itself between jobs. No cut-off date; the contract is versioned and old runners keep working until they update. - **Figures:** queue time (the single most useful CI metric), utilisation per runner and pool, and cost per job, pool and region where a provider knows its prices. ## 12. Embedded or on its own ```mermaid flowchart LR subgraph A["Embedded (the default)"] OH1["OxygenHub process
+ jet inside"] R1["runners"] --> OH1 end subgraph B["On its own"] OH2["OxygenHub"] --> JS2["jet server"] OH3["another OxygenHub"] --> JS2 R2["runners"] --> JS2 end ``` - **Embedded:** the same crate in OxygenHub's process, with no configuration. This is what OxygenHub does unless told otherwise. - **On its own:** `[flow] jetstream = "https://jet.example"` in the hub's configuration points it at a standalone `jet server`; several hubs may share one fleet. - **Other forges, later:** the forge contract (§4) and Git sources (§5) are what an adapter needs, such as one that runs GitLab or GitHub jobs on Jetstream runners. That would be a contained piece of work, not a rewrite; it is not planned yet. ## 13. Running a pipeline on your own machine ```sh flow run # every job of .flow/pipeline.kdl, on this directory's files flow run test # one job, and what it needs flow run --var X=1 --secret TOKEN=… # what the hub would supply ``` `flow run` exists today: it runs the pipeline's jobs in the same sandboxes CI uses, before anything is pushed. With Jetstream it becomes an in-process Jetstream with one local runner, the same placement and the same code path as CI, so passing locally means passing in CI. With images served by Jetstream (§8), the first local run fetches the pinned image by hash instead of needing it imported by hand. ## 14. Security - **Isolation per job:** a microVM where KVM exists, the syscall tier (Landlock and seccomp) elsewhere, a platform sandbox where a cluster offers one. No job ever shares a Docker daemon or a host socket. - **Secrets** are mounted as files in the job and masked in logs (spec §9), never written to a runner's disk outside the job. - **Cloud VMs** have no cloud identity of their own, the metadata service is blocked from inside job microVMs, and job subnets accept nothing inbound. - **Runner tokens** are per runner, and one-shot for ephemeral ones. ## 15. Order of work 1. **The boundary, inside the hub:** queue, placement and runners behind the Jetstream contract in a `jet` crate, embedded; no behaviour change, CI proves it. 2. **Images served by Jetstream**, fetched by hash; images built by Flow jobs. 3. **Facts on every claim, learned requirements, placement with its explanation**, on the job page and the queue. 4. **narvi-1**, the first runner brought up this way (riscv64, syscall). 5. **`jet server`**, OxygenHub pointing at it. 6. **The Kubernetes controller**, with a KEDA scaler on `/demand`. 7. **Cloud providers:** GCP, then AWS, then Azure. ## 16. Open questions - Whether artifacts and report files travel through Jetstream or go straight from the runner to the forge (today: straight to the hub). - Multi-tenancy in a shared `jet server`: pools per organisation, and who may submit to which. - How long the best-fit hold (§7) should be, and whether it should learn. - Receipts: the runner signs; whether Jetstream countersigns where it placed the job.