2814f421 Andy McCall 24 days ago ↶
1
# Jetstream
2
3
* Design and architecture decision, 2026-10-11 (oxygenflow#38). Builds on
4
spec §11, runner capabilities and placement (#36). Crate and CLI: ` jet` .*
5
6
** Flow** says what to run: a pipeline of hermetic jobs, each in a sandbox,
7
with its outputs, reports and signed receipt. ** Jet** carries each job to
8
where it should run: a fleet of runners on your own machines, on
9
Kubernetes or in the cloud, that measure themselves, explain every choice
10
they make, and fetch what they need on their own.
11
12
## 1. The decision
13
14
** Context.** Today OxygenHub holds the job queue and talks to runners
15
directly (` /api/v1/flow/claim` , logs, outputs, finish). Runners are
16
configured by hand: images are copied onto each machine, labels steer
17
jobs, and a job that cannot run anywhere simply waits. Every
18
self-hosted CI system has the same shape and the same problems (§2), and
19
none of it is specific to Oxygen.
20
21
** Decision.** The fleet becomes a service of its own, ** Jetstream** :
22
23
- Jetstream owns the queue, the runners and their measured facts,
24
placement and its explanations, pools (hosts, Kubernetes, cloud
25
providers) and images. It knows nothing about repositories.
26
- The ** forge** owns pipelines, runs, checks, receipts, artifacts,
27
reports and the UI. It hands Jetstream jobs and receives their events.
28
- ** OxygenHub embeds Jetstream** in its own process by default, so nothing
29
changes for anyone who never configures it, and can instead point at a
30
standalone ` jet server` shared by several forges.
31
- ** Sources are pluggable** : an Oxygen repository is mounted lazily
32
through flow-casfs; a Git repository is checked out.
33
- ** ` flow run` ** runs a pipeline on your own machine through an
34
in-process Jetstream with one local runner: the same code path as CI.
35
36
** Consequences.**
37
38
- Jetstream can be reused by others, and could later run jobs for forges
39
that are not OxygenHub (§12) without a rewrite.
40
- One more boundary to keep stable: the forge contract (§4) is versioned.
41
- Oxygen's advantage becomes visible: the same fleet runs both kinds of
42
source, and an Oxygen job starts without a checkout (§5).
43
44
``` mermaid
45
flowchart LR
46
subgraph Forges
47
OH["OxygenHub<br/>pipelines · runs · checks<br/>receipts · reports"]
48
OTHER["another forge<br/>(an adapter, later)"]
49
end
50
subgraph JS["Jetstream (jet)"]
51
Q["queue"]
52
P["placement<br/>+ why"]
53
F["runner facts"]
54
I["images"]
55
D["demand"]
56
end
57
subgraph Pools
58
H["hosts<br/>earth-1 · plex-1 · narvi-1"]
59
K["Kubernetes<br/>controller, pod per job"]
60
C["cloud<br/>GCP · AWS · Azure"]
61
end
62
OH -- "jobs" --> Q
63
OTHER -. "jobs" .-> Q
64
Q -- "events: logs, outputs,<br/>finish, receipt" --> OH
65
Q --> P
66
F --> P
67
H -- "claim + facts" --> P
68
K -- "claim + facts" --> P
69
C -- "claim + facts" --> P
70
I -- "by hash" --> H
71
I -- "by hash" --> K
72
I -- "by hash" --> C
73
D -- "how many, of what" --> K
74
D -- "how many, of what" --> C
75
```
76
77
## 2. What it fixes
78
79
Research into self-hosted runners (GitHub Actions, GitLab, Buildkite,
80
Jenkins, Forgejo; 2023–2026) found the same complaints everywhere. Most of
81
them are fleet problems, not pipeline problems.
82
83
84
85
| 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 |
86
| A job waits for hours, then fails, with no reason given | Every waiting job says why, in terms of facts (§7) |
87
| 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 |
88
| 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) |
89
| Exact-match labels that sprawl | Facts measured by the runner; labels only steer (§7) |
90
| Lost runners and spot reclaims leave jobs hanging | Requeue on a lost runner or a reclaim notice (§11) |
91
| Forced runner upgrades with a cut-off date | Runners update themselves between jobs; the contract is versioned |
92
| Ephemeral runners start cold, 30–60 s and no caches | Firecracker boots in about a second; images and caches stay warm by hash |
93
| Multi-arch builds through QEMU, 5–30× slower; no RISC-V runner at all | Native runners per architecture, RISC-V included (narvi) |
94
| No queue, utilisation or cost figures | Queue time, utilisation and cost by job, pool and region, built in (§11) |
95
96
## 3. Responsibilities
97
98
99
100
| Pipeline from the repository | | ✔ |
101
| Runs, jobs, ` needs` , the run page | | ✔ |
102
| Queue, placement, why a job waits | ✔ | shows it |
103
| Runners, their facts and tokens | ✔ | shows them |
104
| Pools: hosts, Kubernetes, cloud | ✔ | |
105
| Images | ✔ (by hash) | |
106
| Secrets and variables | carries them to the job | ✔ (stores them) |
107
| Job identity (OIDC) | carries the token | ✔ (issues it) |
108
| Logs | streams them | ✔ (keeps them) |
109
| Outputs, reports, receipts | carries them | ✔ (keeps, checks, shows) |
110
| Checks on a change | | ✔ |
111
112
## 4. The contracts
113
114
Two contracts, both HTTPS and JSON, versioned from the start.
115
116
** Forge ↔ Jetstream.** The forge submits a job and follows it.
117
118
- ` submit` : the job's spec (sandbox, image hash per architecture, exec,
119
outputs, time limit), its ** source** (§5), its secrets and OIDC token,
120
the forge's run and job ids, and optional steering (` runs-on` ,
121
` prefer` ).
122
- ` events` : a stream per forge of ` placed` (where, and why), ` started` ,
123
` log` , ` output` (a file or blob), ` finished` (exit code, peak disk and
124
memory, duration), ` receipt` (signed by the runner), and ` waiting`
125
(why, when nothing fits).
126
- ` cancel` , and ` queue` (what is waiting, for the forge's pages).
127
128
** Runner ↔ Jetstream.** What runners already do with the hub today, moved:
129
` claim` (with its facts, §6), ` log` , ` output` , ` finish` , ` decline` (with a
130
reason). A runner talks only to Jetstream, never to a forge: moving from
131
embedded to external Jetstream is a URL change for it.
132
133
``` mermaid
134
sequenceDiagram
135
participant Forge as OxygenHub
136
participant Jet as Jetstream
137
participant R as earth-1
138
Forge->>Jet: submit(job, source, secrets, token)
139
Note over Jet: queued: needs x86_64, microvm,<br/>~6 GiB disk (from its last run)
140
R->>Jet: claim(facts: idle, 41 GiB free, holds image)
141
Jet->>Forge: placed on earth-1 (holds the image, idle, score 100)
142
Jet->>R: job
143
R->>Jet: log, log, output
144
Jet->>Forge: log, log, output
145
R->>Jet: finish(exit 0, peak 5.2 GiB disk, 3.1 GiB memory)
146
Jet->>Forge: finished + receipt
147
```
148
149
## 5. Sources: mounted, or checked out
150
151
A job's source says how a runner gets its files.
152
153
- ** Oxygen** (` oxy://HUB/OWNER/NAME@STATE` ): flow-casfs mounts the state
154
read-only and reads objects as the job touches them (spec §5.1). Nothing
155
is checked out; a job that reads 50 files of a 100,000-file repository
156
fetches 50 files. Jobs whose scope a change does not touch keep their
157
previous result (spec §6), and receipts name the exact state.
158
- ** Git** (` git+https://…@COMMIT` ): the runner clones or fetches the
159
commit into the job's disk before it starts, as every CI system does.
160
161
Both run through the same fleet, so the difference is measured, not
162
claimed: the job page shows how long the source took.
163
164
``` mermaid
165
flowchart TB
166
subgraph O["Oxygen source"]
167
O1["job starts"] --> O2["mount state (ms)"] --> O3["read files as used"]
168
end
169
subgraph G["Git source"]
170
G1["clone / fetch commit"] --> G2["check out every file"] --> G3["job starts"]
171
end
172
```
173
174
## 6. Facts: runners know what they are
175
176
A runner measures itself, and sends what it measured ** with every claim** .
177
Idle runners claim every few seconds, so Jetstream always holds every idle
178
runner's facts from moments ago; there is no separate audit channel to go
179
stale.
180
181
182
183
| Architecture, CPU model, cores | ` uname` , ` /proc/cpuinfo` | at start |
184
| Tiers: microvm (KVM opened, not assumed), syscall (Landlock ABI), wasm | probing | at start, then hourly |
185
| Memory free, disk free where jobs run | ` /proc/meminfo` , ` statvfs` | every claim |
186
| Load, jobs running | ` /proc/loadavg` , its own state | every claim |
187
| Images held | its image store | every claim |
188
| 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 |
189
| Where it runs | the pool: host, cluster and node, or cloud, region, instance type, spot | at start |
190
191
The score is checked against reality: Jetstream compares each runner's
192
claimed speed with how fast its jobs actually ran, and ranks a runner
193
whose real timings drift (thermal throttling, noisy neighbours) by what
194
it achieved.
195
196
## 7. Placement: requirements, ranking, and why
197
198
** A job's requirements are mostly derived** , never typed by the user:
199
200
- architecture from its image;
201
- tier from its sandbox;
202
- disk and memory ** learned from its own past runs** (peak plus
203
headroom); a new job goes to the roomiest runner the first time;
204
- an explicit floor when the pipeline gives one.
205
206
** Placement** filters, then ranks:
207
208
1. ** Filter on hard facts:** architecture, tier, enough free disk and
209
memory now, steering that requires a pool or label.
210
2. ** Rank on soft ones:** already holds the image (warm), idle, speed
211
score, the shortest estimated finish.
212
3. ** A short hold for the best fit:** when the runner asking is a poor
213
fit and a better one claimed seconds ago, the job is held a few seconds
214
for it, then goes to whoever is next.
215
4. ** The runner has the last word:** it checks again on accept and
216
declines with a reason if its disk filled meanwhile.
217
218
** Every decision explains itself** , on the job and through the API:
219
220
> ran on ** earth-1** : holds the image, idle, score 100, estimated 4 min
221
222
> ** waiting** : needs ~12 GiB disk (its last run used 10.4); plex-1 has 9;
223
> earth-1 is busy until about 11:40
224
225
** Labels only steer.** ` runs-on "plex"` pins, ` prefer "fast"` biases. A
226
user who never writes one never needs to.
227
228
## 8. Images
229
230
- ** Content-addressed.** An image is an ext4 root file system named by
231
its BLAKE3 hash (` cas://blob/…` ), as today. A sandbox may name one per
232
architecture.
233
- ** Served by Jetstream.** A runner that lacks an image fetches it by hash
234
and verifies it; nobody copies images between machines. Images are
235
chunked, so a rebuilt image moves only its changed chunks.
236
- ** Built by Flow.** An image is a job's output: a project's
237
` .flow/image/` becomes a job, its image is kept and served, and the
238
sandbox's pin follows it (a proposal: "image changed: af3c… → 91be…").
239
- ** Architecture recorded at build** , so an x86 image never reaches a
240
RISC-V runner.
241
b14aeaae Andy McCall 23 days ago ↶
242
### 8.1 The swarm: runners share images
243
244
Runners that are configured for it fetch image chunks from each other as
245
well as from the hub (oxygenflow #47, oxygenhub #144). Fifty fresh
246
runners asking for the same 2 GiB image then cost the hub about one copy,
247
not fifty, and a site behind a thin link pulls each chunk across it once.
248
249
- ** Trust comes from the hashes.** The manifest comes from the hub, and
250
every chunk is checked against its name before it is kept. A peer can
251
fail to help; it can never change what a job runs.
ecce3719 Andy McCall 23 days ago ↶
252
- ** The hub is the tracker.** A runner reports its swarm address, its site
253
and its certificate's fingerprint with every claim. ` POST /api/v1/flow/swarm/announce {image, state}`
b14aeaae Andy McCall 23 days ago ↶
254
(` fetching` , ` done` or ` stopped` ) returns the peers in the same site that
255
hold the image or are fetching it, and a ** ticket** .
256
- ** Tickets admit peers.** A ticket names the runner, its site and an
257
expiry (15 minutes), signed with the hub key that ` /.well-known/oxyhub-key`
258
publishes. A runner shows its ticket once to each peer
259
(` POST /swarm/v1/hello` ) and gets a session for the ticket's life; no
260
other runner and no account can take chunks from a peer.
ecce3719 Andy McCall 23 days ago ↶
261
- ** Encrypted, and pinned by the hub.** Each runner makes its own TLS
262
certificate on first use (` swarm/cert.der` , its key readable by the
263
runner alone) and keeps it, so its fingerprint (BLAKE3 of the
264
certificate) holds across restarts. The tracker hands each peer out with
265
that fingerprint, and a fetcher talks only to a peer showing that
266
certificate and proving its key in the handshake; no CA is involved. A
267
peer at another address, or a stranger at a peer's, gets nothing and
268
sees nothing: chunks, tickets and sessions all travel inside TLS.
269
- ** The peer protocol** is HTTPS on the runner's swarm address:
b14aeaae Andy McCall 23 days ago ↶
270
- ` POST /swarm/v1/hello` with the ticket: a session token.
271
- ` GET /swarm/v1/images/{image}/have` : ` complete` , or two bit maps over
272
the manifest's needed list (` Manifest::needed` ), in its order: the
273
chunks it has, then the chunks it is fetching now.
274
- ` GET /swarm/v1/chunks/{chunk}` : the chunk's bytes, from the chunk
275
cache or read out of a whole image; ` 503` when the peer is at its
ecce3719 Andy McCall 23 days ago ↶
276
connection limit, and the fetcher asks it again shortly.
b14aeaae Andy McCall 23 days ago ↶
277
- ** Rarest first.** The fetcher refreshes every peer's maps twice a
278
second and asks each peer, four requests at a time, for the chunk the
279
fewest peers hold. The hub is a seed that holds everything: it is asked
280
only for chunks no peer has or is fetching, or any chunk once the peers
281
have made no progress for two seconds. Runners that start together so
282
split the hub's chunks between them rather than each taking them all. When every missing chunk is already asked
283
for, idle workers ask a second source for it (the endgame; the hub joins
284
it only once the peers stall). A peer that
285
fails is dropped and its chunks go back to the pool.
286
- ** Serving while fetching.** A chunk is offered to others as soon as it
287
is checked and kept, so runners starting together share the load.
288
- ** Bounded.** A runner serves at most 16 connections and ` upload_mib_s`
289
(default 50 MiB/s), so serving never starves its own jobs.
290
- ** Configured, never assumed.** Without a ` [runner.swarm]` section a
ecce3719 Andy McCall 23 days ago ↶
291
runner neither listens nor asks peers. Peers are not offered across
292
sites, so a site is the network its runners can reach each other on.
b14aeaae Andy McCall 23 days ago ↶
293
294
In flow.toml (or as ` [swarm]` in runner.toml, which stands in for the
295
` [runner]` section):
296
297
``` toml
298
[runner.swarm]
299
listen = "0.0.0.0:7401" # where peers reach this runner
300
advertise = "10.0.0.5:7401" # the address peers are given (default: listen)
301
site = "home" # peers are only ever in the same site
302
upload_mib_s = 50
303
```
304
3e3cc947 Andy McCall 9 days ago ↶
305
#### 8.1.1 Images only runners hold (oxygenflow#94, oxygenhub#342)
306
307
Until now an image had to be sent to the hub (` flow rootfs push` ) before any
308
runner but the one that imported it could take its jobs: the hub hands out
309
the manifest, and placement offers a job to a runner that fetches only when
310
the hub holds its image. On 2026-10-25 the Rust image was re-pinned and not
311
pushed, and for a day every Rust job queued on the one runner that had
312
imported it while the others sat idle.
313
32f54b39 Andy McCall 9 days ago ↶
314
- ** Runners say what they hold.** At start, every ten minutes and after an
315
import, a runner sends the hashes of the images it holds
316
(` POST /flow/images/held {images}` ); the hub answers with those it keeps
317
no manifest for (` {wanted}` ), and the runner sends each manifest alone
3e3cc947 Andy McCall 9 days ago ↶
318
(` PUT /flow/images/{hash}/held` , a few MB even for a 14 GB image): no
32f54b39 Andy McCall 9 days ago ↶
319
chunks. (Not in the claim: an idle runner's claim gets no answer at all.)
3e3cc947 Andy McCall 9 days ago ↶
320
- ** The hub keeps it as peer-held.** It checks the manifest is well formed
321
and names the hash it is filed under, and records the image as held by
322
runners, not by itself: it cannot read the image to check it. A pushed
323
image (` flow rootfs push` , verified whole) replaces a peer-held record.
324
- ** Trust still comes from the hashes.** The pipeline pins the image's hash;
325
a fetcher asks for the manifest by that hash, checks every chunk against
326
its name, and checks the assembled image against the pin before it keeps
327
it (` jet::images::assemble` ). A wrong manifest can make a fetch fail; it
328
cannot change what a job runs. Only runner tokens may send one.
329
- ** Placement counts peers.** A job whose image the hub holds only as
330
peer-held goes to a runner that fetches only when a runner of the same
331
swarm site holds the image, and says so when none does ("held only by
332
earth-1, in another site").
333
- ** The hub is no seed for it.** The announce answer says whether the hub
334
holds the chunks (` seed` ); when it does not, the fetcher asks peers only,
335
and a fetch with no peer left fails with that reason rather than after
336
the hub refused chunk after chunk.
337
c90dd8d5 Andy McCall 10 days ago ↶
338
### 8.2 Runners update themselves
339
340
Proposed 2026-10-25 from Andy's experience: keeping a large fleet's
341
runners up to date by hand is one of the biggest costs of running CI.
342
Runners fetch new runner builds the way they fetch images, from each other
343
through the swarm, check them as strictly as a release, and swap
344
themselves in without losing work, under a policy the organisation sets,
345
testing on a few runners before the rest.
346
347
** A runner build** is a ` flow` binary from an OxygenFlow release, one per
348
target (linux-x86_64, linux-arm64, linux-riscv64; Windows and macOS when
349
those runners exist), built by Flow from the tagged state with a signed
350
receipt, as oxy's and OxygenHub's releases are. The hub serves it as a
351
blob with a chunk manifest, so the swarm (§8.1) carries it unchanged: one
352
runner of a site fetches each chunk from the hub and the rest from it.
353
354
** Off unless chosen, nearest wins.** The policy is set for the site, an
355
organisation, a test group and a runner; the nearest setting wins:
356
357
- ` updates` : ` off` (the default) or ` on` ;
358
- ` channel` : ` stable` , ` canary` , or a pinned version (` 0.3.4` );
359
- ` window` : hours in which a runner may update (outside them it waits);
360
- ` dry_run` : fetch and check, say what would happen, do not swap.
361
362
A runner's own ` flow.toml` has the last word: ` [runner.update] allow =
363
false` refuses every update whatever the hub says, so a site can hold a
364
machine back while it tests.
365
366
** Test groups and waves.** A test group is a named set of runners: chosen
367
by name, by label or by pool. A rollout is a release and its waves, in
368
order; each wave is a group and its share of it, ** a percentage or chosen
369
runners** . A percentage is taken by a stable hash of each runner's id, so
370
"10% of build-farm" is the same machines every rollout, the ones the
371
organisation watches. A wave starts when the one before has been healthy
372
for its soak time (say 24 hours) or when an admin promotes it. For
373
example: canary (two chosen runners), then build-farm at 10%, then
374
build-farm at 100%, then everyone.
375
376
** Healthy** means the updated runners came back on the new version within
377
ten minutes, claim jobs, and their jobs' infrastructure errors (` lost` ,
378
` error` , declines) over the soak are no worse than the same runners' on
379
the version before, over enough jobs to tell. A wave that is not healthy
380
halts the rollout, and its runners roll back. Job failures from the
381
pipelines themselves do not count.
382
383
** On the runner,** in order:
384
385
1. The hub's answer to a claim or heartbeat names an update when the
386
policy says so: the version, its blob and manifest, its receipt.
387
2. Fetch the blob through the swarm (hub as seed); every chunk checked
388
against the manifest from the hub.
389
3. Check it as a release: its BLAKE3 matches the release asset the hub
390
attests; the receipt is signed by an ` owned` build runner (trust
391
classes, #79) and names the tagged state; the target is this machine's.
392
A peer can only serve bytes; it cannot change what is installed.
393
4. Stage it (` bin/flow.new` ) and run its self-check (` flow.new tiers` ,
394
` --version` ).
395
5. Drain with notice (` leave_within` , §11.5.2 of the spec): finish what
396
fits, hand back the rest with its checkpoints.
397
6. Swap: ` flow` → ` flow.prev` , ` flow.new` → ` flow` , and restart.
398
7. Prove it: the first claim reports the new version. A runner that will
399
not start, or that the hub does not hear from within ten minutes, goes
400
back to ` flow.prev` (a start counter in the runner's home: three
401
failed starts after an update restore the previous build).
402
8. The runner's page shows its version, channel, group, last update and
403
why; the audit log records every update and rollback.
404
405
** Phones** take runner updates with the app's releases (Android updates an
406
app only through its store or an install), so they follow the app.
407
408
** Order of work:** (1) the runner side, with a local source (` flow runner
85275e1e Andy McCall 10 days ago ↶
409
update --from FILE` ): check, stage, drain, swap, prove, roll back (done,
410
# 85: spec §11.5.3); (2) the
c90dd8d5 Andy McCall 10 days ago ↶
411
policy on the hub (opt in and out, channels, windows, dry run) and the
412
update in the claim answer; (3) test groups, waves and health; (4) the
413
swarm as transport; (5) Admin › Runners › Updates and ` oh runner update` .
414
2814f421 Andy McCall 24 days ago ↶
415
## 9. Pools
416
417
A ** pool** is one place runners come from. All pools report the same
418
facts and follow the same placement.
419
420
``` mermaid
421
flowchart TB
422
J["Jetstream"]
423
subgraph Hosts["Host pool"]
424
E["earth-1<br/>x86_64 · microvm"]
425
PL["plex-1<br/>x86_64 · microvm"]
426
N["narvi-1<br/>riscv64 · syscall"]
427
end
428
subgraph K8s["Kubernetes pool"]
429
CTL["jet controller"]
430
KP1["KVM node pool<br/>Firecracker in pod"]
431
KP2["Kata / gVisor<br/>RuntimeClass"]
432
KP3["ordinary pods<br/>syscall tier"]
433
CTL --> KP1
434
CTL --> KP2
435
CTL --> KP3
436
end
437
subgraph Cloud["Cloud pool (europe-west2)"]
438
PRV["jet provider"]
439
VM1["warm VM, nested KVM<br/>several microVMs"]
440
VM2["VM per job<br/>syscall tier"]
441
PRV --> VM1
442
PRV --> VM2
443
end
444
J --> Hosts
445
J --> CTL
446
J --> PRV
447
```
448
449
** Hosts.** Machines you own run ` jet runner` (today's ` flow runner` ).
450
451
** Kubernetes.** ` jet controller` runs in the cluster, claims for it, and
452
creates ** one pod per job** , the pattern every major CI system uses on
453
Kubernetes. The tier follows what each node pool offers:
454
455
456
457
| ` /dev/kvm` through a device plugin (a tainted pool) | microvm: Firecracker inside the pod |
458
| A Kata or gVisor RuntimeClass (AKS Pod Sandboxing, GKE Sandbox) | syscall, inside the platform's sandbox |
459
| Nothing special (GKE Autopilot included) | syscall, in an unprivileged pod |
460
461
Dedicated KVM hosts beside a cluster stay first class: that is how
462
microVM CI is run in practice.
463
464
** Cloud providers.** ` jet provider` creates and removes VMs in a cloud
465
account, keyless through OIDC (OxygenHub is already an issuer, OxygenHub spec
466
§13.5):
467
468
- ** Packing first:** a warm 8–16 vCPU VM with nested KVM runs several
469
microVM jobs, saving the 30–60 s boot, the cold disk and per-VM minimum
470
charges. Nested KVM: GCP Intel types and N4D, AWS C7i/C8i and newer
471
(since 2026), Azure Dsv5.
472
- ** One VM per job** with the syscall tier inside is the fallback where
473
nested KVM is not offered.
474
- ** Regions are pinned** per pool and never fall back outside the set:
475
for the UK, ` europe-west2` , ` uksouth` , ` eu-west-2` . Every job records
476
where it ran.
477
- ** Spot** , with on-demand fallback; a reclaim notice requeues the job.
478
- ** Scale-in** only of idle VMs near the end of their billing increment.
479
- Build order: GCP, then AWS, then Azure.
480
481
The provider interface: ` create(n, spec)` , ` delete(ids)` , ` list()` ,
482
` health(id)` , ` capabilities()` , ` quota()` .
483
484
## 10. Demand and autoscaling
485
486
Jetstream answers ** demand by pull** , so nothing needs a way into your
487
network:
488
489
- ` GET /demand?pool=…` (long-poll): waiting jobs grouped by architecture,
490
tier and size, and the age of the oldest. Kubernetes pools scale on it
491
through a KEDA scaler; cloud providers through their own loop.
492
- ** One-shot runner tokens** for ephemeral runners and pods: issued for
493
one start, never long-lived.
494
495
## 11. Reliability and running it
496
497
- ** Lost runner:** its jobs are requeued after a short grace; a spot
498
reclaim notice requeues at once.
499
- ** OOM** is reported as OOM, with the job's peak, and raises its learned
500
memory need.
501
- ** Updates:** a runner told of a newer version updates itself between
502
jobs. No cut-off date; the contract is versioned and old runners keep
503
working until they update.
504
- ** Figures:** queue time (the single most useful CI metric), utilisation
505
per runner and pool, and cost per job, pool and region where a provider
506
knows its prices.
507
508
## 12. Embedded or on its own
509
510
``` mermaid
511
flowchart LR
512
subgraph A["Embedded (the default)"]
513
OH1["OxygenHub process<br/>+ jet inside"]
514
R1["runners"] --> OH1
515
end
516
subgraph B["On its own"]
517
OH2["OxygenHub"] --> JS2["jet server"]
518
OH3["another OxygenHub"] --> JS2
519
R2["runners"] --> JS2
520
end
521
```
522
523
- ** Embedded:** the same crate in OxygenHub's process, with no
524
configuration. This is what OxygenHub does unless told otherwise.
525
- ** On its own:** ` [flow] jetstream = "https://jet.example"` in the hub's
526
configuration points it at a standalone ` jet server` ; several hubs may
527
share one fleet.
528
- ** Other forges, later:** the forge contract (§4) and Git sources (§5)
529
are what an adapter needs, such as one that runs GitLab or GitHub jobs
530
on Jetstream runners. That would be a contained piece of work, not a
531
rewrite; it is not planned yet.
532
533
## 13. Running a pipeline on your own machine
534
535
``` sh
536
flow run # every job of .flow/pipeline.kdl, on this directory's files
537
flow run test # one job, and what it needs
538
flow run --var X=1 --secret TOKEN=… # what the hub would supply
539
```
540
541
` flow run` exists today: it runs the pipeline's jobs in the same sandboxes
542
CI uses, before anything is pushed. With Jetstream it becomes an
543
in-process Jetstream with one local runner, the same placement and the
544
same code path as CI, so passing locally means passing in CI. With images
545
served by Jetstream (§8), the first local run fetches the pinned image by
546
hash instead of needing it imported by hand.
547
548
## 14. Security
549
550
- ** Isolation per job:** a microVM where KVM exists, the syscall tier
551
(Landlock and seccomp) elsewhere, a platform sandbox where a cluster
552
offers one. No job ever shares a Docker daemon or a host socket.
553
- ** Secrets** are mounted as files in the job and masked in logs (spec
554
§9), never written to a runner's disk outside the job.
555
- ** Cloud VMs** have no cloud identity of their own, the metadata service
556
is blocked from inside job microVMs, and job subnets accept nothing
557
inbound.
558
- ** Runner tokens** are per runner, and one-shot for ephemeral ones.
559
560
## 15. Order of work
561
562
1. ** The boundary, inside the hub:** queue, placement and runners behind
563
the Jetstream contract in a ` jet` crate, embedded; no behaviour change,
564
CI proves it.
565
2. ** Images served by Jetstream** , fetched by hash; images built by Flow
566
jobs.
567
3. ** Facts on every claim, learned requirements, placement with its
568
explanation** , on the job page and the queue.
569
4. ** narvi-1** , the first runner brought up this way (riscv64, syscall).
570
5. ** ` jet server` ** , OxygenHub pointing at it.
571
6. ** The Kubernetes controller** , with a KEDA scaler on ` /demand` .
572
7. ** Cloud providers:** GCP, then AWS, then Azure.
573
574
## 16. Open questions
575
576
- Whether artifacts and report files travel through Jetstream or go
577
straight from the runner to the forge (today: straight to the hub).
578
- Multi-tenancy in a shared ` jet server` : pools per organisation, and
579
who may submit to which.
580
- How long the best-fit hold (§7) should be, and whether it should learn.
581
- Receipts: the runner signs; whether Jetstream countersigns where it
582
placed the job.