OXYGENHUB

The hub reads what each image is for, and learns the rest from passes 93501da2a162

Andy's "detect and learn" (OxygenFlow spec §11.2): an image with no
recorded architecture matched every runner, so a riscv64 runner could be
offered an x86_64 image's jobs.

- On push the hub reads the image's own files: jet::rootfs follows its
  ext4 to its shell's ELF header out of the kept chunks, a few of them.
  The manifest's word is taken only when the image cannot be read, and
  the kept manifest carries the answer, so runners fetch it named.
- Once at start, every kept image with no architecture is read.
- An image nobody can read takes the architecture of the runner its
  first job passed on (at each pass, and from past passes at start); a
  runner's own report, read from the image it holds, wins over that.

Tested end to end: an ext4 image sent with no architecture is named
x86_64, the start-up pass reads it again, an unreadable image learns
x86_64 from a pass, and a riscv64 runner is offered neither job. Moves
deps/oxygenflow to e5e70c57 for jet::rootfs.

closes #173, refs andymccall/oxygenflow#53
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-14 00:59 UTC
Signature
✔ signed by Andy McCall
Hash
93501da2a16265b08c1032b331b4a29e193e1823c7e495042d76289d4eb7e2e1
Depends on
18e381ea88c2 380996db84e5 42fa95540468 4fa0da126b70 92a797b20912 93be9ce4653e b0612e7dce30 cc19697f8871 d23464c0ac60 d943160dafeb efd29a0033e8 f677d6270298
Depended on by
951aed50c2f5 d9d0d314fdcb e57765dee61a c8684d44d549 3b1bc27fe805 5d90e0e045e2 0e04dfc20211 c2be148cfe83 f4d6ef418a96
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
17 atoms, 0 tree operations, 7 files

Browse the tree at this change

modified crates/hub/src/api/images.rs +3 −3
…
91 unchanged lines
92 92
        return Err(ApiError(StatusCode::BAD_REQUEST, format!("the manifest is for {}, not {}", m.hash, hash)));
93 93
    }
94 94
    let store = app.images();
95 -
    let kept = m.clone();
96 -
    tokio::task::spawn_blocking(move || store.verify_and_keep(&kept))
95 +
    // Kept with the architecture its own files name (oxygenhub#173).
96 +
    let m = tokio::task::spawn_blocking(move || store.keep_reading_arch(&m))
97 97
        .await
98 98
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
99 99
        .map_err(|e| ApiError(StatusCode::CONFLICT, e))?;
100 100
    crate::images::record(&app.db, &m, &who).await?;
101 101
    crate::db::accounts::audit(&app.db, None, "flow.image", &who, &format!("{} ({} MiB)", &m.hash[..12], m.size >> 20)).await.ok();
102 -
    Ok((StatusCode::CREATED, Json(serde_json::json!({ "hash": m.hash, "size": m.size, "chunks": m.chunks.len() }))))
102 +
    Ok((StatusCode::CREATED, Json(serde_json::json!({ "hash": m.hash, "size": m.size, "chunks": m.chunks.len(), "arch": m.arch }))))
103 103
}
104 104
105 105
/// `GET /flow/images/{hash}`: an image's manifest.
…
16 unchanged lines
modified crates/hub/src/db/flow.rs +39 −2
…
405 unchanged lines
406 406
        let v: serde_json::Value = serde_json::from_str(sandbox_json).unwrap_or_default();
407 407
        let need = |k: &str| v[k].as_u64().unwrap_or(0);
408 408
        let rootfs = v["rootfs"].as_str().unwrap_or("");
409 -
        // The architecture: the sandbox's own ask, else the image's as known;
410 -
        // an unknown one matches, as before (OxygenFlow spec §11.3).
409 +
        // The architecture: the sandbox's own ask, else the image's as read
410 +
        // or learned from its passes (oxygenhub#173); one nobody has read
411 +
        // or run yet matches (OxygenFlow spec §11.3).
411 412
        let wanted = v["arch"].as_str().map(String::from).or_else(|| self.image_archs.get(rootfs).cloned());
412 413
        let arch_ok = self.arch.is_empty() || wanted.as_deref().is_none_or(|w| w == self.arch);
413 414
        arch_ok
…
19 unchanged lines
433 434
    Ok(())
434 435
}
435 436
437 +
/// Give every image nobody has read the architecture of the runners its
438 +
/// jobs passed on (oxygenhub#173), for the jobs before the hub learned at
439 +
/// each pass. Never overrides a known architecture.
440 +
pub async fn learn_archs_from_runs(pool: &Pool) -> sqlx::Result<u64> {
441 +
    let r = sqlx::query(
442 +
        "INSERT INTO flow_images (hash, arch, seen_ms)
443 +
         SELECT lower(json_extract(j.sandbox, '$.rootfs')), MIN(r.arch), ? FROM flow_jobs j JOIN flow_runners r ON r.id = j.runner_id
444 +
         WHERE j.status = 'passed' AND r.arch != '' AND length(json_extract(j.sandbox, '$.rootfs')) = 64
445 +
         GROUP BY lower(json_extract(j.sandbox, '$.rootfs'))
446 +
         ON CONFLICT (hash) DO NOTHING",
447 +
    )
448 +
    .bind(now_ms())
449 +
    .execute(pool)
450 +
    .await?;
451 +
    Ok(r.rows_affected())
452 +
}
453 +
436 454
/// Every image's architecture the hub knows.
437 455
pub async fn image_archs(pool: &Pool) -> sqlx::Result<std::collections::HashMap<String, String>> {
438 456
    let rows: Vec<(String, String)> = sqlx::query_as("SELECT hash, arch FROM flow_images").fetch_all(pool).await?;
…
343 unchanged lines
782 800
        .bind(job_id)
783 801
        .execute(&mut *tx)
784 802
        .await?;
803 +
    // A pass teaches what its image is for, when nobody has read it
804 +
    // (oxygenhub#173): the architecture of the runner it passed on.
805 +
    if o.status == "passed" {
806 +
        let rootfs = serde_json::from_str::<serde_json::Value>(&j.sandbox)
807 +
            .ok()
808 +
            .and_then(|v| v["rootfs"].as_str().map(str::to_ascii_lowercase))
809 +
            .unwrap_or_default();
810 +
        if rootfs.len() == 64 {
811 +
            sqlx::query(
812 +
                "INSERT INTO flow_images (hash, arch, seen_ms) SELECT ?, r.arch, ? FROM flow_jobs j JOIN flow_runners r ON r.id = j.runner_id \
813 +
                 WHERE j.id = ? AND r.arch != '' ON CONFLICT (hash) DO NOTHING",
814 +
            )
815 +
            .bind(&rootfs)
816 +
            .bind(now)
817 +
            .bind(job_id)
818 +
            .execute(&mut *tx)
819 +
            .await?;
820 +
        }
821 +
    }
785 822
    let mut changed = vec![job_id];
786 823
    // Dependents of anything that did not pass are skipped, transitively.
787 824
    loop {
…
180 unchanged lines
modified crates/hub/src/images.rs +102 −1
…
81 unchanged lines
82 82
        if got != m.hash {
83 83
            return Err(format!("the chunks make an image named {}, not {}", &got[..12], &m.hash[..12]));
84 84
        }
85 +
        self.keep_manifest(m)
86 +
    }
87 +
88 +
    /// Write manifest `m`, whose image is here.
89 +
    fn keep_manifest(&self, m: &Manifest) -> Result<(), String> {
85 90
        let path = self.manifest_path(&m.hash);
86 91
        std::fs::create_dir_all(path.parent().expect("the manifests directory")).map_err(|e| e.to_string())?;
87 92
        let tmp = path.with_extension("json.tmp");
88 93
        std::fs::write(&tmp, serde_json::to_vec(m).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
89 94
        std::fs::rename(&tmp, &path).map_err(|e| e.to_string())
90 95
    }
96 +
97 +
    /// What image `m` is for, read from its own files (OxygenFlow spec
98 +
    /// §11.2, oxygenhub#173): its shell's ELF header, found through its ext4
99 +
    /// out of the kept chunks, a few of them, not the whole image. `None`
100 +
    /// when it is not ext4 or has no shell to read.
101 +
    pub fn read_arch(&self, m: &Manifest) -> Option<&'static str> {
102 +
        let mut starts = Vec::with_capacity(m.chunks.len());
103 +
        let mut end = 0u64;
104 +
        for c in &m.chunks {
105 +
            starts.push(end);
106 +
            end += c.len() as u64;
107 +
        }
108 +
        let mut cache: std::collections::HashMap<usize, Vec<u8>> = std::collections::HashMap::new();
109 +
        let short = || std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "past the image's end");
110 +
        jet::rootfs::arch(&mut |at: u64, buf: &mut [u8]| {
111 +
            let mut done = 0usize;
112 +
            while done < buf.len() {
113 +
                let pos = at + done as u64;
114 +
                let i = starts.partition_point(|&s| s <= pos).checked_sub(1).ok_or_else(short)?;
115 +
                let c = &m.chunks[i];
116 +
                let within = (pos - starts[i]) as usize;
117 +
                if within >= c.len() {
118 +
                    return Err(short());
119 +
                }
120 +
                let n = (c.len() - within).min(buf.len() - done);
121 +
                if c.is_zeros() {
122 +
                    buf[done..done + n].fill(0);
123 +
                } else {
124 +
                    if !cache.contains_key(&i) {
125 +
                        if cache.len() >= 32 {
126 +
                            cache.clear();
127 +
                        }
128 +
                        cache.insert(i, self.chunk(c.hash())?);
129 +
                    }
130 +
                    let bytes = &cache[&i];
131 +
                    if bytes.len() != c.len() {
132 +
                        return Err(short());
133 +
                    }
134 +
                    buf[done..done + n].copy_from_slice(&bytes[within..within + n]);
135 +
                }
136 +
                done += n;
137 +
            }
138 +
            Ok(())
139 +
        })
140 +
    }
141 +
142 +
    /// Keep image `m`, checked as [`Store::verify_and_keep`], with the
143 +
    /// architecture its own files name: the manifest's word is taken only
144 +
    /// when the image cannot be read. The manifest as kept.
145 +
    pub fn keep_reading_arch(&self, m: &Manifest) -> Result<Manifest, String> {
146 +
        self.verify_and_keep(m)?;
147 +
        match self.read_arch(m) {
148 +
            Some(a) if m.arch.as_deref() != Some(a) => {
149 +
                let mut kept = m.clone();
150 +
                kept.arch = Some(a.to_string());
151 +
                self.keep_manifest(&kept)?;
152 +
                Ok(kept)
153 +
            }
154 +
            _ => Ok(m.clone()),
155 +
        }
156 +
    }
157 +
158 +
    /// Record `arch` in kept image `hash`'s manifest.
159 +
    fn set_arch(&self, hash: &str, arch: &str) -> Result<(), String> {
160 +
        let mut m = self.manifest(hash).ok_or("no such image")?;
161 +
        m.arch = Some(arch.to_string());
162 +
        self.keep_manifest(&m)
163 +
    }
91 164
}
92 165
93 166
/// Record an image this hub now keeps.
94 167
pub async fn record(pool: &Pool, m: &Manifest, uploader: &str) -> sqlx::Result<()> {
95 168
    sqlx::query(
96 169
        "INSERT INTO jet_images (hash, size, arch, chunks, uploader, created_ms) VALUES (?, ?, ?, ?, ?, ?) \
97 -
         ON CONFLICT (hash) DO NOTHING",
170 +
         ON CONFLICT (hash) DO UPDATE SET arch = excluded.arch WHERE excluded.arch != ''",
98 171
    )
99 172
    .bind(&m.hash)
100 173
    .bind(m.size as i64)
…
7 unchanged lines
108 181
        crate::db::flow::record_image_archs(pool, &[(m.hash.clone(), a.clone())].into_iter().collect()).await?;
109 182
    }
110 183
    Ok(())
184 +
}
185 +
186 +
/// Learn what every image is for (oxygenhub#173), once at start: read
187 +
/// each kept image that has no architecture from its own files, then give
188 +
/// any image still unknown the architecture its jobs passed on. A runner's
189 +
/// own report, read from the image it holds, wins over what was learned.
190 +
/// How many images were read.
191 +
pub async fn learn_archs(app: &crate::Shared) -> sqlx::Result<usize> {
192 +
    let mut read = 0;
193 +
    for k in kept(&app.db).await?.into_iter().filter(|k| k.arch.is_empty()) {
194 +
        let store = app.images();
195 +
        let hash = k.hash.clone();
196 +
        let arch = tokio::task::spawn_blocking(move || {
197 +
            let a = store.read_arch(&store.manifest(&hash)?)?;
198 +
            store.set_arch(&hash, a).ok()?;
199 +
            Some(a)
200 +
        })
201 +
        .await
202 +
        .ok()
203 +
        .flatten();
204 +
        if let Some(a) = arch {
205 +
            sqlx::query("UPDATE jet_images SET arch = ? WHERE hash = ?").bind(a).bind(&k.hash).execute(&app.db).await?;
206 +
            crate::db::flow::record_image_archs(&app.db, &[(k.hash.clone(), a.to_string())].into_iter().collect()).await?;
207 +
            read += 1;
208 +
        }
209 +
    }
210 +
    crate::db::flow::learn_archs_from_runs(&app.db).await?;
211 +
    Ok(read)
111 212
}
112 213
113 214
/// Every image this hub keeps, by hash.
…
48 unchanged lines
modified crates/hub/src/lib.rs +11 −0
…
139 unchanged lines
140 140
        let app = app.clone();
141 141
        async move { diffusion::backfill(&app).await }
142 142
    });
143 +
    // What each image is for, read or learned once (oxygenhub#173).
144 +
    tokio::spawn({
145 +
        let app = app.clone();
146 +
        async move {
147 +
            match images::learn_archs(&app).await {
148 +
                Ok(n) if n > 0 => tracing::info!(images = n, "read the architecture of kept images"),
149 +
                Ok(_) => {}
150 +
                Err(e) => tracing::warn!(error = %e, "learning image architectures"),
151 +
            }
152 +
        }
153 +
    });
143 154
    Ok(app)
144 155
}
145 156
…
57 unchanged lines
modified crates/hub/tests/flow.rs +107 −0
…
3650 unchanged lines
3651 3651
    oxygenhub::web::proposals::settle_after_push(&h.app, &row, "main", "andy").await;
3652 3652
    assert_eq!(runs_of(&h.app, "andy", "demo").await.len(), before, "not again for the same state");
3653 3653
}
3654 +
3655 +
/// Send image bytes to the hub as their chunks and then a manifest that
3656 +
/// names no architecture; the manifest as the hub answers it.
3657 +
async fn send_image(app: &Shared, image: &[u8]) -> (String, serde_json::Value) {
3658 +
    let mut chunks = std::collections::HashMap::new();
3659 +
    let m = jet::images::scan(image, None, |c, b| {
3660 +
        chunks.insert(c.hash().to_string(), b.to_vec());
3661 +
        Ok(())
3662 +
    })
3663 +
    .unwrap();
3664 +
    for c in m.needed() {
3665 +
        let (st, body) = put_bytes(app, &format!("/api/v1/flow/images/chunks/{c}"), "site", chunks[c].clone()).await;
3666 +
        assert_eq!(st, StatusCode::NO_CONTENT, "{body}");
3667 +
    }
3668 +
    let (st, body) =
3669 +
        call(app, "PUT", &format!("/api/v1/flow/images/{}", m.hash), Some("site"), Some(serde_json::to_string(&m).unwrap())).await;
3670 +
    assert_eq!(st, StatusCode::CREATED, "{body}");
3671 +
    (m.hash.clone(), serde_json::from_str(&body).unwrap())
3672 +
}
3673 +
3674 +
/// oxygenhub#173 (OxygenFlow spec §11.2, Andy's "detect and learn"): an
3675 +
/// image sent with no architecture is read from its own files, so a
3676 +
/// riscv64 runner is never offered an x86_64 image's jobs; an image
3677 +
/// nobody can read takes the architecture its first pass ran on.
3678 +
#[tokio::test(flavor = "multi_thread")]
3679 +
async fn an_image_names_its_own_architecture_and_a_pass_teaches_the_rest() {
3680 +
    use oxy_core::identity::SecretKey;
3681 +
    let h = hub().await;
3682 +
    db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", true).await.unwrap();
3683 +
    // An ext4 image whose shell is x86_64, as `flow rootfs import` makes one.
3684 +
    let dir = tempfile::tempdir().unwrap();
3685 +
    let tree = dir.path().join("tree");
3686 +
    std::fs::create_dir_all(tree.join("bin")).unwrap();
3687 +
    let mut elf = vec![0u8; 64];
3688 +
    elf[..7].copy_from_slice(b"\x7fELF\x02\x01\x01");
3689 +
    elf[18..20].copy_from_slice(&0x3Eu16.to_le_bytes());
3690 +
    std::fs::write(tree.join("bin/sh"), &elf).unwrap();
3691 +
    let img = dir.path().join("rootfs.ext4");
3692 +
    let made = std::process::Command::new("mkfs.ext4").args(["-q", "-F", "-d"]).arg(&tree).arg(&img).arg("8M").status();
3693 +
    if !made.is_ok_and(|s| s.success()) {
3694 +
        eprintln!("mkfs.ext4 not found: skipped");
3695 +
        return;
3696 +
    }
3697 +
    let (x86, answer) = send_image(&h.app, &std::fs::read(&img).unwrap()).await;
3698 +
    assert_eq!(answer["arch"], "x86_64", "read from its shell: {answer}");
3699 +
    let (_, body) = call(&h.app, "GET", &format!("/api/v1/flow/images/{x86}"), Some("runner-secret"), None).await;
3700 +
    assert_eq!(serde_json::from_str::<jet::images::Manifest>(&body).unwrap().arch.as_deref(), Some("x86_64"), "runners fetch it named");
3701 +
    // One nobody can read: no ext4, no shell.
3702 +
    let blob: Vec<u8> = (0..300_000u32).map(|i| (i % 241) as u8).collect();
3703 +
    let (unread, answer) = send_image(&h.app, &blob).await;
3704 +
    assert!(answer["arch"].is_null(), "{answer}");
3705 +
    let archs = db::flow::image_archs(&h.app.db).await.unwrap();
3706 +
    assert_eq!((archs.get(&x86).map(String::as_str), archs.get(&unread)), (Some("x86_64"), None));
3707 +
    // Kept images with none are read once at start.
3708 +
    sqlx::query("UPDATE jet_images SET arch = ''").execute(&h.app.db).await.unwrap();
3709 +
    sqlx::query("DELETE FROM flow_images").execute(&h.app.db).await.unwrap();
3710 +
    assert_eq!(oxygenhub::images::learn_archs(&h.app).await.unwrap(), 1);
3711 +
    assert_eq!(db::flow::image_archs(&h.app.db).await.unwrap().get(&x86).map(String::as_str), Some("x86_64"));
3712 +
3713 +
    // A job on each image.
3714 +
    create(&h.app, "andy", "arch").await;
3715 +
    let work = tempfile::tempdir().unwrap();
3716 +
    let c = client(&format!("{}/andy/arch", h.base), work.path());
3717 +
    let kdl = format!(
3718 +
        "schema-version 1\nsandbox \"unread\" {{\n    rootfs \"cas://blob/{unread}\"\n}}\nsandbox \"x86\" {{\n    rootfs \"cas://blob/{x86}\"\n}}\n\
3719 +
         job \"first\" {{\n    sandbox \"unread\"\n    exec \"make\"\n}}\njob \"second\" {{\n    sandbox \"x86\"\n    exec \"make\"\n    depends-on \"first\"\n}}\n\
3720 +
         job \"third\" {{\n    sandbox \"unread\"\n    exec \"make\"\n    depends-on \"first\"\n}}\n"
3721 +
    );
3722 +
    write(work.path(), ".flow/pipeline.kdl", &kdl);
3723 +
    c.commit("Jobs on two images", &[]).unwrap();
3724 +
    assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok));
3725 +
    runs_of(&h.app, "andy", "arch").await;
3726 +
3727 +
    // The x86_64 runner passes the first job: its image is x86_64 now.
3728 +
    let x86_info = serde_json::json!({ "arch": "x86_64", "os": "linux", "version": "0.1.0", "labels": [], "max_jobs": 1 });
3729 +
    let x86_claim =
3730 +
        serde_json::json!({ "runner": "earth", "runtimes": ["microvm"], "images": [], "fetches_images": true, "info": x86_info });
3731 +
    let (st, body) = call(&h.app, "POST", "/api/v1/flow/claim", Some("runner-secret"), Some(x86_claim.to_string())).await;
3732 +
    assert_eq!(st, StatusCode::OK, "{body}");
3733 +
    let first: serde_json::Value = serde_json::from_str(&body).unwrap();
3734 +
    assert_eq!(first["job"]["name"], "first");
3735 +
    let ph = first["pipeline_hash"].as_str().unwrap();
3736 +
    let id = first["job_id"].as_i64().unwrap();
3737 +
    assert_eq!(
3738 +
        finish(&h.app, id, serde_json::json!({ "exit_code": 0, "duration_ms": 700, "receipt": receipt("first", ph, 0) })).await,
3739 +
        StatusCode::NO_CONTENT
3740 +
    );
3741 +
    assert_eq!(db::flow::image_archs(&h.app.db).await.unwrap().get(&unread).map(String::as_str), Some("x86_64"), "learned");
3742 +
3743 +
    // A riscv64 runner is offered neither: one read, one learned.
3744 +
    let token = "flowreg_narvi";
3745 +
    db::runners::add_registration(&h.app.db, &blake3::hash(token.as_bytes()).to_hex(), "admin", &Default::default()).await.unwrap();
3746 +
    let identity = SecretKey::generate().identity("narvi", "", 1);
3747 +
    let info = serde_json::json!({ "arch": "riscv64", "os": "linux", "version": "0.1.0", "labels": [], "max_jobs": 1 });
3748 +
    let reg = serde_json::json!({ "token": token, "name": "narvi", "identity": hex::encode(identity.encode()), "info": info });
3749 +
    let (st, body) = call(&h.app, "POST", "/api/v1/flow/runners/register", None, Some(reg.to_string())).await;
3750 +
    assert_eq!(st, StatusCode::OK, "{body}");
3751 +
    let credential = serde_json::from_str::<serde_json::Value>(&body).unwrap()["credential"].as_str().unwrap().to_string();
3752 +
    let narvi =
3753 +
        serde_json::json!({ "runner": "narvi", "runtimes": ["microvm"], "images": [], "fetches_images": true, "info": info }).to_string();
3754 +
    let (st, body) = call(&h.app, "POST", "/api/v1/flow/claim", Some(&credential), Some(narvi)).await;
3755 +
    assert_eq!(st, StatusCode::NO_CONTENT, "the second job's image is x86_64 and the third's learned so: {body}");
3756 +
    for _ in 0..2 {
3757 +
        let (st, body) = call(&h.app, "POST", "/api/v1/flow/claim", Some("runner-secret"), Some(x86_claim.to_string())).await;
3758 +
        assert_eq!(st, StatusCode::OK, "both go to x86_64: {body}");
3759 +
    }
3760 +
}
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 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).
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; 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