Flow priorities: site, organisation and repository levels, and expediting a run 107eaba52c9d
Runners take queued jobs by level first (urgent, high, normal, low), then by turn (fair share, ae0f1380). A run's level is its own when expedited, else its repository's (Settings > OxygenFlow priority: maintainers choose low, normal or the default, site administrators high too), else its organisation's (column only, no form yet), else the site's (Admin > Runners > Settings), else normal. A maintainer expedites a queued or running run to urgent on its page, with a reason: audited as flow.expedite, shown on the run and in the queue's Priority column, and lapsing when the run ends. Nothing running is stopped for it. Migration 0073 for SQLite and Postgres; the spec says it all. A test checks a low repository waits for a normal one, an expedited run goes first, and the site's level is the default; it and the schema test pass on Postgres too. Refs #244
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
Claude Code <[email protected]> · delegate for changes, until 2026-11-13 19:00Z · key ea5f78160088
- When
- · 2026-10-16 23:19 UTC
- Signature
- ✔ signed by Claude Code
- Hash
107eaba52c9d9a3f7f363ef1801dac423bc0f01c0da8bfe608799b12038a0489- Depends on
- 10708ab8f54d 19bb31ff1f5c 1edac4b68c6d 3b1bc27fe805 417184c73f4e 42fa95540468 4a4400c5812d 55673c650986 5d90e0e045e2 5dead1f4f26d 6ca0b971ff07 736f6813543d 745d225de344 74be97962ed8 9008bf6c2b8c 93be9ce4653e 941abdd6bb4c b64e2537d702 b66e70b7b6c7 b6b7e4cf6b24 bf3d07541368 c4c87f481d05 d23464c0ac60 d3b048b27277 e71583501b1d e9b9619cd857
- Depended on by
- b1860d6090ec ab4df6aae179 794ff94f194a 367f34b8e590 c7067c7bda56 a9f1c0bf403a 4a68f1064b08 a7aa76ac52d7 026a76d251ce 53a0fb2e4ac6 76f93eaefe45
- In bookmarks
- api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
- In tags
- 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
- 33 atoms, 2 tree operations, 15 files
Browse the tree at this change
added crates/hub/migrations-pg/0073_flow_priority.sql +7 −0
| 1 | + | -- Flow priorities (oxygenhub#244): a repository's and an organisation's |
|
| 2 | + | -- level ('' to inherit: low, normal, high), and a run's own, set only by |
|
| 3 | + | -- expediting it (urgent), with who did it and why. |
|
| 4 | + | ALTER TABLE repositories ADD COLUMN flow_priority TEXT NOT NULL DEFAULT ''; |
|
| 5 | + | ALTER TABLE organisations ADD COLUMN flow_priority TEXT NOT NULL DEFAULT ''; |
|
| 6 | + | ALTER TABLE flow_runs ADD COLUMN priority TEXT NOT NULL DEFAULT ''; |
|
| 7 | + | ALTER TABLE flow_runs ADD COLUMN priority_note TEXT NOT NULL DEFAULT ''; |
added crates/hub/migrations/0073_flow_priority.sql +7 −0
| 1 | + | -- Flow priorities (oxygenhub#244): a repository's and an organisation's |
|
| 2 | + | -- level ('' to inherit: low, normal, high), and a run's own, set only by |
|
| 3 | + | -- expediting it (urgent), with who did it and why. |
|
| 4 | + | ALTER TABLE repositories ADD COLUMN flow_priority TEXT NOT NULL DEFAULT ''; |
|
| 5 | + | ALTER TABLE organisations ADD COLUMN flow_priority TEXT NOT NULL DEFAULT ''; |
|
| 6 | + | ALTER TABLE flow_runs ADD COLUMN priority TEXT NOT NULL DEFAULT ''; |
|
| 7 | + | ALTER TABLE flow_runs ADD COLUMN priority_note TEXT NOT NULL DEFAULT ''; |
modified crates/hub/src/db/flow.rs +51 −9
| … | 21 unchanged lines |
||
| 22 | 22 | pub actor: String, |
|
| 23 | 23 | pub created_ms: i64, |
|
| 24 | 24 | pub finished_ms: Option<i64>, |
|
| 25 | + | /// `urgent` when expedited (oxygenhub#244), else empty: the run follows |
|
| 26 | + | /// its repository's, organisation's or the site's level. |
|
| 27 | + | #[sqlx(default)] |
|
| 28 | + | pub priority: String, |
|
| 29 | + | /// Who expedited it and why. |
|
| 30 | + | #[sqlx(default)] |
|
| 31 | + | pub priority_note: String, |
|
| 32 | + | } |
|
| 33 | + | ||
| 34 | + | /// The priority levels, most urgent first (oxygenhub#244). |
|
| 35 | + | pub const PRIORITIES: [&str; 4] = ["urgent", "high", "normal", "low"]; |
|
| 36 | + | ||
| 37 | + | /// SQL for a queued job's level as a rank (0 urgent … 3 low): the run's |
|
| 38 | + | /// own (expedited), else its repository's, its organisation's, the site's |
|
| 39 | + | /// (`settings.flow_priority`), else normal. Needs `r` (the run) and `rep` |
|
| 40 | + | /// (its repository) in the query. |
|
| 41 | + | pub const PRIORITY_RANK: &str = "CASE COALESCE(NULLIF(r.priority, ''), NULLIF(rep.flow_priority, ''), \ |
|
| 42 | + | NULLIF((SELECT o.flow_priority FROM organisations o WHERE o.name = rep.owner), ''), \ |
|
| 43 | + | NULLIF((SELECT s.value FROM settings s WHERE s.key = 'flow_priority'), ''), 'normal') \ |
|
| 44 | + | WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'low' THEN 3 ELSE 2 END"; |
|
| 45 | + | ||
| 46 | + | /// Raise run `run_id` to urgent, saying who and why; a run already ended |
|
| 47 | + | /// is left alone. Whether it changed. |
|
| 48 | + | pub async fn expedite(pool: &Pool, run_id: i64, note: &str) -> sqlx::Result<bool> { |
|
| 49 | + | let r = sqlx::query("UPDATE flow_runs SET priority = 'urgent', priority_note = $1 WHERE id = $2 AND status IN ('queued', 'running')") |
|
| 50 | + | .bind(note) |
|
| 51 | + | .bind(run_id) |
|
| 52 | + | .execute(pool) |
|
| 53 | + | .await?; |
|
| 54 | + | Ok(r.rows_affected() > 0) |
|
| 25 | 55 | } |
|
| 26 | 56 | ||
| 27 | 57 | impl Run { |
|
| … | 261 unchanged lines |
||
| 289 | 319 | pub sandbox: String, |
|
| 290 | 320 | /// When it started running. |
|
| 291 | 321 | pub started_ms: Option<i64>, |
|
| 322 | + | /// Its level (0 urgent … 3 low, oxygenhub#244) and, when expedited, who |
|
| 323 | + | /// did it and why. |
|
| 324 | + | pub priority_rank: i64, |
|
| 325 | + | pub priority_note: String, |
|
| 292 | 326 | } |
|
| 293 | 327 | ||
| 294 | 328 | impl QueueEntry { |
|
| 329 | + | pub fn priority(&self) -> &'static str { |
|
| 330 | + | PRIORITIES.get(self.priority_rank as usize).copied().unwrap_or("normal") |
|
| 331 | + | } |
|
| 332 | + | } |
|
| 333 | + | ||
| 334 | + | impl QueueEntry { |
|
| 295 | 335 | /// The runner labels the job needs. |
|
| 296 | 336 | pub fn needs(&self) -> Vec<String> { |
|
| 297 | 337 | labels_in(&self.spec, &self.sandbox) |
|
| … | 8 unchanged lines |
||
| 306 | 346 | /// Every job of a live run that is running or queued: running ones first, |
|
| 307 | 347 | /// then queued ones in the order runners claim them (hub spec §13.2). |
|
| 308 | 348 | pub async fn queue(pool: &Pool) -> sqlx::Result<Vec<QueueEntry>> { |
|
| 309 | - | sqlx::query_as( |
|
| 310 | - | "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, j.started_ms \ |
|
| 311 | - | FROM flow_jobs j JOIN flow_runs r ON r.id = j.run_id JOIN repositories p ON p.id = r.repo_id \ |
|
| 349 | + | sqlx::query_as(&format!( |
|
| 350 | + | "SELECT j.id AS job_id, j.run_id, r.repo_id, rep.owner, rep.name, rep.visibility, r.number AS run_number, j.name AS job, j.status, j.runner, j.spec, j.sandbox, j.started_ms, \ |
|
| 351 | + | CAST({PRIORITY_RANK} AS BIGINT) AS priority_rank, r.priority_note \ |
|
| 352 | + | FROM flow_jobs j JOIN flow_runs r ON r.id = j.run_id JOIN repositories rep ON rep.id = r.repo_id \ |
|
| 312 | 353 | WHERE j.status IN ('queued', 'running') AND j.approval != 'waiting' AND r.status IN ('queued', 'running') \ |
|
| 313 | 354 | ORDER BY CASE j.status WHEN 'running' THEN 0 ELSE 1 END, \ |
|
| 355 | + | CASE j.status WHEN 'running' THEN 0 ELSE {PRIORITY_RANK} END, \ |
|
| 314 | 356 | CASE j.status WHEN 'running' THEN 0 ELSE ROW_NUMBER() OVER (PARTITION BY r.repo_id, j.status ORDER BY j.run_id, j.depth, j.id) \ |
|
| 315 | 357 | + (SELECT COUNT(*) FROM flow_jobs x JOIN flow_runs xr ON xr.id = x.run_id WHERE x.status = 'running' AND xr.repo_id = r.repo_id) END, \ |
|
| 316 | - | j.run_id, j.depth, j.id", |
|
| 317 | - | ) |
|
| 358 | + | j.run_id, j.depth, j.id" |
|
| 359 | + | )) |
|
| 318 | 360 | .fetch_all(pool) |
|
| 319 | 361 | .await |
|
| 320 | 362 | } |
|
| … | 201 unchanged lines |
||
| 522 | 564 | elsewhere: &(dyn Fn(&Job) -> bool + Sync), |
|
| 523 | 565 | ) -> sqlx::Result<Option<Job>> { |
|
| 524 | 566 | let mut tx = super::begin_write(pool).await?; |
|
| 525 | - | let queued: Vec<Job> = sqlx::query_as( |
|
| 567 | + | let queued: Vec<Job> = sqlx::query_as(&format!( |
|
| 526 | 568 | "SELECT j.* FROM flow_jobs j JOIN flow_runs r ON r.id = j.run_id JOIN repositories rep ON rep.id = r.repo_id \ |
|
| 527 | 569 | WHERE j.status = 'queued' AND j.approval != 'waiting' AND r.status IN ('queued', 'running') \ |
|
| 528 | 570 | AND ($1 = '' OR rep.owner = $2) AND ($3 IS NULL OR r.repo_id = $4) \ |
|
| 529 | 571 | AND NOT ($5 = '' AND $6 IS NULL AND rep.flow = 'own') \ |
|
| 530 | - | ORDER BY ROW_NUMBER() OVER (PARTITION BY r.repo_id ORDER BY j.run_id, j.depth, j.id) \ |
|
| 572 | + | ORDER BY {PRIORITY_RANK}, ROW_NUMBER() OVER (PARTITION BY r.repo_id ORDER BY j.run_id, j.depth, j.id) \ |
|
| 531 | 573 | + (SELECT COUNT(*) FROM flow_jobs x JOIN flow_runs xr ON xr.id = x.run_id WHERE x.status = 'running' AND xr.repo_id = r.repo_id), \ |
|
| 532 | - | j.run_id, j.depth, j.id LIMIT 200", |
|
| 533 | - | ) |
|
| 574 | + | j.run_id, j.depth, j.id LIMIT 200" |
|
| 575 | + | )) |
|
| 534 | 576 | .bind(&scope.owner) |
|
| 535 | 577 | .bind(&scope.owner) |
|
| 536 | 578 | .bind(scope.repo_id) |
|
| … | 620 unchanged lines |
modified crates/hub/src/db/repos.rs +9 −0
| … | 26 unchanged lines |
||
| 27 | 27 | /// OxygenFlow: `on`, `off`, or empty to follow hub.toml (migration 0018). |
|
| 28 | 28 | #[sqlx(default)] |
|
| 29 | 29 | pub flow: String, |
|
| 30 | + | /// Its jobs' priority: `low`, `normal`, `high`, or empty to follow its |
|
| 31 | + | /// organisation's and the site's (oxygenhub#244, migration 0073). |
|
| 32 | + | #[sqlx(default)] |
|
| 33 | + | pub flow_priority: String, |
|
| 30 | 34 | /// Diffusion: `off`, `green`, `any`, or empty to follow hub.toml |
|
| 31 | 35 | /// (migration 0058, #148). |
|
| 32 | 36 | #[sqlx(default)] |
|
| … | 285 unchanged lines |
||
| 318 | 322 | /// `on`, `off`, or `` (follow hub.toml's `[flow] repos`). |
|
| 319 | 323 | pub async fn set_flow(pool: &Pool, id: i64, setting: &str) -> sqlx::Result<()> { |
|
| 320 | 324 | sqlx::query("UPDATE repositories SET flow = $1 WHERE id = $2").bind(setting).bind(id).execute(pool).await?; |
|
| 325 | + | Ok(()) |
|
| 326 | + | } |
|
| 327 | + | ||
| 328 | + | pub async fn set_flow_priority(pool: &Pool, id: i64, priority: &str) -> sqlx::Result<()> { |
|
| 329 | + | sqlx::query("UPDATE repositories SET flow_priority = $1 WHERE id = $2").bind(priority).bind(id).execute(pool).await?; |
|
| 321 | 330 | Ok(()) |
|
| 322 | 331 | } |
|
| 323 | 332 | ||
| … | 39 unchanged lines |
modified crates/hub/src/flow.rs +2 −0
| … | 2395 unchanged lines |
||
| 2396 | 2396 | spec: String::new(), |
|
| 2397 | 2397 | sandbox: String::new(), |
|
| 2398 | 2398 | started_ms: None, |
|
| 2399 | + | priority_rank: 2, |
|
| 2400 | + | priority_note: String::new(), |
|
| 2399 | 2401 | } |
|
| 2400 | 2402 | } |
|
| 2401 | 2403 | ||
| … | 96 unchanged lines |
modified crates/hub/src/web/flow.rs +43 −0
| … | 254 unchanged lines |
||
| 255 | 255 | }) |
|
| 256 | 256 | } |
|
| 257 | 257 | ||
| 258 | + | #[derive(Deserialize)] |
|
| 259 | + | pub struct ExpediteForm { |
|
| 260 | + | #[serde(rename = "_csrf")] |
|
| 261 | + | csrf_token: String, |
|
| 262 | + | #[serde(default)] |
|
| 263 | + | reason: String, |
|
| 264 | + | } |
|
| 265 | + | ||
| 266 | + | /// `POST /{owner}/{repo}/flow/{n}/expedite` (oxygenhub#244): a maintainer |
|
| 267 | + | /// raises a running or queued run to urgent, saying why (an incident's |
|
| 268 | + | /// fix, say). Audited and shown on the run; it lapses when the run ends. |
|
| 269 | + | pub async fn expedite( |
|
| 270 | + | State(app): State<Shared>, |
|
| 271 | + | viewer: Viewer, |
|
| 272 | + | Path((owner, name, number)): Path<(String, String, i64)>, |
|
| 273 | + | Form(f): Form<ExpediteForm>, |
|
| 274 | + | ) -> Result<Response, Error> { |
|
| 275 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/flow/{number}"), &viewer); |
|
| 276 | + | let me = require(&viewer, &shell)?; |
|
| 277 | + | let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; |
|
| 278 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 279 | + | let run = fdb::run(&app.db, row.id, number) |
|
| 280 | + | .await |
|
| 281 | + | .map_err(|e| Error::from_db(shell.clone(), e))? |
|
| 282 | + | .ok_or_else(|| Error::not_found(shell.clone()))?; |
|
| 283 | + | let reason: String = f.reason.chars().filter(|c| !c.is_control()).take(200).collect(); |
|
| 284 | + | let reason = reason.trim(); |
|
| 285 | + | if reason.is_empty() { |
|
| 286 | + | return Ok(Redirect::to(&format!("/{owner}/{name}/flow?error={}", urlencoding("Say why the run is urgent."))).into_response()); |
|
| 287 | + | } |
|
| 288 | + | let note = format!("expedited by {}: {reason}", me.account.username); |
|
| 289 | + | if fdb::expedite(&app.db, run.id, ¬e).await.map_err(|e| Error::from_db(shell.clone(), e))? { |
|
| 290 | + | db::accounts::audit(&app.db, Some(&me.account), "flow.expedite", &format!("{owner}/{name}"), &format!("#{number}: {reason}")) |
|
| 291 | + | .await |
|
| 292 | + | .ok(); |
|
| 293 | + | } |
|
| 294 | + | Ok(Redirect::to(&format!("/{owner}/{name}/flow/{number}")).into_response()) |
|
| 295 | + | } |
|
| 296 | + | ||
| 258 | 297 | /// Percent-encode a message for a query string. |
|
| 259 | 298 | fn urlencoding(s: &str) -> String { |
|
| 260 | 299 | s.bytes() |
|
| … | 107 unchanged lines |
||
| 368 | 407 | can_rerun: bool, |
|
| 369 | 408 | /// Whether the viewer may cancel it. |
|
| 370 | 409 | can_cancel: bool, |
|
| 410 | + | /// May raise the run to urgent (oxygenhub#244): maintainers, while it runs. |
|
| 411 | + | can_expedite: bool, |
|
| 371 | 412 | /// Where a live run stands among the runners' work. |
|
| 372 | 413 | queue: Option<crate::flow::QueueInfo>, |
|
| 373 | 414 | /// Releases a writer may attach this run's artifacts to. |
|
| … | 99 unchanged lines |
||
| 473 | 514 | let can_write = viewer.account().is_some() && role >= Role::Write; |
|
| 474 | 515 | let can_rerun = !run.live() && can_write && crate::flow::enabled(&app, &row); |
|
| 475 | 516 | let can_cancel = run.live() && can_write; |
|
| 517 | + | let can_expedite = run.live() && run.priority.is_empty() && viewer.account().is_some() && role >= Role::Maintain; |
|
| 476 | 518 | let queue = if run.live() { |
|
| 477 | 519 | let q = fdb::queue(&app.db).await.unwrap_or_default(); |
|
| 478 | 520 | Some(crate::flow::queue_info(&q, row.id, run.id)).filter(|i| i.position.is_some()) |
|
| … | 14 unchanged lines |
||
| 493 | 535 | duration, |
|
| 494 | 536 | can_rerun, |
|
| 495 | 537 | can_cancel, |
|
| 538 | + | can_expedite, |
|
| 496 | 539 | queue, |
|
| 497 | 540 | releases, |
|
| 498 | 541 | approvals, |
|
| … | 400 unchanged lines |
modified crates/hub/src/web/mod.rs +3 −0
| … | 144 unchanged lines |
||
| 145 | 145 | .route("/admin/storage", get(admin::storage).post(admin::maintain)) |
|
| 146 | 146 | .route("/admin/runners", get(runners::list)) |
|
| 147 | 147 | .route("/admin/runners/settings", get(runners::settings).post(runners::save_settings)) |
|
| 148 | + | .route("/admin/runners/priority", post(runners::save_priority)) |
|
| 148 | 149 | .route("/admin/runners/images", get(runners::images)) |
|
| 149 | 150 | .route("/admin/diffusion", get(diffusion::admin)) |
|
| 150 | 151 | .route("/admin/backups", get(backups::admin)) |
|
| … | 117 unchanged lines |
||
| 268 | 269 | .route("/{owner}/{name}/flow/{id}/jobs/{job}/rerun", post(flow::rerun_job)) |
|
| 269 | 270 | .route("/{owner}/{name}/flow/{id}/jobs/{job}/decide", post(flow::decide)) |
|
| 270 | 271 | .route("/{owner}/{name}/flow/{id}/cancel", post(flow::cancel)) |
|
| 272 | + | .route("/{owner}/{name}/flow/{id}/expedite", post(flow::expedite)) |
|
| 271 | 273 | .route("/{owner}/{name}/flow/stamp/{r}", get(flow::stamp)) |
|
| 272 | 274 | .route("/{owner}/{name}/flow/at/{r}", get(flow::at)) |
|
| 273 | 275 | .route("/{owner}/{name}/receipts/{attest}", get(flow::receipt_file)) |
|
| … | 18 unchanged lines |
||
| 292 | 294 | .route("/{owner}/{name}/git-receive-pack", post(git_receive_pack)) |
|
| 293 | 295 | .route("/{owner}/{name}/settings/mirror", post(repo_settings::mirror)) |
|
| 294 | 296 | .route("/{owner}/{name}/settings/flow", post(repo_settings::flow)) |
|
| 297 | + | .route("/{owner}/{name}/settings/flow-priority", post(repo_settings::flow_priority)) |
|
| 295 | 298 | .route("/{owner}/{name}/settings/diffusion", post(repo_settings::diffusion)) |
|
| 296 | 299 | .route("/{owner}/{name}/settings/backup", post(repo_settings::backup)) |
|
| 297 | 300 | .route("/{owner}/{name}/settings/storage", post(repo_settings::storage)) |
|
| … | 493 unchanged lines |
modified crates/hub/src/web/repo_settings.rs +47 −0
| … | 848 unchanged lines |
||
| 849 | 849 | } |
|
| 850 | 850 | ||
| 851 | 851 | #[derive(Deserialize)] |
|
| 852 | + | pub struct PriorityForm { |
|
| 853 | + | #[serde(rename = "_csrf")] |
|
| 854 | + | csrf_token: String, |
|
| 855 | + | /// `low`, `normal`, `high`, or `default`. |
|
| 856 | + | priority: String, |
|
| 857 | + | } |
|
| 858 | + | ||
| 859 | + | /// `POST /{owner}/{repo}/settings/flow-priority` (oxygenhub#244): its jobs' |
|
| 860 | + | /// level. Maintainers choose low, normal or the default; only site |
|
| 861 | + | /// administrators raise a repository to high. |
|
| 862 | + | pub async fn flow_priority( |
|
| 863 | + | State(app): State<Shared>, |
|
| 864 | + | viewer: Viewer, |
|
| 865 | + | Path((owner, name)): Path<(String, String)>, |
|
| 866 | + | Form(f): Form<PriorityForm>, |
|
| 867 | + | ) -> Result<Page<RepoSettingsPage>, Error> { |
|
| 868 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); |
|
| 869 | + | let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; |
|
| 870 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 871 | + | let level = match f.priority.as_str() { |
|
| 872 | + | "low" | "normal" | "high" => f.priority.as_str(), |
|
| 873 | + | "default" => "", |
|
| 874 | + | _ => return settings_page(&app, shell, row, role, None, Some("Pick low, normal, high or the default.".into())).await, |
|
| 875 | + | }; |
|
| 876 | + | if level == "high" && !viewer.is_admin() { |
|
| 877 | + | return settings_page(&app, shell, row, role, None, Some("Only site administrators raise a repository to high.".into())).await; |
|
| 878 | + | } |
|
| 879 | + | db::repos::set_flow_priority(&app.db, row.id, level).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 880 | + | accounts::audit( |
|
| 881 | + | &app.db, |
|
| 882 | + | viewer.account(), |
|
| 883 | + | "repo.flow_priority", |
|
| 884 | + | &format!("{owner}/{name}"), |
|
| 885 | + | if level.is_empty() { "default" } else { level }, |
|
| 886 | + | ) |
|
| 887 | + | .await |
|
| 888 | + | .ok(); |
|
| 889 | + | let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row); |
|
| 890 | + | let notice = if level.is_empty() { |
|
| 891 | + | "Its jobs follow the organisation's and the site's priority.".to_string() |
|
| 892 | + | } else { |
|
| 893 | + | format!("Its jobs are {level} priority.") |
|
| 894 | + | }; |
|
| 895 | + | settings_page(&app, shell, row, role, Some(notice), None).await |
|
| 896 | + | } |
|
| 897 | + | ||
| 898 | + | #[derive(Deserialize)] |
|
| 852 | 899 | pub struct FlowForm { |
|
| 853 | 900 | #[serde(rename = "_csrf")] |
|
| 854 | 901 | csrf_token: String, |
|
| … | 203 unchanged lines |
modified crates/hub/src/web/runners.rs +35 −0
| … | 445 unchanged lines |
||
| 446 | 446 | /// What hub.toml says, shown beside a setting the page overrides. |
|
| 447 | 447 | file_default_m: u64, |
|
| 448 | 448 | flow_repos: Vec<String>, |
|
| 449 | + | /// The site's priority for jobs (oxygenhub#244): low, normal or high. |
|
| 450 | + | priority: String, |
|
| 449 | 451 | notice: Option<String>, |
|
| 450 | 452 | error: Option<String>, |
|
| 451 | 453 | } |
|
| … | 7 unchanged lines |
||
| 459 | 461 | grace_m: l.grace_ms / 60_000, |
|
| 460 | 462 | file_default_m: app.config.flow.job_timeout_minutes, |
|
| 461 | 463 | flow_repos: app.config.flow.repos.clone(), |
|
| 464 | + | priority: accounts::setting(&app.db, "flow_priority") |
|
| 465 | + | .await |
|
| 466 | + | .ok() |
|
| 467 | + | .flatten() |
|
| 468 | + | .filter(|p| !p.is_empty()) |
|
| 469 | + | .unwrap_or_else(|| "normal".into()), |
|
| 462 | 470 | notice, |
|
| 463 | 471 | error, |
|
| 464 | 472 | }) |
|
| 465 | 473 | } |
|
| 466 | 474 | ||
| 475 | + | #[derive(Deserialize)] |
|
| 476 | + | pub struct PriorityForm { |
|
| 477 | + | #[serde(rename = "_csrf")] |
|
| 478 | + | csrf_token: String, |
|
| 479 | + | priority: String, |
|
| 480 | + | } |
|
| 481 | + | ||
| 482 | + | /// `POST /admin/runners/priority` (oxygenhub#244): the level every job has |
|
| 483 | + | /// unless its organisation, repository or run says otherwise. |
|
| 484 | + | pub async fn save_priority(State(app): State<Shared>, viewer: Viewer, Form(f): Form<PriorityForm>) -> Result<Page<SettingsPage>, Error> { |
|
| 485 | + | let shell = Shell::new(&app, "/admin/runners/settings", &viewer); |
|
| 486 | + | let me = require_admin(&viewer, &shell)?; |
|
| 487 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 488 | + | if !["low", "normal", "high"].contains(&f.priority.as_str()) { |
|
| 489 | + | return Ok(settings_page(&app, shell, None, Some("Pick low, normal or high (urgent is for expediting one run).".into())).await); |
|
| 490 | + | } |
|
| 491 | + | accounts::set_setting(&app.db, "flow_priority", &f.priority).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 492 | + | accounts::audit(&app.db, Some(&me), "flow.priority", "site", &f.priority).await.ok(); |
|
| 493 | + | Ok(settings_page( |
|
| 494 | + | &app, |
|
| 495 | + | shell, |
|
| 496 | + | Some(format!("Saved: jobs are {} unless their organisation, repository or run says otherwise.", f.priority)), |
|
| 497 | + | None, |
|
| 498 | + | ) |
|
| 499 | + | .await) |
|
| 500 | + | } |
|
| 501 | + | ||
| 467 | 502 | /// `GET /admin/runners/settings` (oxygenhub#89). |
|
| 468 | 503 | pub async fn settings(State(app): State<Shared>, viewer: Viewer) -> Result<Page<SettingsPage>, Error> { |
|
| 469 | 504 | let shell = Shell::new(&app, "/admin/runners/settings", &viewer); |
|
| … | 189 unchanged lines |
modified crates/hub/tests/flow.rs +38 −0
| … | 4625 unchanged lines |
||
| 4626 | 4626 | }; |
|
| 4627 | 4627 | assert_eq!(order, ["andy/busy", "andy/quiet", "andy/busy"]); |
|
| 4628 | 4628 | } |
|
| 4629 | + | ||
| 4630 | + | /// Priority comes before taking turns (oxygenhub#244): a low repository's |
|
| 4631 | + | /// jobs wait for a normal one's, and an expedited run goes first of all. |
|
| 4632 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 4633 | + | async fn priority_then_turns_and_an_expedited_run_first() { |
|
| 4634 | + | let h = hub().await; |
|
| 4635 | + | for name in ["early", "later"] { |
|
| 4636 | + | create(&h.app, "andy", name).await; |
|
| 4637 | + | let work = tempfile::tempdir().unwrap(); |
|
| 4638 | + | let c = client(&format!("{}/andy/{name}", h.base), work.path()); |
|
| 4639 | + | write(work.path(), ".flow/pipeline.kdl", &pipeline("")); |
|
| 4640 | + | write(work.path(), "test.sh", "echo ok\n"); |
|
| 4641 | + | c.snapshot("test").unwrap(); |
|
| 4642 | + | c.commit("Add a pipeline", &[]).unwrap(); |
|
| 4643 | + | assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 4644 | + | assert_eq!(runs_of(&h.app, "andy", name).await.len(), 1); |
|
| 4645 | + | } |
|
| 4646 | + | let early = db::repos::get(&h.app.db, "andy", "early").await.unwrap().unwrap(); |
|
| 4647 | + | db::repos::set_flow_priority(&h.app.db, early.id, "low").await.unwrap(); |
|
| 4648 | + | // early pushed first, but it is low: later's jobs go first. |
|
| 4649 | + | let repo_of = |c: serde_json::Value| c["repo"].as_str().unwrap().to_string(); |
|
| 4650 | + | assert_eq!(repo_of(claim(&h.app).await.unwrap()), "andy/later"); |
|
| 4651 | + | assert_eq!(repo_of(claim(&h.app).await.unwrap()), "andy/later"); |
|
| 4652 | + | // Expedited, early's run goes before anything else. |
|
| 4653 | + | let run = &runs_of(&h.app, "andy", "early").await[0]; |
|
| 4654 | + | assert!(db::flow::expedite(&h.app.db, run.id, "expedited by andy: an incident").await.unwrap()); |
|
| 4655 | + | assert_eq!(repo_of(claim(&h.app).await.unwrap()), "andy/early"); |
|
| 4656 | + | let q = db::flow::queue(&h.app.db).await.unwrap(); |
|
| 4657 | + | assert!( |
|
| 4658 | + | q.iter().any(|e| e.name == "early" && e.priority() == "urgent" && e.priority_note.contains("an incident")), |
|
| 4659 | + | "{:?}", |
|
| 4660 | + | q.iter().map(|e| (&e.name, e.priority())).collect::<Vec<_>>() |
|
| 4661 | + | ); |
|
| 4662 | + | // The site's level is the default for everyone else. |
|
| 4663 | + | db::accounts::set_setting(&h.app.db, "flow_priority", "high").await.unwrap(); |
|
| 4664 | + | let q = db::flow::queue(&h.app.db).await.unwrap(); |
|
| 4665 | + | assert!(q.iter().filter(|e| e.name == "later").all(|e| e.priority() == "high")); |
|
| 4666 | + | } |
modified docs/spec.md +1 −0
| … | 481 unchanged lines |
||
| 482 | 482 | ||
| 483 | 483 | - **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) `job_timeout_minutes` (60) and `drain_after_errors` (5, 0 for never: see Alerts). |
|
| 484 | 484 | - **Alerts** (oxygenflow#30). A run that ends failed tells its pusher and the repository's watchers, in their inboxes and by email as each chose (`flow.failed`, from the `oxygenflow` account, which cannot sign in), naming the jobs that did not pass. A runner whose last `drain_after_errors` jobs since its state last changed all ended in an infrastructure error (status `error`: it could not run them, not a failing test) is set to draining and the site administrators are told (`flow.runner_drained`, with the last error); making it active again on Admin › Runners starts the count afresh (migration 0064, `state_ms`). |
|
| 485 | + | - **Priority and turns** (oxygenhub#244). Runners take queued jobs by level first (`urgent`, `high`, `normal`, `low`), then by turn: a job's place in its own repository's queue plus that repository's running jobs, oldest first among equals, so repositories take turns and one repository's burst can't hold up the rest. A run's level is its own if it was expedited, else its repository's (Settings › OxygenFlow priority; maintainers choose low, normal or the default, site administrators also high), else its organisation's (`organisations.flow_priority`, no form yet), else the site's (Admin › Runners › Settings), else normal. A maintainer expedites a queued or running run to urgent on its page with a reason; it is audited (`flow.expedite`), shown on the run and in the queue, and lapses when the run ends. Nothing running is stopped for a more urgent job (migration 0073). |
|
| 485 | 486 | - **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. |
|
| 486 | 487 | - **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. A run waiting for a runner says where it stands: on the Flow tab its place in line, on its page also the jobs running now and those queued ahead of it (named when their repository is this one or public, otherwise counted), and in the API `queue_position`. Writers also start runs without moving a bookmark: **Run pipeline** runs a committed state without a push: |
|
| 487 | 488 | - On the Flow tab and beside each bookmark on the bookmarks page, it runs a bookmark's current state. |
|
| … | 272 unchanged lines |
modified templates/admin_runner_settings.html +8 −0
| … | 19 unchanged lines |
||
| 20 | 20 | <p class="small mute">A runner stops a job at its limit and says so at once; a job still running this long after is one its runner no longer reports, and the hub ends it.</p> |
|
| 21 | 21 | <button type="submit">Save</button> |
|
| 22 | 22 | </form> |
|
| 23 | + | <form class="card wide" id="priority" method="post" action="/admin/runners/priority"> |
|
| 24 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 25 | + | <h2>Priority</h2> |
|
| 26 | + | <p>Runners take higher levels first; within a level, repositories take turns. An organisation or repository can set its own level, and a maintainer can expedite one run to urgent, with a reason.</p> |
|
| 27 | + | <label for="priority-site">The site's level</label> |
|
| 28 | + | <select id="priority-site" name="priority">{% for p in ["low", "normal", "high"] %}<option{% if *p == priority %} selected{% endif %}>{{ p }}</option>{% endfor %}</select> |
|
| 29 | + | <button type="submit">Save</button> |
|
| 30 | + | </form> |
|
| 23 | 31 | <section class="panel"> |
|
| 24 | 32 | <h2>Repositories that run Flow</h2> |
|
| 25 | 33 | {% if flow_repos.is_empty() %}<p class="mute">None listed in hub.toml; each repository's administrator switches Flow on in its Settings.</p>{% else %}<ul class="plain">{% for r in flow_repos %}<li><code>{{ r }}</code></li>{% endfor %}</ul><p class="small mute">From hub.toml's <code>[flow] repos</code>; others are switched on in each repository's Settings.</p>{% endif %} |
|
| … | 2 unchanged lines |
modified templates/admin_runners.html +2 −2
| … | 48 unchanged lines |
||
| 49 | 49 | </form> |
|
| 50 | 50 | {% if queue.is_empty() %}<p class="mute small">{% if filter.any() %}Nothing matches.{% else %}Nothing running or waiting.{% endif %}</p>{% else %} |
|
| 51 | 51 | <div class="table"><table> |
|
| 52 | - | <tr><th>Repository</th><th>Run</th><th>Job</th><th>Status</th><th>Started</th><th>Runner</th></tr> |
|
| 53 | - | {% 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 class="small">{% if let Some(t) = q.started_ms %}<time title="{{ t|when_i }}">{{ t|ago_i }}</time>{% endif %}</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 %}{% if let Some(a) = q.arch() %} on <span class="lbl">{{ a }}</span>{% endif %}</span>{% else %}<a href="/admin/runners?runner={{ q.runner|urlencode }}#queue">{{ q.runner }}</a>{% endif %}</td></tr>{% endfor %} |
|
| 52 | + | <tr><th>Repository</th><th>Run</th><th>Job</th><th>Status</th><th>Priority</th><th>Started</th><th>Runner</th></tr> |
|
| 53 | + | {% 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 class="small"{% if !q.priority_note.is_empty() %} title="{{ q.priority_note }}"{% endif %}>{% if q.priority() == "normal" %}<span class="mute">normal</span>{% else %}<span class="tag">{{ q.priority() }}</span>{% endif %}</td><td class="small">{% if let Some(t) = q.started_ms %}<time title="{{ t|when_i }}">{{ t|ago_i }}</time>{% endif %}</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 %}{% if let Some(a) = q.arch() %} on <span class="lbl">{{ a }}</span>{% endif %}</span>{% else %}<a href="/admin/runners?runner={{ q.runner|urlencode }}#queue">{{ q.runner }}</a>{% endif %}</td></tr>{% endfor %} |
|
| 54 | 54 | </table></div> |
|
| 55 | 55 | <p class="small mute">Queued jobs are in the order runners claim them: repositories take turns, a repository with jobs running waiting behind those without; a job waits for its dependencies and for a runner whose scope, tier and room fit it.</p> |
|
| 56 | 56 | {% endif %} |
|
| … | 12 unchanged lines |
modified templates/flow_run.html +2 −0
| … | 8 unchanged lines |
||
| 9 | 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">{% if run.bookmark.is_empty() %}<span class="tag" title="Run on one change, not a bookmark">change</span>{% else %}<span class="lbl lbl-bm">{{ run.bookmark }}</span>{% endif %} at <a href="{{ repo.url }}/tree/{{ run.heads }}" title="state {{ run.state }}"><code>{{ run.state|short }}</code></a> · {{ run.started_by() }} <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 | {% if can_rerun %}<form class="inline" method="post" action="{{ repo.url }}/flow/{{ run.number }}/rerun"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit" class="small">RE-RUN</button></form>{% endif %} |
|
| 12 | + | {% if !run.priority.is_empty() %}<p class="small"><span class="tag tag-urgent" title="Runners take it before any other">{{ run.priority }}</span> {{ run.priority_note }}</p>{% endif %} |
|
| 13 | + | {% if can_expedite %}<form class="inline" method="post" action="{{ repo.url }}/flow/{{ run.number }}/expedite" title="Runners take an urgent run's jobs before any other's"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="text" name="reason" size="22" maxlength="200" placeholder="why it is urgent" required> <button type="submit" class="small">EXPEDITE</button></form>{% endif %} |
|
| 12 | 14 | {% if can_cancel %}<form class="inline" method="post" action="{{ repo.url }}/flow/{{ run.number }}/cancel" onsubmit="return confirm('Cancel run #{{ run.number }}? Jobs still running are stopped.')"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit" class="small danger-btn">CANCEL</button></form>{% endif %} |
|
| 13 | 15 | </div> |
|
| 14 | 16 | {% if let Some(q) = queue %} |
|
| … | 53 unchanged lines |
modified templates/repo_settings.html +12 −0
| … | 83 unchanged lines |
||
| 84 | 84 | <p class="small mute">To run pipelines on your own machines, register a runner for this repository under <a href="#runners">Runners</a>, then choose <strong>on, own runners only</strong> here. A site administrator can also let the site's runners take its jobs.</p> |
|
| 85 | 85 | {% endif %} |
|
| 86 | 86 | </form> |
|
| 87 | + | <form class="card wide" id="flow-priority" method="post" action="{{ repo.url }}/settings/flow-priority"> |
|
| 88 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 89 | + | <h2>OxygenFlow priority</h2> |
|
| 90 | + | <p>Runners take higher levels first; within a level, repositories take turns. To rush one run (an incident's fix), expedite it on its page.</p> |
|
| 91 | + | <select name="priority" aria-label="Priority"> |
|
| 92 | + | <option value="default"{% if row.flow_priority.is_empty() %} selected{% endif %}>the default (the organisation's or the site's)</option> |
|
| 93 | + | {% if site_admin %}<option value="high"{% if row.flow_priority == "high" %} selected{% endif %}>high</option>{% endif %} |
|
| 94 | + | <option value="normal"{% if row.flow_priority == "normal" %} selected{% endif %}>normal</option> |
|
| 95 | + | <option value="low"{% if row.flow_priority == "low" %} selected{% endif %}>low</option> |
|
| 96 | + | </select> |
|
| 97 | + | <button type="submit">SAVE</button> |
|
| 98 | + | </form> |
|
| 87 | 99 | <form class="card wide" id="diffusion" method="post" action="{{ repo.url }}/settings/diffusion"> |
|
| 88 | 100 | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 89 | 101 | <h2>Diffusion</h2> |
|
| … | 156 unchanged lines |