OXYGENHUB

Number Flow runs per repository a0d4bbfbedd1

Runs were shown and linked by the hub-wide row id, so a repository's first
run could be #37. Each run now has a number within its repository, from 1,
used by the run list, the run page (/{owner}/{name}/flow/{number}), its
live updates, the state stamp and check links.

Migration 0019 numbers existing runs per repository in creation order and
rewrites the links of existing flow checks to the new numbers; tried on a
copy of Earth's runs and checks first: every link names a run of its own
repository.

closes #23
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-28 22:19 UTC
Signature
✔ signed by Andy McCall
Hash
a0d4bbfbedd151cb19737a2893e56a5b8b1b19b286c3da4f59cc16b1bc7c9cb7
Depends on
42fa95540468 736f6813543d 9008bf6c2b8c 93be9ce4653e af9c6ca11c49 d23464c0ac60
Depended on by
5588bb3631d2 a9f1c0bf403a 55673c650986 5d90e0e045e2 1c8276b90314 070a7b64b368 4f6925337848 bf3d07541368 90326c9601f9 1edac4b68c6d
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.1.0 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
31 atoms, 1 tree operation, 8 files

Browse the tree at this change

added crates/hub/migrations/0019_flow_run_numbers.sql +10 −0
1 +
-- Runs are numbered per repository (1, 2, 3 …), not by the hub-wide row id.
2 +
ALTER TABLE flow_runs ADD COLUMN number INTEGER NOT NULL DEFAULT 0;
3 +
UPDATE flow_runs SET number = (
4 +
    SELECT COUNT(*) FROM flow_runs r2 WHERE r2.repo_id = flow_runs.repo_id AND r2.id <= flow_runs.id
5 +
);
6 +
CREATE UNIQUE INDEX flow_runs_number ON flow_runs (repo_id, number);
7 +
-- Checks link to their run's page by number now.
8 +
UPDATE checks SET url = substr(checks.url, 1, length(checks.url) - length(CAST(r.id AS TEXT))) || r.number
9 +
FROM flow_runs r
10 +
WHERE checks.repo_id = r.repo_id AND checks.name LIKE 'flow/%' AND checks.url LIKE '%/flow/' || r.id;
modified crates/hub/src/db/flow.rs +9 −3
…
6 unchanged lines
7 7
pub struct Run {
8 8
    pub id: i64,
9 9
    pub repo_id: i64,
10 +
    /// The run's number within its repository, from 1 (migration 0019).
11 +
    #[sqlx(default)]
12 +
    pub number: i64,
10 13
    pub bookmark: String,
11 14
    pub state: String,
12 15
    pub heads: String,
…
83 unchanged lines
96 99
    let now = now_ms();
97 100
    let status = if r.error.is_some() { "error" } else { "queued" };
98 101
    let id: i64 = sqlx::query_scalar(
99 -
        "INSERT INTO flow_runs (repo_id, bookmark, state, heads, pipeline_hash, status, error, actor, created_ms, finished_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id",
102 +
        "INSERT INTO flow_runs (repo_id, number, bookmark, state, heads, pipeline_hash, status, error, actor, created_ms, finished_ms) \
103 +
         VALUES (?, (SELECT COALESCE(MAX(number), 0) + 1 FROM flow_runs WHERE repo_id = ?), ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id",
100 104
    )
101 105
    .bind(r.repo_id)
106 +
    .bind(r.repo_id)
102 107
    .bind(r.bookmark)
103 108
    .bind(r.state)
104 109
    .bind(r.heads)
…
32 unchanged lines
137 142
        .await
138 143
}
139 144
140 -
pub async fn run(pool: &Pool, repo_id: i64, id: i64) -> sqlx::Result<Option<Run>> {
141 -
    sqlx::query_as("SELECT * FROM flow_runs WHERE repo_id = ? AND id = ?").bind(repo_id).bind(id).fetch_optional(pool).await
145 +
/// A repository's run by its number there.
146 +
pub async fn run(pool: &Pool, repo_id: i64, number: i64) -> sqlx::Result<Option<Run>> {
147 +
    sqlx::query_as("SELECT * FROM flow_runs WHERE repo_id = ? AND number = ?").bind(repo_id).bind(number).fetch_optional(pool).await
142 148
}
143 149
144 150
pub async fn run_by_id(pool: &Pool, id: i64) -> sqlx::Result<Option<Run>> {
…
219 unchanged lines
modified crates/hub/src/flow.rs +10 −6
…
82 unchanged lines
83 83
                .collect();
84 84
            let hash = hex::encode(p.hash());
85 85
            let id = db::flow::create_run(&app.db, db::flow::NewRun { pipeline_hash: &hash, ..base }, &jobs).await;
86 -
            if let Ok(id) = id {
86 +
            if let Ok(Some(run)) = match id {
87 +
                Ok(id) => fdb::run_by_id(&app.db, id).await,
88 +
                Err(_) => Ok(None),
89 +
            } {
87 90
                for j in &jobs {
88 -
                    report_check(app, row, state, id, &j.name, "queued", "waiting for a runner").await;
91 +
                    report_check(app, row, state, run.number, &j.name, "queued", "waiting for a runner").await;
89 92
                }
90 93
            }
91 94
            id
…
4 unchanged lines
96 99
    }
97 100
}
98 101
99 -
async fn report_check(app: &Shared, row: &db::repos::RepoRow, state: &str, run_id: i64, job: &str, status: &str, summary: &str) {
100 -
    let url = format!("{}/{}/{}/flow/{run_id}", app.config.base_url(), row.owner, row.name);
102 +
/// Record a job's check; it links to the run's page by the run's number.
103 +
async fn report_check(app: &Shared, row: &db::repos::RepoRow, state: &str, run_number: i64, job: &str, status: &str, summary: &str) {
104 +
    let url = format!("{}/{}/{}/flow/{run_number}", app.config.base_url(), row.owner, row.name);
101 105
    let name = format!("flow/{job}");
102 106
    let c = db::hooks::upsert_check(
103 107
        &app.db,
…
42 unchanged lines
146 150
    let run = fdb::run_by_id(&app.db, job.run_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
147 151
    let row =
148 152
        db::repos::get_by_id(&app.db, run.repo_id).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
149 -
    report_check(&app, &row, &run.state, run.id, &job.name, "running", &format!("running on {runner}")).await;
153 +
    report_check(&app, &row, &run.state, run.number, &job.name, "running", &format!("running on {runner}")).await;
150 154
    let bad = |e: serde_json::Error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("stored job: {e}"));
151 155
    let claim = Claim {
152 156
        job_id: job.id,
…
171 unchanged lines
324 328
                    "skipped" => "skipped: a dependency did not pass".into(),
325 329
                    _ => format!("could not run: {}", j.error),
326 330
                };
327 -
                report_check(&app, &row, &run.state, run.id, &j.name, check_status(&j.status), &summary).await;
331 +
                report_check(&app, &row, &run.state, run.number, &j.name, check_status(&j.status), &summary).await;
328 332
            }
329 333
        }
330 334
    }
…
22 unchanged lines
modified crates/hub/src/web/flow.rs +3 −3
…
286 unchanged lines
287 287
    let (heads, _) = state_of(&app, &owner, &name, &r).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
288 288
    let run = fdb::latest_for_heads(&app.db, row.id, &heads).await.map_err(|e| Error::from_db(shell.clone(), e))?;
289 289
    let to = match run {
290 -
        Some(r) => format!("/{owner}/{name}/flow/{}", r.id),
290 +
        Some(r) => format!("/{owner}/{name}/flow/{}", r.number),
291 291
        None => format!("/{owner}/{name}/flow"),
292 292
    };
293 293
    Ok(axum::response::Redirect::to(&to).into_response())
…
38 unchanged lines
332 332
    }
333 333
    let meta = serde_json::json!({
334 334
        "heads": heads.split('+').filter(|h| !h.is_empty()).collect::<Vec<_>>(),
335 -
        "run": run.map(|r| r.id),
335 +
        "run": run.map(|r| r.number),
336 336
        "status": status,
337 337
        "jobs": ordered.iter().map(|j| serde_json::json!({ "name": j.name, "status": j.status, "receipt": j.receipt_hash })).collect::<Vec<_>>(),
338 338
    });
339 339
    let title = match run {
340 -
        Some(r) => format!("OxygenFlow run #{} {status} on state {short}", r.id),
340 +
        Some(r) => format!("OxygenFlow run #{} {status} on state {short}", r.number),
341 341
        None => format!("OxygenFlow: no run for state {short}"),
342 342
    };
343 343
    format!(
…
11 unchanged lines
modified crates/hub/tests/flow.rs +25 −6
…
256 unchanged lines
257 257
        checks.iter().map(|c| (c.name.as_str(), c.status.as_str())).collect::<Vec<_>>(),
258 258
        [("flow/build", "passed"), ("flow/lint", "passed"), ("flow/test", "failed")]
259 259
    );
260 -
    assert!(checks[2].summary.contains("exit 3") && checks[2].url.ends_with(&format!("/andy/demo/flow/{}", run.id)));
260 +
    assert!(checks[2].summary.contains("exit 3") && checks[2].url.ends_with(&format!("/andy/demo/flow/{}", run.number)));
261 261
262 262
    // Pages: the list, the run with its graph and logs, and the live view.
263 263
    let (st, list) = call(&h.app, "GET", "/andy/demo/flow", None, None).await;
264 264
    assert_eq!(st, StatusCode::OK);
265 -
    assert!(list.contains(&format!("/andy/demo/flow/{}", run.id)) && list.contains("st-failed"), "{list}");
266 -
    let (st, page) = call(&h.app, "GET", &format!("/andy/demo/flow/{}", run.id), None, None).await;
265 +
    assert!(list.contains(&format!("/andy/demo/flow/{}", run.number)) && list.contains("st-failed"), "{list}");
266 +
    let (st, page) = call(&h.app, "GET", &format!("/andy/demo/flow/{}", run.number), None, None).await;
267 267
    assert_eq!(st, StatusCode::OK);
268 268
    assert!(page.contains("<svg class=\"flow-graph\""), "graph");
269 269
    assert_eq!(page.matches("data-job-node=").count(), 3);
270 270
    assert_eq!(page.matches("<path class=\"edge").count(), 1, "one dependency edge");
271 271
    assert!(page.contains("cc -o demo demo.c"), "log shown");
272 272
    assert!(!page.contains("data-flow-live"), "a finished run does not poll");
273 -
    let (st, live) = call(&h.app, "GET", &format!("/andy/demo/flow/{}/live?after={a_id}:1", run.id), None, None).await;
273 +
    let (st, live) = call(&h.app, "GET", &format!("/andy/demo/flow/{}/live?after={a_id}:1", run.number), None, None).await;
274 274
    assert_eq!(st, StatusCode::OK);
275 275
    let live: serde_json::Value = serde_json::from_str(&live).unwrap();
276 276
    assert_eq!(live["status"], "failed");
…
55 unchanged lines
332 332
    let third = third.expect("a third run");
333 333
    assert_eq!(third.status, "error");
334 334
    assert!(third.error.contains("line 18") && third.error.contains("\"missing\""), "{}", third.error);
335 -
    let (_, page) = call(&h.app, "GET", &format!("/andy/demo/flow/{}", third.id), None, None).await;
335 +
    let (_, page) = call(&h.app, "GET", &format!("/andy/demo/flow/{}", third.number), None, None).await;
336 336
    assert!(page.contains("The pipeline is not valid"));
337 337
338 338
    // State stamps (hub spec §13.2) and images in READMEs.
…
164 unchanged lines
503 503
    let h = hub().await;
504 504
    create(&h.app, "other", "plain").await;
505 505
    create(&h.app, "andy", "quiet").await;
506 +
    // A run elsewhere first, so hub-wide ids and per-repository numbers differ.
507 +
    create(&h.app, "andy", "first").await;
508 +
    {
509 +
        let work = tempfile::tempdir().unwrap();
510 +
        let c = client(&format!("{}/andy/first", h.base), work.path());
511 +
        write(work.path(), ".flow/pipeline.kdl", &pipeline(""));
512 +
        write(work.path(), "test.sh", "echo ok\n");
513 +
        c.commit("Add a pipeline", &[]).unwrap();
514 +
        assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok));
515 +
        assert_eq!(runs_of(&h.app, "andy", "first").await.len(), 1);
516 +
    }
506 517
    let row = db::repos::get(&h.app.db, "other", "plain").await.unwrap().unwrap();
507 518
    db::repos::set_flow(&h.app.db, row.id, "on").await.unwrap();
508 519
    let quiet = db::repos::get(&h.app.db, "andy", "quiet").await.unwrap().unwrap();
…
6 unchanged lines
515 526
        c.commit("Add a pipeline", &[]).unwrap();
516 527
        assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok));
517 528
    }
518 -
    assert_eq!(runs_of(&h.app, "other", "plain").await.len(), 1, "turned on");
529 +
    let plain = runs_of(&h.app, "other", "plain").await;
530 +
    assert_eq!(plain.len(), 1, "turned on");
531 +
    // Runs are numbered per repository: this is plain's first, whatever
532 +
    // other repositories ran before.
533 +
    assert_eq!(plain[0].number, 1);
534 +
    let checks = db::hooks::checks_for_state(&h.app.db, row.id, &plain[0].state).await.unwrap();
535 +
    assert!(checks.iter().all(|c| c.url.ends_with("/other/plain/flow/1")), "{checks:?}");
536 +
    let (st, page) = call(&h.app, "GET", "/other/plain/flow/1", None, None).await;
537 +
    assert!(st == StatusCode::OK && page.contains("Run #1"), "{st}");
519 538
    assert!(runs_of(&h.app, "andy", "quiet").await.is_empty(), "turned off despite andy/*");
520 539
}
modified docs/spec.md +1 −1
…
419 unchanged lines
420 420
421 421
- **Configuration.** `[flow] runner_tokens` (bearer tokens for runners, compared in constant time), `repos` (patterns such as `owner/*` or `owner/name`: the repositories whose pushes start runs by default; empty means none) and `job_timeout_minutes` (60).
422 422
- **Per repository.** A site administrator turns OxygenFlow on or off for one repository under Settings → OxygenFlow, which overrides `repos` in either direction; "site default" follows it again. Runs use the site's runners, so repository administrators see the setting but cannot change it. Changes are audited (`repo.flow`) and apply from the next push, with no restart.
423 -
- **Triggers.** A push that moves a bookmark of an allowed repository starts a **run** on the bookmark's new state: the hub reads `.flow/pipeline.kdl` from that state and validates it with `flow-core` (the same code `flow check` runs). An invalid pipeline records a run in `error` with the problems and their lines; a state without a pipeline records nothing. Tags and proposals start no runs yet, so only code from people with write access ever runs.
423 +
- **Triggers.** A push that moves a bookmark of an allowed repository starts a **run** on the bookmark's new state, numbered within its repository from 1 (the run's page is `/{owner}/{name}/flow/{number}`): the hub reads `.flow/pipeline.kdl` from that state and validates it with `flow-core` (the same code `flow check` runs). An invalid pipeline records a run in `error` with the problems and their lines; a state without a pipeline records nothing. Tags and proposals start no runs yet, so only code from people with write access ever runs.
424 424
- **Runs and jobs.** A run holds the repository, bookmark, state hash and heads, pipeline hash and pusher; one job per pipeline job with its compiled form, sandbox and dependencies. Job states: `queued`, `running`, `passed`, `failed`, `skipped` (a dependency did not pass), `error` (the runner could not run it). A run is `running` while any job is, then `passed` when every job passed, else `failed`.
425 425
- **Runner API** (runner token): `POST /api/v1/flow/claim` hands out the oldest queued job whose dependencies passed and whose tier the runner offers, marking it `running`, with a **job token**; `204` when there is none. `POST /api/v1/flow/jobs/{id}/log` appends log records (text cut at 16 KiB, at most 50 000 per job); `POST /api/v1/flow/jobs/{id}/finish` records the exit code and receipt (the hub recomputes the receipt hash) and settles dependents. Only the runner holding the job may report on it.
426 426
- **Job tokens.** Random, stored as a hash, valid only for reading the job's repository over the wire protocol while the job is running and for at most two hours. They cannot push, call the API or read other repositories.
…
158 unchanged lines
modified templates/flow_run.html +4 −4
1 1
{% extends "base.html" %}
2 2
{% import "repo_tabs.html" as m %}
3 -
{% block title %}Run #{{ run.id }} · Flow · {{ repo.owner }}/{{ repo.name }}{% endblock %}
4 -
{% block bodyattrs %} data-repo="{{ repo.url }}"{% if run.live() %} data-flow-live="{{ repo.url }}/flow/{{ run.id }}/live"{% endif %}{% endblock %}
3 +
{% block title %}Run #{{ run.number }} · Flow · {{ repo.owner }}/{{ repo.name }}{% endblock %}
4 +
{% block bodyattrs %} data-repo="{{ repo.url }}"{% if run.live() %} data-flow-live="{{ repo.url }}/flow/{{ run.number }}/live"{% endif %}{% endblock %}
5 5
{% block tabs %}{% call m::tabs(repo, "flow") %}{% endblock %}
6 6
{% block mainclass %} class="wide"{% endblock %}
7 7
{% block content %}
8 8
<div class="head">
9 -
  <h1><span class="flow-glyph st-{{ run.status }}" data-run-glyph>{{ crate::web::flow::glyph(run.status) }}</span> Run #{{ run.id }} <small data-run-status>{{ run.status }}</small></h1>
9 +
  <h1><span class="flow-glyph st-{{ run.status }}" data-run-glyph>{{ crate::web::flow::glyph(run.status) }}</span> Run #{{ run.number }} <small data-run-status>{{ run.status }}</small></h1>
10 10
  <p class="small mute"><span class="lbl lbl-bm">{{ run.bookmark }}</span> at <a href="{{ repo.url }}/tree/{{ run.heads }}" title="state {{ run.state }}"><code>{{ run.state|short }}</code></a> · pushed by {{ run.actor }} <time title="{{ run.created_ms|when_i }}">{{ run.created_ms|ago_i }}</time> · {{ duration }}{% if !run.pipeline_hash.is_empty() %} · pipeline <code title="{{ run.pipeline_hash }}">{{ run.pipeline_hash|short }}</code>{% endif %} · <a href="{{ repo.url }}/file/{{ run.heads }}/.flow/pipeline.kdl">.flow/pipeline.kdl</a> · <a href="{{ repo.url }}/flow">all runs</a></p>
11 11
</div>
12 12
{% if run.status == "error" %}
13 13
<section class="panel"><div class="strip bad">The pipeline is not valid, so nothing ran.</div><pre class="snippet">{{ run.error }}</pre><p class="small mute">Run <code>flow check</code> locally to see the same problems.</p></section>
14 14
{% else %}
15 15
<section class="panel flow-graph-box">
16 -
  <svg class="flow-graph" viewBox="0 0 {{ graph.width }} {{ graph.height }}" width="{{ graph.width }}" height="{{ graph.height }}" role="img" aria-label="Job graph of run {{ run.id }}">
16 +
  <svg class="flow-graph" viewBox="0 0 {{ graph.width }} {{ graph.height }}" width="{{ graph.width }}" height="{{ graph.height }}" role="img" aria-label="Job graph of run {{ run.number }}">
17 17
    {% for e in graph.edges %}<path class="edge st-{{ e.status }}" d="{{ e.d }}"/>{% endfor %}
18 18
    {% for n in graph.nodes %}
19 19
    <a href="#job-{{ n.j.id }}" data-job-node="{{ n.j.id }}" class="node st-{{ n.j.status }}">
…
20 unchanged lines
modified templates/flow_runs.html +1 −1
…
31 unchanged lines
32 32
  {% for row in runs %}
33 33
  <tr class="st-{{ row.r.status }}">
34 34
    <td class="st"><span class="flow-glyph st-{{ row.r.status }}" title="{{ row.r.status }}">{{ crate::web::flow::glyph(row.r.status) }}</span></td>
35 -
    <td><a href="{{ repo.url }}/flow/{{ row.r.id }}"><strong>#{{ row.r.id }}</strong></a> <span class="small mute">{{ row.r.status }}</span></td>
35 +
    <td><a href="{{ repo.url }}/flow/{{ row.r.number }}"><strong>#{{ row.r.number }}</strong></a> <span class="small mute">{{ row.r.status }}</span></td>
36 36
    <td class="flow-dots">{% for (n, s) in row.jobs %}<span class="flow-glyph st-{{ s }}" title="{{ n }}: {{ s }}">{{ crate::web::flow::glyph(s) }}</span>{% endfor %}{% if row.r.status == "error" %}<span class="small bad">pipeline invalid</span>{% endif %}</td>
37 37
    <td><span class="lbl lbl-bm">{{ row.r.bookmark }}</span></td>
38 38
    <td><a href="{{ repo.url }}/tree/{{ row.r.heads }}" title="state {{ row.r.state }}"><code>{{ row.r.state|short }}</code></a></td>
…
10 unchanged lines