OXYGENHUB

Diffusion: the reverse pin index, Used by, and how far behind f677d6270298

A push that moves a repository's default bookmark, or the bookmark its
work lands on (learned from where its proposals land), records each Oxygen
subrepo pin in the new state; a hub without the index fills it at start.
GET /repos/{owner}/{name}/consumers lists the consumers the caller may
read, with each pin and how many of the upstream's changes it lacks, and
the overview shows them as Used by.

refs #148
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-12 21:46 UTC
Signature
✔ signed by Andy McCall
Hash
f677d62702981b36d47c67ed494b982066280d44c3faec93af2c090ad5df4db8
Depends on
1553bfc9ae53 18e381ea88c2 25091e5c3bba 39442b000e6b 3f4b0ea6eabf 42fa95540468 4edcf24025cf 6ca0b971ff07 8a3334b7a364 93be9ce4653e b64e2537d702 cc19697f8871 d23464c0ac60
Depended on by
3e868a1f94e1 ba0a55bb5b3c 356183b71fab 166f6e9bf4f1 33d98147511b e57765dee61a 2a8b621676b4 d1bf2d698777 5d90e0e045e2 7e01a7665aa5 62cdf34daec1 0e04dfc20211 5d54cf746e00 5360cea36c30 ffb67a81188c bff9106edcba f4d6ef418a96 93501da2a162 6ae3fa0af4f6 d7d182467382 eb2a3502601d c9aacc0e7dc1 73ef9f767132 757195ad7a5d c4e432da4bc9 9aaa88b2d9c8 f01cb865fc60 1b6536f06132 7a14bdaeb857 3776b70d9b17 a7e6e8e6094c 1d634f698ecd e71583501b1d
In bookmarks
api-unknown-fields cancel-landed-runs close-proposal-140 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
16 atoms, 2 tree operations, 10 files

Browse the tree at this change

added crates/hub/migrations/0057_diffusion_pins.sql +15 −0
1 +
-- Diffusion (#148, docs/diffusion.md §4.1): the reverse pin index. Each
2 +
-- Oxygen subrepo pin on a repository's default bookmark, and on the
3 +
-- bookmark its work lands on, recorded when a push moves that bookmark:
4 +
-- who consumes which upstream (by the upstream's Oxygen repository id,
5 +
-- hex), at which heads.
6 +
CREATE TABLE diffusion_pins (
7 +
    consumer_id  INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
8 +
    bookmark     TEXT NOT NULL,
9 +
    path         TEXT NOT NULL,
10 +
    upstream     TEXT NOT NULL,
11 +
    heads        TEXT NOT NULL,
12 +
    updated_ms   INTEGER NOT NULL,
13 +
    PRIMARY KEY (consumer_id, bookmark, path)
14 +
);
15 +
CREATE INDEX diffusion_pins_by_upstream ON diffusion_pins (upstream);
added crates/hub/src/diffusion.rs +168 −0
1 +
//! Diffusion (#148, docs/diffusion.md): an upstream's changes reach the
2 +
//! repositories that pin it as a subrepo.
3 +
//!
4 +
//! D1 starts with the reverse pin index (§4.1): when a push moves a
5 +
//! repository's default bookmark, or the bookmark its work lands on, each
6 +
//! Oxygen subrepo pin in the new state is recorded. That answers "who
7 +
//! consumes this repository, at which state, and how far behind", which
8 +
//! an upstream's page shows as Used by.
9 +
10 +
use crate::auth::roles;
11 +
use crate::db::{self, accounts::Account, repos::RepoRow, Pool};
12 +
use crate::repos::RepoName;
13 +
use crate::Shared;
14 +
use oxy_core::hash::ChangeHash;
15 +
use serde::Serialize;
16 +
17 +
/// How far back landed proposals teach where a repository's work lands.
18 +
pub const LEARN_DAYS: i64 = 90;
19 +
20 +
/// The bookmark a repository's work lands on (§4.3): the most common
21 +
/// target of its proposals landed in the last `LEARN_DAYS`, else its
22 +
/// default bookmark.
23 +
pub async fn target(pool: &Pool, row: &RepoRow) -> String {
24 +
    let since = db::now_ms() - LEARN_DAYS * 24 * 3600 * 1000;
25 +
    let learned: Option<String> = sqlx::query_scalar(
26 +
        "SELECT target FROM proposals WHERE repo_id = ? AND state = 'landed' AND landed_ms >= ?
27 +
         GROUP BY target ORDER BY COUNT(*) DESC, MAX(landed_ms) DESC LIMIT 1",
28 +
    )
29 +
    .bind(row.id)
30 +
    .bind(since)
31 +
    .fetch_optional(pool)
32 +
    .await
33 +
    .ok()
34 +
    .flatten();
35 +
    learned.filter(|t| !t.is_empty()).unwrap_or_else(|| row.default_bookmark.clone())
36 +
}
37 +
38 +
/// The bookmarks whose pins are indexed: the default and the learned
39 +
/// target (one when they are the same).
40 +
pub async fn followed_bookmarks(pool: &Pool, row: &RepoRow) -> Vec<String> {
41 +
    let mut out = vec![row.default_bookmark.clone()];
42 +
    let t = target(pool, row).await;
43 +
    if !out.contains(&t) {
44 +
        out.push(t);
45 +
    }
46 +
    out
47 +
}
48 +
49 +
/// Record `row`'s pins on `bookmark` at `heads`, replacing what was there.
50 +
pub async fn record(app: &Shared, row: &RepoRow, bookmark: &str, heads: &[ChangeHash]) {
51 +
    let Some(name) = RepoName::new(&row.owner, &row.name) else { return };
52 +
    let pins = if heads.is_empty() { Vec::new() } else { crate::flow::pins_in(app, name, heads.to_vec()).await };
53 +
    let now = db::now_ms();
54 +
    let Ok(mut tx) = app.db.begin().await else { return };
55 +
    let mut ok = sqlx::query("DELETE FROM diffusion_pins WHERE consumer_id = ? AND bookmark = ?")
56 +
        .bind(row.id)
57 +
        .bind(bookmark)
58 +
        .execute(&mut *tx)
59 +
        .await
60 +
        .is_ok();
61 +
    for (path, id, pinned) in &pins {
62 +
        let heads: Vec<String> = pinned.iter().map(|h| h.to_hex()).collect();
63 +
        ok &=
64 +
            sqlx::query("INSERT INTO diffusion_pins (consumer_id, bookmark, path, upstream, heads, updated_ms) VALUES (?, ?, ?, ?, ?, ?)")
65 +
                .bind(row.id)
66 +
                .bind(bookmark)
67 +
                .bind(path)
68 +
                .bind(hex::encode(id))
69 +
                .bind(heads.join("+"))
70 +
                .bind(now)
71 +
                .execute(&mut *tx)
72 +
                .await
73 +
                .is_ok();
74 +
    }
75 +
    if ok {
76 +
        tx.commit().await.ok();
77 +
    }
78 +
}
79 +
80 +
/// After a push: index the pins of each moved bookmark that is followed.
81 +
pub async fn after_push(app: &Shared, row: &RepoRow, moved: &[(String, String, Vec<ChangeHash>)]) {
82 +
    let followed = followed_bookmarks(&app.db, row).await;
83 +
    for (bookmark, _, heads) in moved {
84 +
        if followed.contains(bookmark) {
85 +
            record(app, row, bookmark, heads).await;
86 +
        }
87 +
    }
88 +
}
89 +
90 +
/// Index every repository's followed bookmarks, for a hub that had no
91 +
/// index before (run once at start, in the background).
92 +
pub async fn backfill(app: &Shared) {
93 +
    let Ok(rows) = db::repos::list(&app.db).await else { return };
94 +
    for row in rows {
95 +
        let Some(name) = RepoName::new(&row.owner, &row.name) else { continue };
96 +
        for bookmark in followed_bookmarks(&app.db, &row).await {
97 +
            let b = bookmark.clone();
98 +
            let heads = app.repos.read(&name, move |r, _| heads_of(r, &b)).await;
99 +
            if let Ok(heads) = heads {
100 +
                record(app, &row, &bookmark, &heads).await;
101 +
            }
102 +
        }
103 +
    }
104 +
}
105 +
106 +
/// A bookmark's heads as the repository has them now (not a cached
107 +
/// list, which a push refreshes a moment later); empty when it has none.
108 +
fn heads_of(r: &oxy_core::repo::Repo, bookmark: &str) -> Result<Vec<ChangeHash>, crate::repos::RepoError> {
109 +
    Ok(r.bookmarks()?.into_iter().find(|b| b.name == bookmark).map(|b| b.heads).unwrap_or_default())
110 +
}
111 +
112 +
/// One consumer of an upstream, as an upstream's page and API show it.
113 +
#[derive(Clone, Debug, Serialize)]
114 +
pub struct Consumer {
115 +
    pub owner: String,
116 +
    pub name: String,
117 +
    pub bookmark: String,
118 +
    pub path: String,
119 +
    /// The upstream heads it pins, hex.
120 +
    pub pinned: Vec<String>,
121 +
    /// Upstream changes on its default bookmark the pin lacks; `None` when
122 +
    /// the hub cannot tell (the pinned changes are not in the upstream).
123 +
    pub behind: Option<usize>,
124 +
}
125 +
126 +
/// The consumers of `row` that `account` may read, with how far behind
127 +
/// the upstream's default bookmark each pin is.
128 +
pub async fn consumers(app: &Shared, row: &RepoRow, account: Option<&Account>) -> Result<Vec<Consumer>, crate::repos::RepoError> {
129 +
    let Some(name) = RepoName::new(&row.owner, &row.name) else { return Ok(Vec::new()) };
130 +
    let id = app.repos.read(&name, |r, _| Ok(r.id()?)).await?;
131 +
    let found: Vec<(i64, String, String, String)> = sqlx::query_as(
132 +
        "SELECT consumer_id, bookmark, path, heads FROM diffusion_pins WHERE upstream = ? ORDER BY consumer_id, bookmark, path",
133 +
    )
134 +
    .bind(hex::encode(id))
135 +
    .fetch_all(&app.db)
136 +
    .await
137 +
    .map_err(|e| crate::repos::RepoError::Io(std::io::Error::other(e)))?;
138 +
    let mut out = Vec::new();
139 +
    let mut pinned_sets = Vec::new();
140 +
    for (consumer_id, bookmark, path, heads) in found {
141 +
        let Ok(Some(c)) = db::repos::get_by_id(&app.db, consumer_id).await else { continue };
142 +
        if !roles::allows(roles::role_for(&app.db, account, &c).await.ok().flatten(), crate::auth::Access::Read) {
143 +
            continue;
144 +
        }
145 +
        let pinned: Vec<String> = heads.split('+').filter(|h| !h.is_empty()).map(String::from).collect();
146 +
        pinned_sets.push(pinned.iter().filter_map(|h| ChangeHash::from_hex(h)).collect::<Vec<_>>());
147 +
        out.push(Consumer { owner: c.owner, name: c.name, bookmark, path, pinned, behind: None });
148 +
    }
149 +
    if out.is_empty() {
150 +
        return Ok(out);
151 +
    }
152 +
    let default = row.default_bookmark.clone();
153 +
    let behind = app
154 +
        .repos
155 +
        .read(&name, move |r, cache| {
156 +
            let tip = heads_of(r, &default)?;
157 +
            let tip = cache.summary(r, &tip)?;
158 +
            Ok(pinned_sets
159 +
                .iter()
160 +
                .map(|p| cache.summary(r, p).ok().map(|s| tip.changes.iter().filter(|c| !s.changes.contains(*c)).count()))
161 +
                .collect::<Vec<_>>())
162 +
        })
163 +
        .await?;
164 +
    for (c, b) in out.iter_mut().zip(behind) {
165 +
        c.behind = b;
166 +
    }
167 +
    Ok(out)
168 +
}
modified crates/hub/src/api/mod.rs +14 −0
…
83 unchanged lines
84 84
        .route("/repos/{owner}/{name}", get(get_repo))
85 85
        .route("/repos/{owner}/{name}/bookmarks", get(bookmarks))
86 86
        .route("/repos/{owner}/{name}/tags", get(tags))
87 +
        .route("/repos/{owner}/{name}/consumers", get(consumers))
87 88
        .route("/repos/{owner}/{name}/history/{r}", get(history))
88 89
        .route("/repos/{owner}/{name}/changes/{hash}", get(change))
89 90
        .route("/repos/{owner}/{name}/tree/{r}", get(tree_root))
…
477 unchanged lines
567 568
    Ok(Json(list.into_iter().map(|b| Bookmark { name: b.name, heads: b.heads, changes: b.changes, default: b.default }).collect()))
568 569
}
569 570
571 +
/// `GET /repos/{owner}/{name}/consumers`: the repositories that pin this
572 +
/// one as a subrepo and that the caller may read, each with its pin and
573 +
/// how far behind this repository's default bookmark it is (#148).
574 +
async fn consumers(
575 +
    State(app): State<Shared>,
576 +
    headers: HeaderMap,
577 +
    Path((owner, name)): Path<(String, String)>,
578 +
) -> Result<Json<Vec<crate::diffusion::Consumer>>, ApiError> {
579 +
    let c = caller(&app, &headers).await?;
580 +
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
581 +
    Ok(Json(crate::diffusion::consumers(&app, &row, c.account.as_ref()).await?))
582 +
}
583 +
570 584
async fn tags(
571 585
    State(app): State<Shared>,
572 586
    headers: HeaderMap,
…
1835 unchanged lines
modified crates/hub/src/flow.rs +1 −1
…
972 unchanged lines
973 973
}
974 974
975 975
/// The Oxygen subrepo pins in `name`'s state `heads`: (path, repository id, pinned heads).
976 -
async fn pins_in(
976 +
pub(crate) async fn pins_in(
977 977
    app: &Shared,
978 978
    name: crate::repos::RepoName,
979 979
    heads: Vec<oxy_core::hash::ChangeHash>,
…
938 unchanged lines
modified crates/hub/src/lib.rs +6 −0
…
9 unchanged lines
10 10
pub mod config;
11 11
pub mod coverage;
12 12
pub mod db;
13 +
pub mod diffusion;
13 14
pub mod events;
14 15
pub mod flow;
15 16
pub mod hooks;
…
118 unchanged lines
134 135
    tokio::spawn(flow::scheduler(app.clone()));
135 136
    tokio::spawn(flow::placement(app.clone()));
136 137
    tokio::spawn(maintenance::nightly(app.clone()));
138 +
    // The reverse pin index for repositories pushed before it existed (#148).
139 +
    tokio::spawn({
140 +
        let app = app.clone();
141 +
        async move { diffusion::backfill(&app).await }
142 +
    });
137 143
    Ok(app)
138 144
}
139 145
…
57 unchanged lines
modified crates/hub/src/protocol.rs +1 −0
…
348 unchanged lines
349 349
                        warm.repos.read(&name, move |r, cache| Ok(cache.briefs(r, &all).map(|_| ())?)).await.ok();
350 350
                    });
351 351
                }
352 +
                crate::diffusion::after_push(&app, &row, &moved).await;
352 353
                for (bookmark, state, heads) in &moved {
353 354
                    crate::flow::on_push(&app, &row, bookmark, state, heads, &actor).await;
354 355
                }
…
30 unchanged lines
modified crates/hub/src/web/repo.rs +19 −1
…
26 unchanged lines
27 27
    stars: i64,
28 28
    watchers: i64,
29 29
    topics: Vec<String>,
30 +
    /// Repositories that pin this one as a subrepo, and how far behind
31 +
    /// each is (Diffusion, #148).
32 +
    used_by: Vec<crate::diffusion::Consumer>,
30 33
}
31 34
32 35
/// A remote name from the hub's host: its first label when that is a
…
110 unchanged lines
143 146
    };
144 147
    let (stars, watchers) = crate::db::repos::counts(&app.db, row.id).await.unwrap_or((0, 0));
145 148
    let topics = row.topic_list().into_iter().map(String::from).collect();
146 -
    Ok(Page(OverviewPage { shell, repo: rs, o, readme_html, watching, latest_release, quickstart, fork, starred, stars, watchers, topics }))
149 +
    let used_by = crate::diffusion::consumers(&app, &row, viewer.account()).await.unwrap_or_default();
150 +
    Ok(Page(OverviewPage {
151 +
        shell,
152 +
        repo: rs,
153 +
        o,
154 +
        readme_html,
155 +
        watching,
156 +
        latest_release,
157 +
        quickstart,
158 +
        fork,
159 +
        starred,
160 +
        stars,
161 +
        watchers,
162 +
        topics,
163 +
        used_by,
164 +
    }))
147 165
}
148 166
149 167
impl view::Overview {
…
376 unchanged lines
modified crates/hub/tests/flow.rs +78 −0
…
3106 unchanged lines
3107 3107
    assert!(page.contains("earth-1 (home)"), "the swarm is named");
3108 3108
    assert_eq!(call(&h.app, "GET", "/admin/runners/images", None, None).await.0, StatusCode::NOT_FOUND, "administrators only");
3109 3109
}
3110 +
3111 +
/// Diffusion D1 (#148): the reverse pin index. A repository that pins
3112 +
/// another as a subrepo is a consumer of it; the upstream's API and page
3113 +
/// say who, at which state, and how far behind its default bookmark.
3114 +
#[tokio::test(flavor = "multi_thread")]
3115 +
async fn consumers_of_a_subrepo_are_indexed_with_how_far_behind_they_are() {
3116 +
    let h = hub().await;
3117 +
    create(&h.app, "andy", "api").await;
3118 +
    create(&h.app, "andy", "game").await;
3119 +
    let api_work = tempfile::tempdir().unwrap();
3120 +
    let api = client(&format!("{}/andy/api", h.base), api_work.path());
3121 +
    write(api_work.path(), "api.h", "v1\n");
3122 +
    api.snapshot("t").unwrap();
3123 +
    api.commit("api v1", &[]).unwrap();
3124 +
    assert!(api.push("origin", false).unwrap().iter().all(|r| r.ok));
3125 +
3126 +
    let game_work = tempfile::tempdir().unwrap();
3127 +
    let game = client(&format!("{}/andy/game", h.base), game_work.path());
3128 +
    game.submodule_add(&format!("{}/andy/api", h.base), "deps/api", Some("main")).unwrap();
3129 +
    write(game_work.path(), "game.c", "int main;\n");
3130 +
    game.snapshot("t").unwrap();
3131 +
    game.commit("game on api v1", &[]).unwrap();
3132 +
    assert!(game.push("origin", false).unwrap().iter().all(|r| r.ok));
3133 +
3134 +
    let consumers = || async {
3135 +
        let (st, body) = call(&h.app, "GET", "/api/v1/repos/andy/api/consumers", Some("site"), None).await;
3136 +
        assert_eq!(st, StatusCode::OK, "{body}");
3137 +
        serde_json::from_str::<Vec<serde_json::Value>>(&body).unwrap()
3138 +
    };
3139 +
    // The index is written by the push's after-work, a moment after the
3140 +
    // client hears the push succeeded.
3141 +
    let settle = |want: &'static (dyn Fn(&[serde_json::Value]) -> bool + Sync)| {
3142 +
        let consumers = &consumers;
3143 +
        async move {
3144 +
            for _ in 0..100 {
3145 +
                let list = consumers().await;
3146 +
                if want(&list) {
3147 +
                    return list;
3148 +
                }
3149 +
                tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3150 +
            }
3151 +
            consumers().await
3152 +
        }
3153 +
    };
3154 +
    let list = settle(&|l| !l.is_empty()).await;
3155 +
    assert_eq!(list.len(), 1, "{list:?}");
3156 +
    assert_eq!(
3157 +
        (list[0]["name"].as_str(), list[0]["path"].as_str(), list[0]["bookmark"].as_str()),
3158 +
        (Some("game"), Some("deps/api"), Some("main"))
3159 +
    );
3160 +
    assert_eq!(list[0]["behind"], 0);
3161 +
3162 +
    // The upstream moves on: the consumer is one change behind.
3163 +
    write(api_work.path(), "api.c", "v2\n");
3164 +
    api.snapshot("t").unwrap();
3165 +
    api.commit("api v2", &[]).unwrap();
3166 +
    assert!(api.push("origin", false).unwrap().iter().all(|r| r.ok));
3167 +
    assert_eq!(settle(&|l| l.first().is_some_and(|c| c["behind"] == 1)).await[0]["behind"], 1);
3168 +
    let (_, page) = call(&h.app, "GET", "/andy/api", None, None).await;
3169 +
    assert!(page.contains("Used by") && page.contains("andy/game") && page.contains("1 behind"), "the page shows it");
3170 +
3171 +
    // A private consumer is not shown to someone who cannot read it.
3172 +
    sqlx::query("UPDATE repositories SET visibility = 'private' WHERE name = 'game'").execute(&h.app.db).await.unwrap();
3173 +
    let (_, body) = call(&h.app, "GET", "/api/v1/repos/andy/api/consumers", None, None).await;
3174 +
    assert_eq!(body.trim(), "[]");
3175 +
    let (_, page) = call(&h.app, "GET", "/andy/api", None, None).await;
3176 +
    assert!(!page.contains("andy/game"));
3177 +
    sqlx::query("UPDATE repositories SET visibility = 'public' WHERE name = 'game'").execute(&h.app.db).await.unwrap();
3178 +
3179 +
    // Dropping the subrepo drops the consumer.
3180 +
    game.submodule_remove("deps/api").unwrap();
3181 +
    std::fs::remove_dir_all(game_work.path().join("deps/api")).unwrap();
3182 +
    game.snapshot("t").unwrap();
3183 +
    game.commit("game without api", &[]).unwrap();
3184 +
    assert!(game.push("origin", false).unwrap().iter().all(|r| r.ok));
3185 +
    let left = settle(&|l| l.is_empty()).await;
3186 +
    assert!(left.is_empty(), "{left:?}");
3187 +
}
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). **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 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 as Used by. **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
modified templates/overview.html +2 −0
…
59 unchanged lines
60 60
    <h3>Bookmarks</h3>
61 61
    <ul class="plain">{% for b in o.bookmarks %}<li><a href="{{ repo.url }}/tree/{{ b.name }}">{{ b.name }}</a>{% if b.heads.len() > 1 %} <span class="tag warn">{{ b.heads.len() }} heads</span>{% endif %}{% if b.conflicted %} <span class="tag bad">conflicts</span>{% endif %}</li>{% endfor %}</ul>
62 62
    {% if let Some(rel) = latest_release %}<h3>Latest release</h3><p><a href="{{ repo.url }}/releases/tag/{{ rel.tag }}">{{ rel.title }}</a> <span class="small mute">{{ rel.created_ms|ago_i }}</span></p>{% endif %}
63 +
    {% if !used_by.is_empty() %}<h3>Used by <span class="count-chip">{{ used_by.len() }}</span></h3>
64 +
    <ul class="plain used-by">{% for c in used_by %}<li><a href="/{{ c.owner }}/{{ c.name }}">{{ c.owner }}/{{ c.name }}</a> <span class="small mute">{{ c.bookmark }} · {{ c.path }}</span><br><span class="small">{% match c.behind %}{% when Some(0) %}<span class="tag">up to date</span>{% when Some(n) %}<span class="tag warn" title="Changes on {{ repo.default_bookmark }} its pin lacks">{{ n }} behind</span>{% when None %}<span class="mute" title="Its pin names changes this repository does not hold">pin not here</span>{% endmatch %}</span></li>{% endfor %}</ul>{% endif %}
63 65
    <h3>Tags</h3>
64 66
    {% if o.tags.is_empty() %}<p class="mute small">None.</p>{% else %}<ul class="plain">{% for t in o.tags %}<li><a href="{{ repo.url }}/tree/{{ t.name }}">{{ t.name }}</a>{% if t.signer.is_some() %} <span class="sig sig-valid" title="signed">✔</span>{% endif %}</li>{% endfor %}</ul>{% endif %}
65 67
    <h3>Trust</h3>
…
9 unchanged lines