OXYGENHUB

Flow: jobs that ask for runner labels go to runners with them (refs #41, part 3) c4c87f481d05

A claim takes a job only when the runner has every label its job and
sandbox name with runs-on, from the labels it reports on the claim or
registered with; the queue on Admin → Runners says which labels a waiting
job needs. oxygenflow pinned with runs-on.
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-01 03:05 UTC
Signature
✔ signed by Andy McCall
Hash
c4c87f481d05863699f00b3fff9578e7d2b07d4156ea2c7e41cbbd9f94b8d77f
Depends on
046bd71a180f 380996db84e5 42fa95540468 74be97962ed8 76bc5a058351 7974adb0be66 93be9ce4653e b66e70b7b6c7 b6b7e4cf6b24 bf3d07541368
Depended on by
107eaba52c9d e9b9619cd857 417184c73f4e 501d8b268dd4 4d2e44d79b03 efd29a0033e8 7f5f6c056d8f b0612e7dce30 070a7b64b368 2bbe88a85860
In bookmarks
api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
In tags
v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 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, 0 tree operations, 6 files

Browse the tree at this change

modified crates/hub/src/db/flow.rs +36 −2
…
153 unchanged lines
154 154
    Ok(id)
155 155
}
156 156
157 +
/// The labels a compiled job and its sandbox ask a runner for (`runs-on`).
158 +
fn labels_in(spec: &str, sandbox: &str) -> Vec<String> {
159 +
    let mut out: Vec<String> = [spec, sandbox]
160 +
        .iter()
161 +
        .filter_map(|j| serde_json::from_str::<serde_json::Value>(j).ok())
162 +
        .flat_map(|v| v["runs_on"].as_array().cloned().unwrap_or_default())
163 +
        .filter_map(|l| l.as_str().map(String::from))
164 +
        .collect();
165 +
    out.sort();
166 +
    out.dedup();
167 +
    out
168 +
}
169 +
170 +
impl Job {
171 +
    /// The runner labels this job needs.
172 +
    pub fn needs(&self) -> Vec<String> {
173 +
        labels_in(&self.spec, &self.sandbox)
174 +
    }
175 +
}
176 +
157 177
/// A job waiting for or held by a runner, with where it comes from.
158 178
#[derive(Clone, Debug, FromRow)]
159 179
pub struct QueueEntry {
…
8 unchanged lines
168 188
    pub status: String,
169 189
    /// The runner holding it, when running.
170 190
    pub runner: String,
191 +
    pub spec: String,
192 +
    pub sandbox: String,
193 +
}
194 +
195 +
impl QueueEntry {
196 +
    /// The runner labels the job needs.
197 +
    pub fn needs(&self) -> Vec<String> {
198 +
        labels_in(&self.spec, &self.sandbox)
199 +
    }
171 200
}
172 201
173 202
/// Every job of a live run that is running or queued: running ones first,
174 203
/// then queued ones in the order runners claim them (hub spec §13.2).
175 204
pub async fn queue(pool: &Pool) -> sqlx::Result<Vec<QueueEntry>> {
176 205
    sqlx::query_as(
177 -
        "SELECT j.id AS job_id, j.run_id, r.repo_id, p.owner, p.name, p.visibility, r.number AS run_number, j.name AS job, j.status, j.runner \
206 +
        "SELECT j.id AS job_id, j.run_id, r.repo_id, p.owner, p.name, p.visibility, r.number AS run_number, j.name AS job, j.status, j.runner, j.spec, j.sandbox \
178 207
         FROM flow_jobs j JOIN flow_runs r ON r.id = j.run_id JOIN repositories p ON p.id = r.repo_id \
179 208
         WHERE j.status IN ('queued', 'running') AND r.status IN ('queued', 'running') \
180 209
         ORDER BY CASE j.status WHEN 'running' THEN 0 ELSE 1 END, j.run_id, j.depth, j.id",
…
79 unchanged lines
260 289
}
261 290
262 291
pub async fn claim(pool: &Pool, runtimes: &[String], runner: &str, token_hash: &str) -> sqlx::Result<Option<Job>> {
263 -
    claim_within(pool, runtimes, runner, token_hash, Room::default(), &Default::default()).await
292 +
    claim_within(pool, runtimes, runner, token_hash, Room::default(), &Default::default(), &[]).await
264 293
}
265 294
266 295
/// The oldest runnable job that fits in `room`.
…
7 unchanged lines
274 303
    token_hash: &str,
275 304
    room: Room,
276 305
    scope: &super::runners::Scope,
306 +
    labels: &[String],
277 307
) -> sqlx::Result<Option<Job>> {
278 308
    let mut tx = super::begin_write(pool).await?;
279 309
    let queued: Vec<Job> = sqlx::query_as(
…
10 unchanged lines
290 320
    .await?;
291 321
    for j in queued {
292 322
        if !runtimes.iter().any(|r| r == &j.runtime) || !room.fits(&j.sandbox) {
323 +
            continue;
324 +
        }
325 +
        // Every label the job and its sandbox ask for (`runs-on`).
326 +
        if !j.needs().iter().all(|l| labels.contains(l)) {
293 327
            continue;
294 328
        }
295 329
        let deps = j.depends_on();
…
222 unchanged lines
modified crates/hub/src/flow.rs +10 −1
…
256 unchanged lines
257 257
    let runner = if me.source == "registered" { me.name.clone() } else { claimed };
258 258
    let secret = format!("{JOB_TOKEN_PREFIX}{}", crate::auth::random_hex(32));
259 259
    let room = fdb::Room { memory_mib: req.free_memory_mib, cpus: req.free_cpus };
260 -
    let Some(job) = fdb::claim_within(&app.db, &req.runtimes, &runner, &crate::auth::token_hash(&secret), room, &me.scope()).await? else {
260 +
    // The labels it reports now, or those it registered with.
261 +
    let labels: Vec<String> = match &req.info {
262 +
        Some(i) => i.labels.clone(),
263 +
        None => me.label_list().into_iter().map(String::from).collect(),
264 +
    };
265 +
    let Some(job) =
266 +
        fdb::claim_within(&app.db, &req.runtimes, &runner, &crate::auth::token_hash(&secret), room, &me.scope(), &labels).await?
267 +
    else {
261 268
        return Ok(StatusCode::NO_CONTENT.into_response());
262 269
    };
263 270
    db::runners::set_job_runner(&app.db, job.id, me.id).await?;
…
372 unchanged lines
636 643
            job: job.into(),
637 644
            status: status.into(),
638 645
            runner: String::new(),
646 +
            spec: String::new(),
647 +
            sandbox: String::new(),
639 648
        }
640 649
    }
641 650
…
30 unchanged lines
modified crates/hub/tests/flow.rs +39 −0
…
1288 unchanged lines
1289 1289
    let (_, page) = web("POST", format!("/orgs/acme/settings/runners/{}/remove", acme.id), Some(format!("_csrf={}", csrf_of(&page)))).await;
1290 1290
    assert!(page.contains("acme-runner removed"), "{page}");
1291 1291
}
1292 +
1293 +
/// runs-on (oxygenhub#41, part 3): a job that asks for a label goes only to
1294 +
/// a runner that has it, and waits for one otherwise.
1295 +
#[tokio::test(flavor = "multi_thread")]
1296 +
async fn jobs_that_ask_for_labels_wait_for_a_runner_with_them() {
1297 +
    use oxy_core::identity::SecretKey;
1298 +
    let h = hub().await;
1299 +
    create(&h.app, "andy", "demo").await;
1300 +
    let work = tempfile::tempdir().unwrap();
1301 +
    let c = client(&format!("{}/andy/demo", h.base), work.path());
1302 +
    let cross = "job \"cross\" {\n    sandbox \"alpine\"\n    runs-on \"riscv64\"\n    exec \"true\"\n}\n";
1303 +
    write(work.path(), ".flow/pipeline.kdl", &pipeline(cross));
1304 +
    c.snapshot("t").unwrap();
1305 +
    c.commit("pipeline", &[]).unwrap();
1306 +
    assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok));
1307 +
    assert!(!runs_of(&h.app, "andy", "demo").await.is_empty());
1308 +
    // The site runner has no labels: it takes the others, never cross.
1309 +
    let mut taken = Vec::new();
1310 +
    while let Some(j) = claim(&h.app).await {
1311 +
        taken.push(j["job"]["name"].as_str().unwrap().to_string());
1312 +
    }
1313 +
    assert!(!taken.contains(&"cross".to_string()) && taken.contains(&"lint".to_string()), "{taken:?}");
1314 +
    let queue = db::flow::queue(&h.app.db).await.unwrap();
1315 +
    let waiting = queue.iter().find(|q| q.job == "cross").unwrap();
1316 +
    assert_eq!((waiting.status.as_str(), waiting.needs()), ("queued", vec!["riscv64".to_string()]));
1317 +
    // A runner registered with the label takes it.
1318 +
    let token = "flowreg_riscv";
1319 +
    db::runners::add_registration(&h.app.db, &blake3::hash(token.as_bytes()).to_hex(), "admin", &Default::default()).await.unwrap();
1320 +
    let identity = SecretKey::generate().identity("visionfive", "", 1);
1321 +
    let info = serde_json::json!({ "arch": "riscv64", "os": "linux", "version": "0.1.0", "labels": ["riscv64"], "max_jobs": 1 });
1322 +
    let reg = serde_json::json!({ "token": token, "name": "visionfive", "identity": hex::encode(identity.encode()), "info": info });
1323 +
    let (st, body) = call(&h.app, "POST", "/api/v1/flow/runners/register", None, Some(reg.to_string())).await;
1324 +
    assert_eq!(st, StatusCode::OK, "{body}");
1325 +
    let credential = serde_json::from_str::<serde_json::Value>(&body).unwrap()["credential"].as_str().unwrap().to_string();
1326 +
    let claim_body = serde_json::json!({ "runner": "x", "runtimes": ["microvm"], "info": info }).to_string();
1327 +
    let (st, body) = call(&h.app, "POST", "/api/v1/flow/claim", Some(&credential), Some(claim_body)).await;
1328 +
    assert_eq!(st, StatusCode::OK, "{body}");
1329 +
    assert_eq!(serde_json::from_str::<serde_json::Value>(&body).unwrap()["job"]["name"], "cross");
1330 +
}
replaced deps/oxygenflow +0 −0

No line changes.

modified docs/spec.md +1 −1
…
462 unchanged lines
463 463
    - On the runner, `flow runner register https://hub --token …` sends the runner's signing identity, architecture, OS, version and labels (`POST /api/v1/flow/runners/register`) and gets its own credential (`flowrunner_…`, stored hashed).
464 464
    - The runner's key is bound at registration: receipts from that runner signed by another key are not kept.
465 465
    - Runners configured with a `hub.toml` token still work. They are tracked from their first request under the name they claim with, and their key is bound on their first signed receipt.
466 -
  - **Scopes** (oxygenhub#41): a runner serves the whole site, one owner's repositories (a user or an organisation), or one repository. The registration token carries the scope (typed as empty, `owner` or `owner/repository` when it is made) and the runner gets it; an administrator can change it on the Runners page. A claim returns only queued jobs of repositories in the runner's scope, filtered before the queue is cut short, so a scoped runner never receives another scope's jobs and never waits behind them. `hub.toml` runners serve the whole site. Changes are audited (`flow.runner.scope`). **Settings → Runners** on a repository (its admins) and on an organisation (its owners) lists the runners that may take its jobs, with what each serves; there they make a registration token for a runner of their own scope (the repository, or the organisation's repositories) and remove such runners when they run nothing. Only site administrators manage site runners.
466 +
  - **Scopes** (oxygenhub#41): a runner serves the whole site, one owner's repositories (a user or an organisation), or one repository. The registration token carries the scope (typed as empty, `owner` or `owner/repository` when it is made) and the runner gets it; an administrator can change it on the Runners page. A claim returns only queued jobs of repositories in the runner's scope, filtered before the queue is cut short, so a scoped runner never receives another scope's jobs and never waits behind them. A job whose pipeline says `runs-on` (OxygenFlow spec 3.1) goes only to a runner that has all of its job's and sandbox's labels, from those the runner reports on the claim or registered with; until one claims it, the queue says which labels it waits for. `hub.toml` runners serve the whole site. Changes are audited (`flow.runner.scope`). **Settings → Runners** on a repository (its admins) and on an organisation (its owners) lists the runners that may take its jobs, with what each serves; there they make a registration token for a runner of their own scope (the repository, or the organisation's repositories) and remove such runners when they run nothing. Only site administrators manage site runners.
467 467
  - **Each claim** carries the runner's description and marks it seen.
468 468
  - **The Runners page** lists each runner: online (seen in the last 90 s), offline, draining or disabled; what it serves; signing key; machine; labels; capacity; jobs running now; and when it was last seen.
469 469
  - **Actions:**
…
179 unchanged lines
modified templates/admin_runners.html +1 −1
…
42 unchanged lines
43 43
  {% if queue.is_empty() %}<p class="mute small">Nothing running or waiting.</p>{% else %}
44 44
  <div class="table"><table>
45 45
  <tr><th>Repository</th><th>Run</th><th>Job</th><th>Status</th><th>Runner</th></tr>
46 -
  {% for q in queue %}<tr><td><a href="/{{ q.owner }}/{{ q.name }}">{{ q.owner }}/{{ q.name }}</a></td><td><a href="/{{ q.owner }}/{{ q.name }}/flow/{{ q.run_number }}">#{{ q.run_number }}</a></td><td>{{ q.job }}</td><td>{{ q.status }}</td><td>{% if q.runner.is_empty() %}<span class="mute">waiting</span>{% else %}{{ q.runner }}{% endif %}</td></tr>{% endfor %}
46 +
  {% for q in queue %}<tr><td><a href="/{{ q.owner }}/{{ q.name }}">{{ q.owner }}/{{ q.name }}</a></td><td><a href="/{{ q.owner }}/{{ q.name }}/flow/{{ q.run_number }}">#{{ q.run_number }}</a></td><td>{{ q.job }}</td><td>{{ q.status }}</td><td>{% if q.runner.is_empty() %}<span class="mute">waiting{% let needs = q.needs() %}{% if !needs.is_empty() %} for a runner with {% for l in needs %}<span class="lbl">{{ l }}</span> {% endfor %}{% endif %}</span>{% else %}{{ q.runner }}{% endif %}</td></tr>{% endfor %}
47 47
  </table></div>
48 48
  <p class="small mute">Queued jobs are in the order runners claim them; a job waits for its dependencies and for a runner whose scope, tier and room fit it.</p>
49 49
  {% endif %}
…
12 unchanged lines