OXYGENHUB

feat(flow): jobs go where their build cache is (refs andymccall/oxygenflow#51) 4fa0da126b70

A runner's claim lists the build caches it holds; the hub ranks a runner
holding a job's cache above one holding its image, holds the job for it up
to 15 s, and says "holds its build cache" in placed. deps/oxygenflow moves
to its main for flow_core::api::cache_slot. The Diffusion test waits for the
bump's job instead of a fixed 300 ms, which a loaded test run outran.
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-13 15:23 UTC
Signature
✔ signed by Andy McCall
Hash
4fa0da126b702d23f945ff2890a0944d8750e155c557b5cca3ebfeedeef908b7
Depends on
08e221b77658 180dc43ad0e0 20b8fe85888b 380996db84e5 42fa95540468 501d8b268dd4 93be9ce4653e a46d15e72101 b0612e7dce30 b5668108487f c95446a17fdd c9aacc0e7dc1 cc19697f8871 e5acd2a2ceb5 eb2a3502601d
Depended on by
951aed50c2f5 c2be148cfe83 93501da2a162
In bookmarks
api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
In tags
v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
15 atoms, 0 tree operations, 5 files

Browse the tree at this change

modified CHANGELOG.md +1 −0
…
4 unchanged lines
5 5
## Unreleased
6 6
7 7
- **Diffusion (#148, docs/diffusion.md).** When a repository that others pin as a subrepo moves on, each consumer that follows it gets a proposal moving the pin, signed by the hub, tested by the consumer's own pipeline, with the upstream's changes listed. One proposal per consumer and subrepo, kept up to date; never landed by itself. Turned on per repository (Settings › Diffusion, `PUT /repos/{owner}/{name}/diffusion`, or `follow` in `.oxymodules`), off by default; `green` waits for the upstream's run to pass. A repository's page shows who uses it and how far behind each is (Diffusion › Diffuses to, `GET /repos/{owner}/{name}/consumers`). Consumers are bumped on the bookmark their work lands on, learned from where their proposals land.
8 +
- **Jobs go where their build cache is (oxygenflow#51):** a runner's claim lists the caches it holds, and the hub holds a job for up to 15 s for the runner holding its cache, ahead of one holding its image; the run page says "holds its build cache".
8 9
- **Diffusion has a face (#154):** the `diffusion` account's picture is the Diffusion mark (`assets/icons/diffusion.svg`), ripples from a source out to its consumers, until someone uploads another.
9 10
- **Diffusion in the API (#153):** `GET /repos/{owner}/{name}/diffusion` gives what a repository takes from and what it diffuses to, each with its pill, for the VS Code extension.
10 11
- **Tidier pages (#152).** The repository sidebar is a column of cards with shaded headings: Diffusion in one card (Takes from and Diffuses to, each with its pill), the ten most recently moved bookmarks with a link to the rest, and the hub's delegate key shown with the OxygenHub mark. The code tree has a shaded head with the latest change and the history, and folder and file icons; the site bar has icons; the Diffusion tables share one column grid; Help › Coming from GitHub covers submodules and Renovate.
…
165 unchanged lines
modified crates/hub/src/flow.rs +40 −9
…
557 unchanged lines
558 558
        db::runners::set_facts(&app.db, me.id, f).await?;
559 559
    }
560 560
    app.swarm.claimed(me.id, req.swarm.as_ref());
561 +
    // The build caches it holds (oxygenflow#51), for placing jobs by.
562 +
    if let Some(c) = &req.caches {
563 +
        CACHES
564 +
            .lock()
565 +
            .unwrap_or_else(|p| p.into_inner())
566 +
            .get_or_insert_with(Default::default)
567 +
            .insert((app.repos.root().display().to_string(), me.id), c.iter().cloned().collect());
568 +
    }
561 569
    let room = fdb::Room {
562 570
        declined: fdb::declined_by(&app.db, me.id, &profile).await?,
563 571
        memory_mib: req.free_memory_mib,
…
20 unchanged lines
584 592
    let runners = db::runners::list(&app.db).await?;
585 593
    let me_now = runners.iter().find(|r| r.id == me.id).cloned().unwrap_or_else(|| me.clone());
586 594
    let running: std::collections::HashMap<i64, i64> = db::runners::running(&app.db).await?.into_iter().collect();
587 -
    let owners: std::collections::HashMap<i64, (String, i64)> =
588 -
        fdb::queue(&app.db).await?.into_iter().map(|q| (q.job_id, (q.owner, q.repo_id))).collect();
595 +
    let queue = fdb::queue(&app.db).await?;
596 +
    let owners: std::collections::HashMap<i64, (String, i64)> = queue.iter().map(|q| (q.job_id, (q.owner.clone(), q.repo_id))).collect();
597 +
    // Each job's build cache slot (oxygenflow#51), as runners report theirs.
598 +
    let slots: std::collections::HashMap<i64, String> =
599 +
        queue.iter().map(|q| (q.job_id, flow_core::api::cache_slot(&format!("{}/{}", q.owner, q.name), &q.job))).collect();
589 600
    let (archs, held) = (fdb::image_archs(&app.db).await?, crate::images::held(&app.db).await?);
590 601
    let now = db::now_ms();
591 602
    let fleet = Fleet { image_archs: &archs, hub_images: &held, now };
…
7 unchanged lines
599 610
        if holds.get(&(hub_key.clone(), j.id)).is_some_and(|t| now - *t >= BETTER_HOLD_MS) {
600 611
            return false;
601 612
        }
602 -
        let mine = rank(&me_now, &rootfs);
613 +
        let slot = slots.get(&j.id).map(String::as_str).unwrap_or("");
614 +
        let mine = rank(&hub_key, &me_now, &rootfs, slot);
603 615
        let other = runners.iter().any(|r| {
604 616
            r.id != me.id
605 617
                && r.state == "active"
606 618
                && r.last_seen_ms.is_some_and(|t| now - t < POLLING_MS)
607 619
                && running.get(&r.id).copied().unwrap_or(0) < r.max_jobs.max(1)
608 -
                && rank(r, &rootfs) > mine
620 +
                && rank(&hub_key, r, &rootfs, slot) > mine
609 621
                && runner_lacks(j, owner, *repo_id, r, &fleet, &[]).is_none()
610 622
        });
611 623
        if other {
…
15 unchanged lines
627 639
    } else {
628 640
        "fetches the image from the hub".to_string()
629 641
    }];
642 +
    if slots.get(&job.id).is_some_and(|slot| holds_cache(&hub_key, me.id, slot)) {
643 +
        why.insert(0, "holds its build cache".into());
644 +
    }
630 645
    match running.get(&me.id).copied().unwrap_or(0) {
631 646
        0 => why.push("idle".into()),
632 647
        n => why.push(format!("running {n} other job{}", if n == 1 { "" } else { "s" })),
…
284 unchanged lines
917 932
/// When each job was first passed over for a better runner.
918 933
/// Keyed by hub (tests run several in one process) and job.
919 934
static HELD: std::sync::Mutex<Option<std::collections::HashMap<(String, i64), i64>>> = std::sync::Mutex::new(None);
935 +
936 +
/// The build cache slots each runner said at its last claim it holds
937 +
/// (oxygenflow#51). Keyed by hub (tests run several in one process) and
938 +
/// runner; a restart forgets them until each runner's next claim.
939 +
type CacheSlots = std::collections::HashMap<(String, i64), std::collections::HashSet<String>>;
940 +
static CACHES: std::sync::Mutex<Option<CacheSlots>> = std::sync::Mutex::new(None);
941 +
942 +
fn holds_cache(hub: &str, runner: i64, slot: &str) -> bool {
943 +
    CACHES
944 +
        .lock()
945 +
        .unwrap_or_else(|p| p.into_inner())
946 +
        .as_ref()
947 +
        .and_then(|c| c.get(&(hub.to_string(), runner)))
948 +
        .is_some_and(|held| held.contains(slot))
949 +
}
920 950
921 -
/// How good runner `r` is for a job on image `rootfs`: holding the image
922 -
/// counts most, then the speed class.
923 -
fn rank(r: &db::runners::Runner, rootfs: &str) -> i64 {
924 -
    let holds = r.image_list().is_some_and(|have| have.iter().any(|h| h == rootfs));
925 -
    (holds as i64) * 10 + r.speed_class.unwrap_or(0)
951 +
/// How good runner `r` is for a job on image `rootfs` whose build cache
952 +
/// slot is `slot`: holding the job's build cache counts most (a warm build
953 +
/// beats any image fetch), then holding the image, then the speed class.
954 +
fn rank(hub: &str, r: &db::runners::Runner, rootfs: &str, slot: &str) -> i64 {
955 +
    let image = r.image_list().is_some_and(|have| have.iter().any(|h| h == rootfs));
956 +
    (holds_cache(hub, r.id, slot) as i64) * 100 + (image as i64) * 10 + r.speed_class.unwrap_or(0)
926 957
}
927 958
928 959
fn rootfs_of(job: &fdb::Job) -> String {
…
1013 unchanged lines
modified crates/hub/tests/flow.rs +51 −5
…
2869 unchanged lines
2870 2870
    assert!(body.contains("\"placed\":\"holds the image"), "{body}");
2871 2871
}
2872 2872
2873 +
/// oxygenflow#51: a job waits briefly for the runner holding its build
2874 +
/// cache, even when the other one holds its image too.
2875 +
#[tokio::test(flavor = "multi_thread")]
2876 +
async fn a_job_waits_briefly_for_the_runner_holding_its_build_cache() {
2877 +
    let h = hub().await;
2878 +
    db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", true).await.unwrap();
2879 +
    db::runners::configured(&h.app.db, &blake3::hash(b"other-secret").to_hex(), "earth-9").await.unwrap();
2880 +
    let slot = flow_core::api::cache_slot("andy/demo", "build");
2881 +
    let ask = |caches: &str| {
2882 +
        format!(
2883 +
            r#"{{"runner":"r","runtimes":["microvm"],"images":["{ROOTFS}"],"fetches_images":true,"caches":[{caches}],"facts":{{"speed":100,"speed_class":3,"free_disk_mib":50000}}}}"#
2884 +
        )
2885 +
    };
2886 +
    let holder = ask(&format!("\"{slot}\""));
2887 +
    let (st, _) = call(&h.app, "POST", "/api/v1/flow/claim", Some("other-secret"), Some(holder.clone())).await;
2888 +
    assert_eq!(st, StatusCode::NO_CONTENT);
2889 +
2890 +
    create(&h.app, "andy", "demo").await;
2891 +
    let url = format!("{}/andy/demo", h.base);
2892 +
    let work = tempfile::tempdir().unwrap();
2893 +
    let c = client(&url, work.path());
2894 +
    write(
2895 +
        work.path(),
2896 +
        ".flow/pipeline.kdl",
2897 +
        &format!("schema-version 1\nsandbox \"alpine\" {{\n    rootfs \"cas://blob/{ROOTFS}\"\n}}\njob \"build\" {{\n    sandbox \"alpine\"\n    exec \"make\"\n}}\n"),
2898 +
    );
2899 +
    c.commit("Build", &[]).unwrap();
2900 +
    assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok));
2901 +
    runs_of(&h.app, "andy", "demo").await;
2902 +
2903 +
    let (st, _) = call(&h.app, "POST", "/api/v1/flow/claim", Some("runner-secret"), Some(ask(""))).await;
2904 +
    assert_eq!(st, StatusCode::NO_CONTENT, "held for the runner holding the build cache");
2905 +
    let (st, body) = call(&h.app, "POST", "/api/v1/flow/claim", Some("other-secret"), Some(holder)).await;
2906 +
    assert_eq!(st, StatusCode::OK, "{body}");
2907 +
    let id = serde_json::from_str::<serde_json::Value>(&body).unwrap()["job_id"].as_i64().unwrap();
2908 +
    let placed = db::flow::job(&h.app.db, id).await.unwrap().unwrap().placed;
2909 +
    assert!(placed.starts_with("holds its build cache, holds the image"), "{placed}");
2910 +
}
2911 +
2873 2912
/// oxygenhub#127 §3.3: report files expire beyond the newest 50 runs of a
2874 2913
/// report on a bookmark other than the default; the default keeps all,
2875 2914
/// and an expired report keeps its metrics.
…
555 unchanged lines
3431 3470
    api.snapshot("t").unwrap();
3432 3471
    api.commit("api v6", &[]).unwrap();
3433 3472
    assert!(api.push("origin", false).unwrap().iter().all(|r| r.ok));
3434 -
    pass_all().await;
3435 -
    tokio::time::sleep(std::time::Duration::from_millis(300)).await;
3436 -
    pass_all().await;
3437 -
    let claims = seen.lock().unwrap().clone();
3438 -
    let bump_job = claims.iter().find(|c| c["repo"] == "andy/game" && c["bookmark"] == "diffusion/deps/api").expect("the bump's job ran");
3473 +
    // The bump's run starts in the background: wait for its job, under a
3474 +
    // loaded test run as well as alone.
3475 +
    let mut bump_job = None;
3476 +
    for _ in 0..100 {
3477 +
        pass_all().await;
3478 +
        bump_job = seen.lock().unwrap().iter().find(|c| c["repo"] == "andy/game" && c["bookmark"] == "diffusion/deps/api").cloned();
3479 +
        if bump_job.is_some() {
3480 +
            break;
3481 +
        }
3482 +
        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
3483 +
    }
3484 +
    let bump_job = bump_job.expect("the bump's job ran");
3439 3485
    let subs: Vec<&str> = bump_job["subrepos"].as_array().unwrap().iter().filter_map(|s| s["path"].as_str()).collect();
3440 3486
    assert_eq!(subs, vec!["deps/api"], "{bump_job}");
3441 3487
…
115 unchanged lines
replaced deps/oxygenflow +0 −0

No line changes.

modified docs/spec.md +1 −1
…
514 unchanged lines
515 515
  - its key is the one the runner's token first signed with. The first signed receipt under a token binds that token to its key in `flow_runner_keys`.
516 516
517 517
  Otherwise the job's result still stands, and the run page says why the receipt was not kept. The run page links each kept receipt, with its signer. The change page lists the receipts for the state whose only head is that change. `/{owner}/{name}/receipts/{attest}` downloads a signed receipt for `oxy receipt verify` or `import`.
518 -
- **Architecture** (OxygenFlow spec §11.2–§11.3, oxygenflow#36): a runner reports each image's architecture on its claims (`image_archs`), kept in `flow_images`; a job is offered only to a runner whose architecture matches its sandbox's `arch`, else its image's as known; unknown matches, as before. The queue says which architecture a waiting job asks for. **Images by hash** (oxygenflow#39, Jetstream): `flow rootfs push HASH` sends a rootfs image as the FastCDC chunks the hub lacks (`POST /flow/images/missing`, `PUT /flow/images/chunks/{hash}`, by a token that may write repositories) and then its manifest (`PUT /flow/images/{hash}`), which the hub accepts only when every chunk is held and they make an image with that hash; chunks are kept zstd-compressed under `<data>/images`, shared by every image, and `jet_images` lists what is held. Runners and accounts fetch with `GET /flow/images/{hash}` and `GET /flow/images/chunks/{hash}`. A runner whose claims say `fetches_images` is offered jobs whose image only the hub holds, and fetches it before its preflight. Each claim also carries the runner's `facts` (OxygenFlow spec §11.1, oxygenflow#40): free disk, load, speed score and class, kernel; the hub keeps the latest on the runner, shows them on Admin › Runners and in `GET /runners`. A job's finish carries the most disk and memory it took (`peak_disk_mib`, `peak_memory_mib`, oxygenflow#41); a new job's `need_disk_mib` is the most its last five runs of the same repository and name took, plus a quarter and 64 MiB, and it is offered only to a runner whose claim reports at least that much free disk; the waiting reason says so. **Ranking** (oxygenflow#42): a job that another free runner which polled in the last 10 s would do better (it holds the job's image while the claimant would fetch it, or has a higher speed class) is held from the claimant for up to 15 s, then any runner that fits takes it; each job records why it ran where it did (`placed`: "holds the image, idle, speed 100 (class 3)", with "after N s held for a better runner" when it was), shown on the run page, in the API and by `oh run view`. A run whose jobs pin an image held by neither the hub nor a runner sets a failing `pipeline/images` check on its state (#137), naming the images and how to send them (`flow rootfs push`), so a proposal pinning a new image shows it before it lands; a later run of the state turns it to passed once every image is held. A placement pass every 15 seconds works out why each ready queued job no runner can take waits, from what each online runner offered at its last claim (scope, tier, architecture, image held, labels, memory and CPUs, a recent decline), and shows it on the job (API `waiting`, `oh run view` and `oh run watch`, its panel open on the run page) and in its check's summary, so a proposal says why its check is stuck (#28). **Diffusion** (#148, `docs/diffusion.md`): a push that moves a repository's default bookmark, or the bookmark its work lands on (the most common target of its proposals landed in the last 90 days), records each Oxygen subrepo pin in the new state (`diffusion_pins`: consumer, bookmark, path, the upstream's Oxygen repository id, the pinned heads); a hub that had no index fills it at start. `GET /repos/{owner}/{name}/consumers` lists the consumers the caller may read, each with its pin and how many changes of this repository's default bookmark it lacks (`behind`, null when the pin names changes this repository does not hold), and the overview shows them under Diffuses to. `GET /repos/{owner}/{name}/diffusion` (#153) gives both directions, `takes_from` (each upstream the repository pins) and `diffuses_to` (each consumer the caller may read), every entry naming its `upstream` and `consumer`, its `path`, `bookmark`, `behind`, `follow`, open `bump`, the bump's newest `run` and `run_status`, and the pill's halves (`upstream_state`, `consumer_state`: `tone` green, amber, red, grey or off, `word`, `title`); hub-api's `Diffusion`. A repository that pins, or is pinned by, another on the hub has a **Diffusion tab** (#150): Diffuses to (each consumer's pin bookmark, how far behind, its status, its open bump and that bump's newest run, its last landed bump, how it follows) and Takes from (each upstream it pins, with the same); its overview shows both in brief, and Admin › Diffusion lists every upstream with its consumers. Each consumer's status is a two-half pill: the upstream on the left (checked when its newest default-bookmark run passed, untested when it has no checks, failing, or testing) and the consumer on the right (passes or fails for its run on the bump, testing, waiting, or up to date), each half coloured green, amber, red or grey; the API keeps one overall `colour` as well. Landing a proposal on the hub refreshes the pin index and propagates as a push does. A bump's run, started by the `diffusion` account, is given the upstream it bumps as a subrepo whatever that account may read (the consumer's own pin of it was committed by someone who may write the consumer); its other subrepos need read access as usual. A consumer follows a subrepo's upstream as its `.oxymodules` entry says (`follow = off | green | any`, `land = propose | auto`, `target` naming the bookmark to bump, `watch` listing paths whose upstream changes are flagged), else as its repository setting says (Settings › Diffusion, or `PUT /repos/{owner}/{name}/diffusion {"follow": ...}` by its owners and admins; migration 0058), else as `[diffusion] follow` in hub.toml says (default `off`). A **bump** moves a consumer's pin at a path to a newer upstream state: one change on the target bookmark's heads that replaces only the pin (oxy-core `build_pin_change`), signed with the hub's key, on the bookmark `diffusion/<path>`, which the hub owns and rebuilds on the target each time (a forced update). When the pin has changes the upstream's state lacks (say, from an upstream proposal not landed yet), the bump moves to both, so a consumer never loses changes it relies on; it is skipped when that combination would conflict, when the pin names changes the upstream does not hold, when the pin already holds the state, or when the bump bookmark already moves it there, and the hub logs why. A bump is **proposed** once per bump bookmark, by the hub's `diffusion` account (made on first use, never able to sign in), into the target, labelled `dependencies`, its body the upstream changes the pin lacks (newest first, linking to the upstream's change pages); a later bump edits the same open proposal. It is triggered when a push moves the upstream's default bookmark (for consumers following `any`) or when a run of that bookmark passes (for `green`). Diffusion never lands a proposal. An upstream with no checks of its own (Flow off, or no run ever of its default bookmark) is **unverified**: it never turns green, so its moves also bump consumers following `green`, and those proposals are marked amber with the `upstream unverified` label and a note in the body; a later bump from a passing run removes the label. A bump already in place refreshes its proposal's text and labels. A consumer's `watch` patterns (`*` within a path segment, `**` across them, `?` one character; a plain path covers everything below it) flag the upstream changes touching a matching file: they are listed first under "Touches what you watch", with the files, and marked ⚑ in the changelog. Turning Diffusion on for a consumer (the setting or the API) catches it up at once: it is bumped to each upstream's newest state whose default-bookmark run passed, or to its newest state when it follows every move. **Images page** (#146): Admin › Runners › Images lists every rootfs image a runner holds, the hub keeps, or a job ran on in the last 90 days (from each job's sandbox), newest use first: its size and architecture, where it is (the hub, each runner holding it, each runner fetching it from the swarm now), the repositories whose jobs used it, and who sent it; an image held nowhere is marked. **The swarm** (#144, oxygenflow docs/jetstream.md §8.1): a runner in an image swarm reports its peer address, its site and the BLAKE3 of its TLS certificate with each claim (`swarm`; without a certificate it is not in the swarm), kept in memory and shown on Admin › Runners. `POST /flow/swarm/announce {image, state}` (by a runner; `fetching`, `done` or `stopped`) returns the online runners in its site that hold the image or announced fetching it in the last 45 seconds, each with its certificate's fingerprint, which the fetcher pins its TLS connection to, and a ticket signed with the hub key (JSON naming the runner, its site and an expiry 15 minutes on, hex, then the signature) that peers check against `/.well-known/oxyhub-key` before serving chunks; a runner that has not reported an address is told to add `[runner.swarm]` (409).
518 +
- **Architecture** (OxygenFlow spec §11.2–§11.3, oxygenflow#36): a runner reports each image's architecture on its claims (`image_archs`), kept in `flow_images`; a job is offered only to a runner whose architecture matches its sandbox's `arch`, else its image's as known; unknown matches, as before. The queue says which architecture a waiting job asks for. **Images by hash** (oxygenflow#39, Jetstream): `flow rootfs push HASH` sends a rootfs image as the FastCDC chunks the hub lacks (`POST /flow/images/missing`, `PUT /flow/images/chunks/{hash}`, by a token that may write repositories) and then its manifest (`PUT /flow/images/{hash}`), which the hub accepts only when every chunk is held and they make an image with that hash; chunks are kept zstd-compressed under `<data>/images`, shared by every image, and `jet_images` lists what is held. Runners and accounts fetch with `GET /flow/images/{hash}` and `GET /flow/images/chunks/{hash}`. A runner whose claims say `fetches_images` is offered jobs whose image only the hub holds, and fetches it before its preflight. Each claim also carries the runner's `facts` (OxygenFlow spec §11.1, oxygenflow#40): free disk, load, speed score and class, kernel; the hub keeps the latest on the runner, shows them on Admin › Runners and in `GET /runners`. A job's finish carries the most disk and memory it took (`peak_disk_mib`, `peak_memory_mib`, oxygenflow#41); a new job's `need_disk_mib` is the most its last five runs of the same repository and name took, plus a quarter and 64 MiB, and it is offered only to a runner whose claim reports at least that much free disk; the waiting reason says so. **Ranking** (oxygenflow#42): a job that another free runner which polled in the last 10 s would do better (it holds the job's build cache, which counts most: claims list the caches a runner holds, `caches`, each `flow_core::api::cache_slot(repo, job)`, oxygenflow#51; or it holds the job's image while the claimant would fetch it; or it has a higher speed class) is held from the claimant for up to 15 s, then any runner that fits takes it; each job records why it ran where it did (`placed`: "holds its build cache, holds the image, idle, speed 100 (class 3)", with "after N s held for a better runner" when it was), shown on the run page, in the API and by `oh run view`. A run whose jobs pin an image held by neither the hub nor a runner sets a failing `pipeline/images` check on its state (#137), naming the images and how to send them (`flow rootfs push`), so a proposal pinning a new image shows it before it lands; a later run of the state turns it to passed once every image is held. A placement pass every 15 seconds works out why each ready queued job no runner can take waits, from what each online runner offered at its last claim (scope, tier, architecture, image held, labels, memory and CPUs, a recent decline), and shows it on the job (API `waiting`, `oh run view` and `oh run watch`, its panel open on the run page) and in its check's summary, so a proposal says why its check is stuck (#28). **Diffusion** (#148, `docs/diffusion.md`): a push that moves a repository's default bookmark, or the bookmark its work lands on (the most common target of its proposals landed in the last 90 days), records each Oxygen subrepo pin in the new state (`diffusion_pins`: consumer, bookmark, path, the upstream's Oxygen repository id, the pinned heads); a hub that had no index fills it at start. `GET /repos/{owner}/{name}/consumers` lists the consumers the caller may read, each with its pin and how many changes of this repository's default bookmark it lacks (`behind`, null when the pin names changes this repository does not hold), and the overview shows them under Diffuses to. `GET /repos/{owner}/{name}/diffusion` (#153) gives both directions, `takes_from` (each upstream the repository pins) and `diffuses_to` (each consumer the caller may read), every entry naming its `upstream` and `consumer`, its `path`, `bookmark`, `behind`, `follow`, open `bump`, the bump's newest `run` and `run_status`, and the pill's halves (`upstream_state`, `consumer_state`: `tone` green, amber, red, grey or off, `word`, `title`); hub-api's `Diffusion`. A repository that pins, or is pinned by, another on the hub has a **Diffusion tab** (#150): Diffuses to (each consumer's pin bookmark, how far behind, its status, its open bump and that bump's newest run, its last landed bump, how it follows) and Takes from (each upstream it pins, with the same); its overview shows both in brief, and Admin › Diffusion lists every upstream with its consumers. Each consumer's status is a two-half pill: the upstream on the left (checked when its newest default-bookmark run passed, untested when it has no checks, failing, or testing) and the consumer on the right (passes or fails for its run on the bump, testing, waiting, or up to date), each half coloured green, amber, red or grey; the API keeps one overall `colour` as well. Landing a proposal on the hub refreshes the pin index and propagates as a push does. A bump's run, started by the `diffusion` account, is given the upstream it bumps as a subrepo whatever that account may read (the consumer's own pin of it was committed by someone who may write the consumer); its other subrepos need read access as usual. A consumer follows a subrepo's upstream as its `.oxymodules` entry says (`follow = off | green | any`, `land = propose | auto`, `target` naming the bookmark to bump, `watch` listing paths whose upstream changes are flagged), else as its repository setting says (Settings › Diffusion, or `PUT /repos/{owner}/{name}/diffusion {"follow": ...}` by its owners and admins; migration 0058), else as `[diffusion] follow` in hub.toml says (default `off`). A **bump** moves a consumer's pin at a path to a newer upstream state: one change on the target bookmark's heads that replaces only the pin (oxy-core `build_pin_change`), signed with the hub's key, on the bookmark `diffusion/<path>`, which the hub owns and rebuilds on the target each time (a forced update). When the pin has changes the upstream's state lacks (say, from an upstream proposal not landed yet), the bump moves to both, so a consumer never loses changes it relies on; it is skipped when that combination would conflict, when the pin names changes the upstream does not hold, when the pin already holds the state, or when the bump bookmark already moves it there, and the hub logs why. A bump is **proposed** once per bump bookmark, by the hub's `diffusion` account (made on first use, never able to sign in), into the target, labelled `dependencies`, its body the upstream changes the pin lacks (newest first, linking to the upstream's change pages); a later bump edits the same open proposal. It is triggered when a push moves the upstream's default bookmark (for consumers following `any`) or when a run of that bookmark passes (for `green`). Diffusion never lands a proposal. An upstream with no checks of its own (Flow off, or no run ever of its default bookmark) is **unverified**: it never turns green, so its moves also bump consumers following `green`, and those proposals are marked amber with the `upstream unverified` label and a note in the body; a later bump from a passing run removes the label. A bump already in place refreshes its proposal's text and labels. A consumer's `watch` patterns (`*` within a path segment, `**` across them, `?` one character; a plain path covers everything below it) flag the upstream changes touching a matching file: they are listed first under "Touches what you watch", with the files, and marked ⚑ in the changelog. Turning Diffusion on for a consumer (the setting or the API) catches it up at once: it is bumped to each upstream's newest state whose default-bookmark run passed, or to its newest state when it follows every move. **Images page** (#146): Admin › Runners › Images lists every rootfs image a runner holds, the hub keeps, or a job ran on in the last 90 days (from each job's sandbox), newest use first: its size and architecture, where it is (the hub, each runner holding it, each runner fetching it from the swarm now), the repositories whose jobs used it, and who sent it; an image held nowhere is marked. **The swarm** (#144, oxygenflow docs/jetstream.md §8.1): a runner in an image swarm reports its peer address, its site and the BLAKE3 of its TLS certificate with each claim (`swarm`; without a certificate it is not in the swarm), kept in memory and shown on Admin › Runners. `POST /flow/swarm/announce {image, state}` (by a runner; `fetching`, `done` or `stopped`) returns the online runners in its site that hold the image or announced fetching it in the last 45 seconds, each with its certificate's fingerprint, which the fetcher pins its TLS connection to, and a ticket signed with the hub key (JSON naming the runner, its site and an expiry 15 minutes on, hex, then the signature) that peers check against `/.well-known/oxyhub-key` before serving chunks; a runner that has not reported an address is told to add `[runner.swarm]` (409).
519 519
- **Variables** (oxygenhub#102): Administration, an organisation's settings and a repository's settings each hold `NAME=value` pairs (a name is a letter or `_` then letters, digits and `_`, up to 64, never `FLOW_*`; a value up to 4096 characters; up to 100 per level) under **Variables**, each page showing what it inherits and which inherited names it overrides. A claim carries the repository's effective set as `env`, inner levels overriding outer; Flow puts them in the job's environment under `FLOW_*` and the pipeline's own `env`, so they are part of the receipt. They are not secrets and are shown in full to whoever sees the page; secrets are mounted by Flow (OxygenFlow spec §9), never put in the environment. Changes are audited (`flow.variable.set`, `flow.variable.deleted`); `flow run --var NAME=VALUE` gives a local run the same.
520 520
- **Secrets** (oxygenhub#107; OxygenFlow spec §9): the same three levels hold **Secrets**, named as a pipeline's `secret "name" { mount-path "/run/secrets/…" }` names them (1 to 64 of `A-Z a-z 0-9 . _ -`, a value up to 64 KiB, up to 100 per level), typed once and sealed under `keys/secrets.key`; pages show names, when each was set and where it comes from, never a value. A claim carries only the secrets its job declares, each with its path and value, from the innermost level that sets the name; one no level sets is noted at the top of the job's log and left out, so the job fails on the missing file rather than running with a stand-in. Flow writes each as a file (0400, the job's user's, on an in-memory mount) before the job starts and never puts it in the environment, the receipt or an artifact. Log intake replaces each value, and its base64, with `***` before a record is stored. Changes are audited (`flow.secret.set`, `flow.secret.deleted`), never with the value.
521 521
- **Artifacts** (OxygenFlow spec §5.2): a job may declare artifacts with `output`.
…
231 unchanged lines