OXYGENHUB

Runners' facts kept from their claims and shown 2fb70d019851

The hub keeps what each runner measured at its last claim (free disk,
load, speed score and class, kernel; migration 0053), shows them on
Admin › Runners beside the machine and in GET /runners. Bumps
deps/oxygenflow for the claim's facts.

refs andymccall/oxygenflow#40
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-11 21:49 UTC
Signature
✔ signed by Andy McCall
Hash
2fb70d0198515f0e9fab3613cb4da69339ce05f2e2c1f336a9f5ff0efb29500b
Depends on
380996db84e5 40dd3f86379a 42fa95540468 4d2e44d79b03 92a797b20912 93be9ce4653e b0612e7dce30 b66e70b7b6c7 b6b7e4cf6b24 cc19697f8871 d23464c0ac60
Depended on by
5d90e0e045e2 a46d15e72101 44a3d2ebaddd
In bookmarks
api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check 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
12 atoms, 1 tree operation, 9 files

Browse the tree at this change

added crates/hub/migrations/0053_runner_facts.sql +7 −0
1 +
-- What each runner measured about itself at its last claim (oxygenflow#40,
2 +
-- OxygenFlow spec §11.1): free disk, load, speed and kernel.
3 +
ALTER TABLE flow_runners ADD COLUMN free_disk_mib INTEGER;
4 +
ALTER TABLE flow_runners ADD COLUMN load_x100 INTEGER;
5 +
ALTER TABLE flow_runners ADD COLUMN speed INTEGER;
6 +
ALTER TABLE flow_runners ADD COLUMN speed_class INTEGER;
7 +
ALTER TABLE flow_runners ADD COLUMN kernel TEXT NOT NULL DEFAULT '';
modified crates/hub-api/src/lib.rs +13 −0
…
1058 unchanged lines
1059 1059
    pub last_seen_ms: Option<i64>,
1060 1060
    /// Jobs it is running now.
1061 1061
    pub running: i64,
1062 +
    /// What it measured at its last claim (oxygenflow#40): free disk in
1063 +
    /// MiB, the 1-minute load ×100, its speed score (100 on the reference
1064 +
    /// machine) and class 1–5, its kernel.
1065 +
    #[serde(default, skip_serializing_if = "Option::is_none")]
1066 +
    pub free_disk_mib: Option<i64>,
1067 +
    #[serde(default, skip_serializing_if = "Option::is_none")]
1068 +
    pub load_x100: Option<i64>,
1069 +
    #[serde(default, skip_serializing_if = "Option::is_none")]
1070 +
    pub speed: Option<i64>,
1071 +
    #[serde(default, skip_serializing_if = "Option::is_none")]
1072 +
    pub speed_class: Option<i64>,
1073 +
    #[serde(default, skip_serializing_if = "String::is_empty")]
1074 +
    pub kernel: String,
1062 1075
}
1063 1076
1064 1077
/// A job a runner ran or is running.
…
177 unchanged lines
modified crates/hub/src/api/mod.rs +5 −0
…
2115 unchanged lines
2116 2116
        source: r.source,
2117 2117
        last_seen_ms: r.last_seen_ms,
2118 2118
        running,
2119 +
        free_disk_mib: r.free_disk_mib,
2120 +
        load_x100: r.load_x100,
2121 +
        speed: r.speed,
2122 +
        speed_class: r.speed_class,
2123 +
        kernel: r.kernel,
2119 2124
    }
2120 2125
}
2121 2126
…
279 unchanged lines
modified crates/hub/src/db/runners.rs +39 −0
…
32 unchanged lines
33 33
    pub timeout_minutes: Option<i64>,
34 34
    /// It fetches images it lacks from the hub (oxygenflow#39).
35 35
    pub fetches_images: bool,
36 +
    /// What it measured at its last claim (oxygenflow#40): free disk (MiB),
37 +
    /// the 1-minute load ×100, its speed score and class, its kernel.
38 +
    pub free_disk_mib: Option<i64>,
39 +
    pub load_x100: Option<i64>,
40 +
    pub speed: Option<i64>,
41 +
    pub speed_class: Option<i64>,
42 +
    pub kernel: String,
36 43
    /// The tiers it offered at its last claim, comma-separated.
37 44
    pub tiers: String,
38 45
    /// The images it held then (a JSON array); empty when it did not say.
…
126 unchanged lines
165 172
    Ok(())
166 173
}
167 174
175 +
/// Keep what a runner measured about itself at a claim (oxygenflow#40).
176 +
pub async fn set_facts(pool: &Pool, id: i64, f: &flow_core::api::Facts) -> sqlx::Result<()> {
177 +
    sqlx::query("UPDATE flow_runners SET free_disk_mib = ?, load_x100 = ?, speed = ?, speed_class = ?, kernel = ? WHERE id = ?")
178 +
        .bind(f.free_disk_mib.map(|v| v.min(i64::MAX as u64) as i64))
179 +
        .bind(f.load_x100.map(i64::from))
180 +
        .bind(f.speed.map(i64::from))
181 +
        .bind(f.speed_class.map(i64::from))
182 +
        .bind(f.kernel.clone().unwrap_or_default())
183 +
        .bind(id)
184 +
        .execute(pool)
185 +
        .await?;
186 +
    Ok(())
187 +
}
188 +
189 +
impl Runner {
190 +
    /// Its facts as words for the Runners page: "speed 100 (class 3) · load
191 +
    /// 1.12 · 41 GiB free"; empty when it has sent none.
192 +
    pub fn facts_line(&self) -> String {
193 +
        let mut out = Vec::new();
194 +
        if let Some(s) = self.speed {
195 +
            out.push(format!("speed {s} (class {})", self.speed_class.unwrap_or(0)));
196 +
        }
197 +
        if let Some(l) = self.load_x100 {
198 +
            out.push(format!("load {:.2}", l as f64 / 100.0));
199 +
        }
200 +
        if let Some(d) = self.free_disk_mib {
201 +
            out.push(if d >= 1024 { format!("{} GiB free", d / 1024) } else { format!("{d} MiB free") });
202 +
        }
203 +
        out.join(" · ")
204 +
    }
205 +
}
206 +
168 207
/// What a runner offers at a claim (OxygenFlow spec §11.1): its tiers and
169 208
/// images, and the profile hash over those, its architecture, labels and
170 209
/// capacity.
…
217 unchanged lines
modified crates/hub/src/flow.rs +3 −0
…
486 unchanged lines
487 487
    // not offered to it again while this stays the same (spec §11.5).
488 488
    let profile = profile_of(&req, &arch);
489 489
    db::runners::set_offer(&app.db, me.id, &req.runtimes, req.images.as_deref(), req.fetches_images, &profile).await?;
490 +
    if let Some(f) = &req.facts {
491 +
        db::runners::set_facts(&app.db, me.id, f).await?;
492 +
    }
490 493
    let room = fdb::Room {
491 494
        declined: fdb::declined_by(&app.db, me.id, &profile).await?,
492 495
        memory_mib: req.free_memory_mib,
…
1237 unchanged lines
modified crates/hub/tests/flow.rs +20 −0
…
2721 unchanged lines
2722 2722
    .await;
2723 2723
    assert_eq!(st, StatusCode::BAD_REQUEST);
2724 2724
}
2725 +
2726 +
/// oxygenflow#40: what a runner measured at its claim is kept and shown.
2727 +
#[tokio::test(flavor = "multi_thread")]
2728 +
async fn a_runners_facts_are_kept_from_its_claim() {
2729 +
    let h = hub().await;
2730 +
    db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", true).await.unwrap();
2731 +
    let body = r#"{"runner":"test-1","runtimes":["microvm"],"facts":{"free_disk_mib":42000,"load_x100":112,"speed":100,"speed_class":3,"kernel":"7.0.0"}}"#;
2732 +
    let (st, _) = call(&h.app, "POST", "/api/v1/flow/claim", Some("runner-secret"), Some(body.into())).await;
2733 +
    assert_eq!(st, StatusCode::NO_CONTENT, "nothing to run, but the facts are kept");
2734 +
    let (st, out) = call(&h.app, "GET", "/api/v1/runners", Some("site"), None).await;
2735 +
    assert_eq!(st, StatusCode::OK, "{out}");
2736 +
    let v: serde_json::Value = serde_json::from_str(&out).unwrap();
2737 +
    let r = &v[0];
2738 +
    assert_eq!(
2739 +
        (r["speed"].as_i64(), r["speed_class"].as_i64(), r["free_disk_mib"].as_i64(), r["load_x100"].as_i64()),
2740 +
        (Some(100), Some(3), Some(42000), Some(112))
2741 +
    );
2742 +
    let row = db::runners::list(&h.app.db).await.unwrap().remove(0);
2743 +
    assert_eq!(row.facts_line(), "speed 100 (class 3) · load 1.12 · 41 GiB free");
2744 +
}
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. 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).
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 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).
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/admin_runners.html +1 −1
…
25 unchanged lines
26 26
    <td class="small">{{ row.scope }}
27 27
      <form class="tiny" method="post" action="/admin/runners/{{ r.id }}/scope" title="Empty for the whole site, an owner, or owner/repository"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="text" name="scope" size="14" placeholder="owner or owner/repo"> <button>set</button></form></td>
28 28
    <td>{% if r.key.is_empty() %}<span class="mute" title="Bound on its first signed receipt">not yet</span>{% else %}<code title="{{ r.key }} · {{ r.key_name }}">{{ r.key|short }}</code>{% endif %}</td>
29 -
    <td class="small">{% if r.arch.is_empty() %}<span class="mute">unknown</span>{% else %}{{ r.arch }} · {{ r.os }} · flow {{ r.version }}{% endif %}</td>
29 +
    <td class="small">{% if r.arch.is_empty() %}<span class="mute">unknown</span>{% else %}{{ r.arch }} · {{ r.os }} · flow {{ r.version }}{% endif %}{% let f = r.facts_line() %}{% if !f.is_empty() %}<br><span class="mute" title="Measured by the runner at its last claim{% if !r.kernel.is_empty() %}; kernel {{ r.kernel }}{% endif %}">{{ f }}</span>{% endif %}</td>
30 30
    <td class="small">{% for l in r.label_list() %}<span class="lbl">{{ l }}</span> {% endfor %}</td>
31 31
    <td class="small">{{ r.max_jobs|plural("job", "jobs") }}{% if let Some(m) = r.memory_mib %} · {{ m }} MiB{% endif %}{% if let Some(c) = r.cpus %} · {{ c }} vCPU{% endif %}</td>
32 32
    <td>{{ row.running }}</td>
…
30 unchanged lines