OXYGENHUB

Say why a job waits when the runners that could take it are full 58cbf42447bc

Seen tonight: oxygenhub-gcp runs waited an hour with an empty reason
while earth-1 showed free slots and mars-1/plex-1 ran long jobs. A job
some runner could take now says why it still waits: which of those
runners are busy ('earth-1 busy (3 of 3)'), or have a free slot without
the memory or disk for it now, and how many ready jobs come first (a
better priority, or older). Its check is reported again only when the
reason changes in kind, not in its counts, so a moving queue doesn't
reach webhooks every pass.

Closes #259
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 655888afc086
When
· 2026-10-18 20:08 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
58cbf42447bc0677932c1882ab0f2935be41175e51405f4b3d641cba95f0ab56
Depends on
42fa95540468 4d2e44d79b03 501d8b268dd4 67040b158ae2 92a797b20912 93be9ce4653e c2be148cfe83 cc19697f8871
Depended on by
5d66f27cebe2 951aed50c2f5 794ff94f194a
In bookmarks
api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main proposal-list-fast
In tags
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
9 atoms, 0 tree operations, 4 files

Browse the tree at this change

modified crates/hub/src/db/flow.rs +21 −0
…
662 unchanged lines
663 663
664 664
/// Queued jobs whose dependencies have passed, in their live runs, with
665 665
/// their repositories (owner, id): the ones a runner could take now.
666 +
/// Every queued job's priority rank, 0 (urgent) to 3 (low), by job id
667 +
/// (#259: how many ready jobs come first).
668 +
pub async fn queued_ranks(pool: &Pool) -> sqlx::Result<std::collections::HashMap<i64, i64>> {
669 +
    let rows: Vec<(i64, i64)> = sqlx::query_as(&format!(
670 +
        "SELECT j.id, CAST({PRIORITY_RANK} AS BIGINT) FROM flow_jobs j JOIN flow_runs r ON r.id = j.run_id JOIN repositories rep ON rep.id = r.repo_id \
671 +
         WHERE j.status = 'queued'"
672 +
    ))
673 +
    .fetch_all(pool)
674 +
    .await?;
675 +
    Ok(rows.into_iter().collect())
676 +
}
677 +
678 +
/// How many jobs each runner is running now, by runner id (#259).
679 +
pub async fn running_by_runner(pool: &Pool) -> sqlx::Result<std::collections::HashMap<i64, i64>> {
680 +
    let rows: Vec<(i64, i64)> =
681 +
        sqlx::query_as("SELECT runner_id, COUNT(*) FROM flow_jobs WHERE status = 'running' AND runner_id IS NOT NULL GROUP BY runner_id")
682 +
            .fetch_all(pool)
683 +
            .await?;
684 +
    Ok(rows.into_iter().collect())
685 +
}
686 +
666 687
pub async fn ready_queued(pool: &Pool) -> sqlx::Result<Vec<(Job, String, i64)>> {
667 688
    let queued: Vec<Job> = sqlx::query_as(
668 689
        "SELECT j.* FROM flow_jobs j JOIN flow_runs r ON r.id = j.run_id \
…
336 unchanged lines
modified crates/hub/src/flow.rs +56 −2
…
927 unchanged lines
928 928
    text
929 929
}
930 930
931 +
/// Why a job that some runner could take still waits (oxygenhub#259): the
932 +
/// runners that could take it are all busy, or have a free slot without
933 +
/// the room for it now, and how many ready jobs come first (a higher
934 +
/// priority, or the same one and older).
935 +
#[allow(clippy::too_many_arguments)]
936 +
pub fn why_still_waiting(
937 +
    job: &fdb::Job,
938 +
    owner: &str,
939 +
    repo_id: i64,
940 +
    runners: &[db::runners::Runner],
941 +
    fleet: (&std::collections::HashMap<String, String>, &std::collections::HashSet<String>, i64),
942 +
    declines: &[fdb::DeclineRow],
943 +
    running: &std::collections::HashMap<i64, i64>,
944 +
    ahead: usize,
945 +
) -> String {
946 +
    let fleet = Fleet { image_archs: fleet.0, hub_images: fleet.1, now: fleet.2 };
947 +
    let mut busy = Vec::new();
948 +
    for r in runners.iter().filter(|r| r.state != "disabled" && r.online()) {
949 +
        if runner_lacks(job, owner, repo_id, r, &fleet, declines).is_some() {
950 +
            continue;
951 +
        }
952 +
        let n = running.get(&r.id).copied().unwrap_or(0);
953 +
        let slots = r.max_jobs.max(1);
954 +
        if n >= slots {
955 +
            busy.push(format!("{} busy ({n} of {slots})", r.name));
956 +
        } else {
957 +
            busy.push(format!("{} has a free slot ({n} of {slots}) but not the memory or disk for it now", r.name));
958 +
        }
959 +
    }
960 +
    let mut parts = Vec::new();
961 +
    if !busy.is_empty() {
962 +
        parts.push(format!("the runners that can take it are busy: {}", busy.join("; ")));
963 +
    }
964 +
    if ahead > 0 {
965 +
        parts.push(format!("{ahead} ready {} first", if ahead == 1 { "job comes" } else { "jobs come" }));
966 +
    }
967 +
    parts.join("; ")
968 +
}
969 +
931 970
/// How long a job is held for a better runner before any runner that fits
932 971
/// takes it (oxygenflow#42).
933 972
const BETTER_HOLD_MS: i64 = 15_000;
…
93 unchanged lines
1027 1066
    let archs = fdb::image_archs(&app.db).await?;
1028 1067
    let held = crate::images::held(&app.db).await?;
1029 1068
    let now = db::now_ms();
1069 +
    let ranks = fdb::queued_ranks(&app.db).await?;
1070 +
    let running = fdb::running_by_runner(&app.db).await?;
1071 +
    // Claim order, roughly: a better rank first, then the older job.
1072 +
    let order: Vec<(i64, i64)> = ready.iter().map(|(j, _, _)| (ranks.get(&j.id).copied().unwrap_or(2), j.id)).collect();
1030 1073
    for (job, owner, repo_id) in ready {
1031 1074
        let declines = fdb::declines_of_job(&app.db, job.id).await?;
1032 -
        let why = why_waiting(&job, &owner, repo_id, &runners, &archs, &declines, now, &held);
1075 +
        let mut why = why_waiting(&job, &owner, repo_id, &runners, &archs, &declines, now, &held);
1076 +
        if why.is_empty() {
1077 +
            let mine = (ranks.get(&job.id).copied().unwrap_or(2), job.id);
1078 +
            let ahead = order.iter().filter(|o| **o < mine).count();
1079 +
            why = why_still_waiting(&job, &owner, repo_id, &runners, (&archs, &held, now), &declines, &running, ahead);
1080 +
        }
1033 1081
        if why == job.waiting {
1034 1082
            continue;
1035 1083
        }
1036 1084
        fdb::set_waiting(&app.db, job.id, &why).await?;
1037 1085
        // The job's check says it too, so a proposal shows why it is stuck
1038 -
        // (oxygenhub#28).
1086 +
        // (oxygenhub#28); only when the reason changes in kind, not in its
1087 +
        // counts (#259: a queue position moves every pass, and each check
1088 +
        // report reaches webhooks).
1089 +
        let kind = |s: &str| s.chars().filter(|c| !c.is_ascii_digit()).collect::<String>();
1090 +
        if kind(&why) == kind(&job.waiting) {
1091 +
            continue;
1092 +
        }
1039 1093
        if let (Some(run), Some(row)) = (fdb::run_by_id(&app.db, job.run_id).await?, db::repos::get_by_id(&app.db, repo_id).await?) {
1040 1094
            let summary = if why.is_empty() { "waiting for a runner" } else { why.as_str() };
1041 1095
            report_check(app, &row, &run.state, run.number, &job.name, "queued", summary).await;
…
984 unchanged lines
modified crates/hub/tests/flow.rs +39 −0
…
2583 unchanged lines
2584 2584
    assert!(check.summary.contains("does not hold image"), "{check:?}");
2585 2585
}
2586 2586
2587 +
/// A job a runner could take but that still waits says why (#259): the
2588 +
/// runners that can take it are busy, or have a free slot without the room
2589 +
/// for it; and how many ready jobs come first.
2590 +
#[tokio::test(flavor = "multi_thread")]
2591 +
async fn a_job_waiting_for_a_busy_runner_says_so() {
2592 +
    let h = hub().await;
2593 +
    create(&h.app, "andy", "demo").await;
2594 +
    let url = format!("{}/andy/demo", h.base);
2595 +
    let work = tempfile::tempdir().unwrap();
2596 +
    let c = client(&url, work.path());
2597 +
    write(work.path(), ".flow/pipeline.kdl", &pipeline(""));
2598 +
    write(work.path(), "test.sh", "echo ok\n");
2599 +
    c.commit("Add a pipeline", &[]).unwrap();
2600 +
    assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok));
2601 +
    let run = runs_of(&h.app, "andy", "demo").await.remove(0);
2602 +
    // One runner holding the image, its one slot taken by lint.
2603 +
    let r = db::runners::configured(&h.app.db, &blake3::hash(b"busy").to_hex(), "earth-9").await.unwrap();
2604 +
    db::runners::seen(&h.app.db, r.id, None, None).await.unwrap();
2605 +
    db::runners::set_offer(&h.app.db, r.id, &["microvm".to_string()], Some(&[ROOTFS.to_string()]), false, "p").await.unwrap();
2606 +
    sqlx::query(
2607 +
        "UPDATE flow_jobs SET status = 'running', runner_id = $1, runner = 'earth-9', started_ms = $2 WHERE run_id = $3 AND name = 'lint'",
2608 +
    )
2609 +
    .bind(r.id)
2610 +
    .bind(db::now_ms())
2611 +
    .bind(run.id)
2612 +
    .execute(&h.app.db)
2613 +
    .await
2614 +
    .unwrap();
2615 +
    oxygenhub::flow::explain_waits(&h.app).await.unwrap();
2616 +
    let jobs = db::flow::jobs(&h.app.db, run.id).await.unwrap();
2617 +
    let build = jobs.iter().find(|j| j.name == "build").unwrap();
2618 +
    assert!(build.waiting.contains("the runners that can take it are busy: earth-9 busy (1 of 1)"), "{}", build.waiting);
2619 +
    // A second slot it hasn't used: free, but not the room for this job.
2620 +
    sqlx::query("UPDATE flow_runners SET max_jobs = 2 WHERE id = $1").bind(r.id).execute(&h.app.db).await.unwrap();
2621 +
    oxygenhub::flow::explain_waits(&h.app).await.unwrap();
2622 +
    let build = db::flow::jobs(&h.app.db, run.id).await.unwrap().into_iter().find(|j| j.name == "build").unwrap();
2623 +
    assert!(build.waiting.contains("earth-9 has a free slot (1 of 2) but not the memory or disk for it now"), "{}", build.waiting);
2624 +
}
2625 +
2587 2626
async fn put_bytes(app: &Shared, path: &str, bearer: &str, bytes: Vec<u8>) -> (StatusCode, String) {
2588 2627
    let req = Request::put(path)
2589 2628
        .header(header::AUTHORIZATION, format!("Bearer {bearer}"))
…
1372 unchanged lines
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. The hub knows an image's architecture from its own files, read through its ext4 to its shell's ELF header (`jet::rootfs`) out of the kept chunks, when it is sent (the manifest's word is taken only when the image cannot be read) and, once at start, for every kept image that had none; an image nobody can read takes the architecture of the runner its first job passed on, and a runner's own report, read from the image it holds, wins over that (oxygenhub#173, Andy's "detect and learn"). Only an image nobody has read or run matches any runner. 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, and when each last kept a disk, `cache_times`, so of two runners holding it the one whose copy was kept at least a minute later ranks higher, oxygenflow#54, and `placed` says "holds its build cache (kept 12 min ago)"; 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 lands a bump only where the consumer chose it (`land = auto` in the entry, else Settings › Diffusion › Landing or `PUT /repos/{owner}/{name}/diffusion {"land": "auto"}`, migration 0061; off by default, #174): once both halves are green (the upstream state it pins passed its own run, and the consumer's run on the bump as it is now passed), as the `diffusion` account through `land_as`, signed by the hub's key, so required checks, approvals and the trust policy decide; never for an unverified upstream; a refusal is noted once on the proposal's timeline. 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. The hub knows an image's architecture from its own files, read through its ext4 to its shell's ELF header (`jet::rootfs`) out of the kept chunks, when it is sent (the manifest's word is taken only when the image cannot be read) and, once at start, for every kept image that had none; an image nobody can read takes the architecture of the runner its first job passed on, and a runner's own report, read from the image it holds, wins over that (oxygenhub#173, Andy's "detect and learn"). Only an image nobody has read or run matches any runner. 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, and when each last kept a disk, `cache_times`, so of two runners holding it the one whose copy was kept at least a minute later ranks higher, oxygenflow#54, and `placed` says "holds its build cache (kept 12 min ago)"; 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). A job some runner could take but that still waits says that too (#259): which of those runners are busy ("earth-1 busy (3 of 3)"), or have a free slot without the memory or disk for it now, and how many ready jobs come first (a better priority, or older); its check is reported again only when the reason changes in kind, not in its counts, so a moving queue does not reach webhooks every pass. **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 lands a bump only where the consumer chose it (`land = auto` in the entry, else Settings › Diffusion › Landing or `PUT /repos/{owner}/{name}/diffusion {"land": "auto"}`, migration 0061; off by default, #174): once both halves are green (the upstream state it pins passed its own run, and the consumer's run on the bump as it is now passed), as the `diffusion` account through `land_as`, signed by the hub's key, so required checks, approvals and the trust policy decide; never for an unverified upstream; a refusal is noted once on the proposal's timeline. 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`.
…
235 unchanged lines