OXYGENHUB

Runners: the queue on Admin → Runners, and the runner API (refs #41, part 4) b66e70b7b6c7

The Runners page shows running jobs with the runner holding each, then
queued jobs in claim order. Site administrators read GET /api/v1/runners,
/runners/{id}/jobs and /runners/queue. hub-api has the types, and the
generated TypeScript catches up (it also lacked the review requests).
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-01 02:32 UTC
Signature
✔ signed by Andy McCall
Hash
b66e70b7b6c751157888cfbba977bfd96c08598c6e70637774e679968375ffab
Depends on
42fa95540468 74be97962ed8 93be9ce4653e b64e2537d702 b6b7e4cf6b24 bf3d07541368 d23464c0ac60 df71c7892d27
Depended on by
9bbca80f777a 107eaba52c9d 55673c650986 e9b9619cd857 2fb70d019851 4d2e44d79b03 62ceb2eec18c efd29a0033e8 7d2eb590051f 7f5f6c056d8f c4c87f481d05
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, 9 files

Browse the tree at this change

modified crates/hub-api/src/lib.rs +49 −0
…
915 unchanged lines
916 916
        ("NewRelease", schemars::schema_for!(NewRelease)),
917 917
    ]
918 918
}
919 +
920 +
/// An OxygenFlow runner (`GET /api/v1/runners`, site administrators).
921 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
922 +
pub struct Runner {
923 +
    pub id: i64,
924 +
    pub name: String,
925 +
    /// `online`, `offline`, `draining` or `disabled`.
926 +
    pub status: String,
927 +
    /// What it serves: empty for the whole site, an owner, or `owner/name`.
928 +
    pub serves: String,
929 +
    pub arch: String,
930 +
    pub os: String,
931 +
    pub version: String,
932 +
    pub labels: Vec<String>,
933 +
    pub max_jobs: i64,
934 +
    pub memory_mib: Option<i64>,
935 +
    pub cpus: Option<i64>,
936 +
    /// The key that signs its receipts (KeyId hex), empty until bound.
937 +
    pub key: String,
938 +
    /// `registered` or `config` (a hub.toml token).
939 +
    pub source: String,
940 +
    pub last_seen_ms: Option<i64>,
941 +
    /// Jobs it is running now.
942 +
    pub running: i64,
943 +
}
944 +
945 +
/// A job a runner ran or is running.
946 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
947 +
pub struct RunnerJob {
948 +
    pub repo: String,
949 +
    pub run: i64,
950 +
    pub job: String,
951 +
    pub status: String,
952 +
    pub started_ms: Option<i64>,
953 +
    pub finished_ms: Option<i64>,
954 +
}
955 +
956 +
/// A job in the queue: running ones first, then queued ones in the order
957 +
/// runners claim them.
958 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
959 +
pub struct QueueItem {
960 +
    pub repo: String,
961 +
    pub run: i64,
962 +
    pub job: String,
963 +
    /// `running` or `queued`.
964 +
    pub status: String,
965 +
    /// The runner holding it, when running.
966 +
    pub runner: String,
967 +
}
modified crates/hub-api/ts/index.d.ts +8 −0
…
68 unchanged lines
69 69
  sealed: boolean;
70 70
};
71 71
72 +
export type Landed = {
73 +
  by: string;
74 +
  via: string;
75 +
  when_ms: number;
76 +
};
77 +
72 78
export type ProposalSource = {
73 79
  bookmark: string;
74 80
  kind: "bookmark";
…
18 unchanged lines
93 99
  conflicted: boolean;
94 100
  created_ms: number;
95 101
  landable: boolean;
102 +
  landed?: Landed | null;
96 103
  number: number;
97 104
  projected_heads: string[];
98 105
  projected_state?: string | null;
99 106
  repository_url: string;
107 +
  requested_reviewers?: string[];
100 108
  source: ProposalSource;
101 109
  source_url?: string | null;
102 110
  state: ProposalState;
…
382 unchanged lines
modified crates/hub/src/api/mod.rs +102 −0
…
45 unchanged lines
46 46
        .route("/projects/{owner}/{number}/cards/{id}", axum::routing::patch(projects::move_card).delete(projects::remove_card))
47 47
        .route("/device/code", post(device::code))
48 48
        .route("/device/token", post(device::token))
49 +
        .route("/runners", get(runners))
50 +
        .route("/runners/queue", get(runner_queue))
51 +
        .route("/runners/{id}/jobs", get(runner_jobs))
49 52
        .route("/user", get(me))
50 53
        .route("/version", get(version))
51 54
        .route("/notifications", get(notifications))
…
1625 unchanged lines
1677 1680
    let found = app.repos.read(repo, move |r, _| Ok(r.view()?.bookmarks.get(&b).copied())).await?;
1678 1681
    found.map(|st| st.to_hex()).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, format!("{s:?} is not a state hash, heads or a bookmark")))
1679 1682
}
1683 +
1684 +
// ----------------------------------------------------------------- runners
1685 +
1686 +
/// Site administrators only, with any token of theirs (hub spec §13.2).
1687 +
fn require_site_admin(c: &Caller) -> Result<&Account, ApiError> {
1688 +
    match c.account.as_ref() {
1689 +
        Some(a) if a.is_admin => Ok(a),
1690 +
        _ => Err(not_found("no such API route")),
1691 +
    }
1692 +
}
1693 +
1694 +
async fn runner_of(app: &Shared, r: db::runners::Runner, running: i64) -> hub_api::Runner {
1695 +
    let serves = match r.scope_repo_id {
1696 +
        Some(id) => db::repos::get_by_id(&app.db, id).await.ok().flatten().map(|p| p.slug()).unwrap_or_default(),
1697 +
        None => r.scope_owner.clone(),
1698 +
    };
1699 +
    hub_api::Runner {
1700 +
        id: r.id,
1701 +
        status: r.status().to_string(),
1702 +
        serves,
1703 +
        labels: r.label_list().into_iter().map(String::from).collect(),
1704 +
        name: r.name,
1705 +
        arch: r.arch,
1706 +
        os: r.os,
1707 +
        version: r.version,
1708 +
        max_jobs: r.max_jobs,
1709 +
        memory_mib: r.memory_mib,
1710 +
        cpus: r.cpus,
1711 +
        key: r.key,
1712 +
        source: r.source,
1713 +
        last_seen_ms: r.last_seen_ms,
1714 +
        running,
1715 +
    }
1716 +
}
1717 +
1718 +
/// `GET /api/v1/runners`.
1719 +
async fn runners(State(app): State<Shared>, headers: HeaderMap) -> Result<Json<Vec<hub_api::Runner>>, ApiError> {
1720 +
    let c = caller(&app, &headers).await?;
1721 +
    require_site_admin(&c)?;
1722 +
    let running: std::collections::HashMap<i64, i64> = db::runners::running(&app.db).await?.into_iter().collect();
1723 +
    let mut out = Vec::new();
1724 +
    for r in db::runners::list(&app.db).await? {
1725 +
        let n = running.get(&r.id).copied().unwrap_or(0);
1726 +
        out.push(runner_of(&app, r, n).await);
1727 +
    }
1728 +
    Ok(Json(out))
1729 +
}
1730 +
1731 +
#[derive(Deserialize)]
1732 +
struct BeforeQuery {
1733 +
    #[serde(default)]
1734 +
    before: Option<i64>,
1735 +
    #[serde(default)]
1736 +
    limit: Option<i64>,
1737 +
}
1738 +
1739 +
/// `GET /api/v1/runners/{id}/jobs?before=MS&limit=N`: newest first.
1740 +
async fn runner_jobs(
1741 +
    State(app): State<Shared>,
1742 +
    headers: HeaderMap,
1743 +
    Path(id): Path<i64>,
1744 +
    Query(q): Query<BeforeQuery>,
1745 +
) -> Result<Json<Vec<hub_api::RunnerJob>>, ApiError> {
1746 +
    let c = caller(&app, &headers).await?;
1747 +
    require_site_admin(&c)?;
1748 +
    db::runners::get(&app.db, id).await?.ok_or_else(|| not_found("no such runner"))?;
1749 +
    let jobs = db::runners::history(&app.db, id, q.limit.unwrap_or(50).clamp(1, 200), q.before).await?;
1750 +
    Ok(Json(
1751 +
        jobs.into_iter()
1752 +
            .map(|j| hub_api::RunnerJob {
1753 +
                repo: format!("{}/{}", j.owner, j.repo),
1754 +
                run: j.run_number,
1755 +
                job: j.job,
1756 +
                status: j.status,
1757 +
                started_ms: j.started_ms,
1758 +
                finished_ms: j.finished_ms,
1759 +
            })
1760 +
            .collect(),
1761 +
    ))
1762 +
}
1763 +
1764 +
/// `GET /api/v1/runners/queue`: running jobs, then queued ones in claim order.
1765 +
async fn runner_queue(State(app): State<Shared>, headers: HeaderMap) -> Result<Json<Vec<hub_api::QueueItem>>, ApiError> {
1766 +
    let c = caller(&app, &headers).await?;
1767 +
    require_site_admin(&c)?;
1768 +
    Ok(Json(
1769 +
        db::flow::queue(&app.db)
1770 +
            .await?
1771 +
            .into_iter()
1772 +
            .map(|e| hub_api::QueueItem {
1773 +
                repo: format!("{}/{}", e.owner, e.name),
1774 +
                run: e.run_number,
1775 +
                job: e.job,
1776 +
                status: e.status,
1777 +
                runner: e.runner,
1778 +
            })
1779 +
            .collect(),
1780 +
    ))
1781 +
}
modified crates/hub/src/db/flow.rs +3 −1
…
165 unchanged lines
166 166
    pub run_number: i64,
167 167
    pub job: String,
168 168
    pub status: String,
169 +
    /// The runner holding it, when running.
170 +
    pub runner: String,
169 171
}
170 172
171 173
/// Every job of a live run that is running or queued: running ones first,
172 174
/// then queued ones in the order runners claim them (hub spec §13.2).
173 175
pub async fn queue(pool: &Pool) -> sqlx::Result<Vec<QueueEntry>> {
174 176
    sqlx::query_as(
175 -
        "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 \
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 \
176 178
         FROM flow_jobs j JOIN flow_runs r ON r.id = j.run_id JOIN repositories p ON p.id = r.repo_id \
177 179
         WHERE j.status IN ('queued', 'running') AND r.status IN ('queued', 'running') \
178 180
         ORDER BY CASE j.status WHEN 'running' THEN 0 ELSE 1 END, j.run_id, j.depth, j.id",
…
337 unchanged lines
modified crates/hub/src/flow.rs +1 −0
…
616 unchanged lines
617 617
            run_number: run_id,
618 618
            job: job.into(),
619 619
            status: status.into(),
620 +
            runner: String::new(),
620 621
        }
621 622
    }
622 623
…
30 unchanged lines
modified crates/hub/src/web/runners.rs +4 −1
…
74 unchanged lines
75 75
    hub: String,
76 76
    notice: Option<String>,
77 77
    error: Option<String>,
78 +
    /// Running jobs, then queued ones in the order runners claim them.
79 +
    queue: Vec<db::flow::QueueEntry>,
78 80
}
79 81
80 82
async fn page(
…
12 unchanged lines
93 95
        let scope = scope_words(app, &r.scope()).await;
94 96
        rows.push(RunnerRow { running: running.get(&r.id).copied().unwrap_or(0), scope, r });
95 97
    }
96 -
    Ok(Page(RunnersPage { hub: app.config.base_url().to_string(), shell, runners: rows, pending, new_token, notice, error }))
98 +
    let queue = db::flow::queue(&app.db).await.map_err(e)?;
99 +
    Ok(Page(RunnersPage { hub: app.config.base_url().to_string(), shell, runners: rows, pending, new_token, notice, error, queue }))
97 100
}
98 101
99 102
/// `GET /admin/runners`.
…
176 unchanged lines
modified crates/hub/tests/flow.rs +19 −0
…
1085 unchanged lines
1086 1086
        page.contains("andy/demo") && page.contains(job["job"]["name"].as_str().unwrap()) && page.contains("Running now</dt><dd>1 job"),
1087 1087
        "{page}"
1088 1088
    );
1089 +
    // The queue on Admin → Runners and through the API (#41, part 4).
1090 +
    let (_, page) = web("GET", "/admin/runners".into(), None).await;
1091 +
    assert!(page.contains("<h2>Queue</h2>") && page.contains("<td>plex</td>"), "{page}");
1092 +
    let (_, tokens) = web("GET", "/settings/tokens".into(), None).await;
1093 +
    let (_, made) = web("POST", "/settings/tokens".into(), Some(format!("_csrf={}&name=admin&scopes=user%3Aread", csrf_of(&tokens)))).await;
1094 +
    let i = made.find("oxyhub_").unwrap();
1095 +
    let admin_token: String = made[i..].chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '_').collect();
1096 +
    let (st, body) = call(&h.app, "GET", "/api/v1/runners", Some(&admin_token), None).await;
1097 +
    assert_eq!(st, StatusCode::OK, "{body}");
1098 +
    let listed: serde_json::Value = serde_json::from_str(&body).unwrap();
1099 +
    let plex_row = listed.as_array().unwrap().iter().find(|r| r["name"] == "plex").unwrap();
1100 +
    assert_eq!((plex_row["running"].as_i64(), plex_row["arch"].as_str()), (Some(1), Some("x86_64")));
1101 +
    let (_, body) = call(&h.app, "GET", "/api/v1/runners/queue", Some(&admin_token), None).await;
1102 +
    let queue: serde_json::Value = serde_json::from_str(&body).unwrap();
1103 +
    assert!(queue.as_array().unwrap().iter().any(|q| q["status"] == "running" && q["runner"] == "plex"), "{queue}");
1104 +
    let (_, body) = call(&h.app, "GET", &format!("/api/v1/runners/{}/jobs", plex.id), Some(&admin_token), None).await;
1105 +
    let jobs: serde_json::Value = serde_json::from_str(&body).unwrap();
1106 +
    assert_eq!(jobs[0]["repo"], "andy/demo", "{jobs}");
1107 +
    assert_eq!(call(&h.app, "GET", "/api/v1/runners", None, None).await.0, StatusCode::NOT_FOUND, "administrators only");
1089 1108
1090 1109
    // Draining: no new work; removing while busy is refused.
1091 1110
    let (_, page) = web("GET", "/admin/runners".into(), None).await;
…
71 unchanged lines
modified docs/spec.md +2 −0
…
463 unchanged lines
464 464
    - **Disable:** its requests are refused.
465 465
    - **Resume.**
466 466
    - **Remove:** refused while the runner has running jobs. Its credential stops working, and its jobs keep its name.
467 +
  - **The queue** (on the Runners page): running jobs with the runner holding each, then queued jobs in the order runners claim them.
468 +
  - **API** (site administrators): `GET /api/v1/runners` (each runner, what it serves, its machine, key, last seen and jobs running), `GET /api/v1/runners/{id}/jobs?before=&limit=` (its history, newest first) and `GET /api/v1/runners/queue`.
467 469
  - **A runner's page** shows its totals (jobs, pass rate, time busy) and its job history, newest first, with links to runs and signed receipts. Jobs record the runner that claimed them (`flow_jobs.runner_id`).
468 470
- **Signed receipts** (Oxygen spec §14.6): `finish` may carry `signed_receipt`, the runner's signed receipt as hex. The hub keeps it in the repository when four things hold:
469 471
  - it verifies;
…
170 unchanged lines
modified templates/admin_runners.html +10 −0
…
38 unchanged lines
39 39
  {% endif %}
40 40
</section>
41 41
<section class="panel">
42 +
  <h2>Queue</h2>
43 +
  {% if queue.is_empty() %}<p class="mute small">Nothing running or waiting.</p>{% else %}
44 +
  <div class="table"><table>
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 %}
47 +
  </table></div>
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 +
  {% endif %}
50 +
</section>
51 +
<section class="panel">
42 52
  <h2>Add a runner</h2>
43 53
  <p class="small">A runner registers with a one-time token: it sends its signing identity, architecture and labels, and gets its own credential, kept in <code>runner.toml</code> beside its <code>flow.toml</code>.</p>
44 54
  <p class="small">The runner serves the whole site, one owner's repositories, or one repository; it never takes another scope's jobs.</p>
…
7 unchanged lines