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 |