Flow priorities: an organisation's level, and a cap on expedited runs 76f93eaefe45
An organisation's owners set its repositories' level (low, normal, or the site's; high for site administrators) on its settings page; a repository's own level still wins. Expediting is capped per owner: at most flow_expedite_cap expedited runs going at once (2 unless Admin > Runners > Settings says otherwise), so urgent can't starve everyone else; one more is refused with how many are going. The priority test counts the live expedited runs the cap reads. Closes #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-17 01:34 UTC
- Signature
- ✔ signed by Claude Code
- Hash
76f93eaefe451cb3fe2daefccf8fd5b8c3bbb071a030a6876459bcde7ac54664- Depends on
- 107eaba52c9d 19bb31ff1f5c 42fa95540468 5dead1f4f26d 7974adb0be66 8ddebaa7e856 a160b98fb922 b6b7e4cf6b24 d23464c0ac60
- Depended on by
- b1860d6090ec
- 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
- 13 atoms, 0 tree operations, 9 files
Browse the tree at this change
modified crates/hub/src/db/accounts.rs +9 −0
| … | 71 unchanged lines |
||
| 72 | 72 | pub created_ms: i64, |
|
| 73 | 73 | /// `png:<hash>` or `jpeg:<hash>` when a picture was uploaded. |
|
| 74 | 74 | pub avatar: String, |
|
| 75 | + | /// Its repositories' Flow priority (oxygenhub#244): `low`, `normal`, |
|
| 76 | + | /// `high`, or empty to follow the site's (migration 0073). |
|
| 77 | + | #[sqlx(default)] |
|
| 78 | + | pub flow_priority: String, |
|
| 79 | + | } |
|
| 80 | + | ||
| 81 | + | pub async fn set_org_flow_priority(pool: &Pool, org_id: i64, priority: &str) -> sqlx::Result<()> { |
|
| 82 | + | sqlx::query("UPDATE organisations SET flow_priority = $1 WHERE id = $2").bind(priority).bind(org_id).execute(pool).await?; |
|
| 83 | + | Ok(()) |
|
| 75 | 84 | } |
|
| 76 | 85 | ||
| 77 | 86 | #[derive(Clone, Debug, FromRow)] |
|
| … | 881 unchanged lines |
modified crates/hub/src/db/flow.rs +11 −0
| … | 42 unchanged lines |
||
| 43 | 43 | NULLIF((SELECT s.value FROM settings s WHERE s.key = 'flow_priority'), ''), 'normal') \ |
|
| 44 | 44 | WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'low' THEN 3 ELSE 2 END"; |
|
| 45 | 45 | ||
| 46 | + | /// How many runs of `owner`'s repositories are expedited and still live. |
|
| 47 | + | pub async fn expedited_live(pool: &Pool, owner: &str) -> sqlx::Result<i64> { |
|
| 48 | + | sqlx::query_scalar( |
|
| 49 | + | "SELECT COUNT(*) FROM flow_runs r JOIN repositories rep ON rep.id = r.repo_id \ |
|
| 50 | + | WHERE rep.owner = $1 AND r.priority = 'urgent' AND r.status IN ('queued', 'running')", |
|
| 51 | + | ) |
|
| 52 | + | .bind(owner) |
|
| 53 | + | .fetch_one(pool) |
|
| 54 | + | .await |
|
| 55 | + | } |
|
| 56 | + | ||
| 46 | 57 | /// Raise run `run_id` to urgent, saying who and why; a run already ended |
|
| 47 | 58 | /// is left alone. Whether it changed. |
|
| 48 | 59 | pub async fn expedite(pool: &Pool, run_id: i64, note: &str) -> sqlx::Result<bool> { |
|
| … | 1150 unchanged lines |
modified crates/hub/src/web/flow.rs +8 −0
| … | 284 unchanged lines |
||
| 285 | 285 | if reason.is_empty() { |
|
| 286 | 286 | return Ok(Redirect::to(&format!("/{owner}/{name}/flow?error={}", urlencoding("Say why the run is urgent."))).into_response()); |
|
| 287 | 287 | } |
|
| 288 | + | // A cap on one owner's urgent runs at once (Admin > Runners > |
|
| 289 | + | // Settings), so urgent can't starve everyone else. |
|
| 290 | + | let cap: i64 = db::accounts::setting(&app.db, "flow_expedite_cap").await.ok().flatten().and_then(|v| v.parse().ok()).unwrap_or(2); |
|
| 291 | + | let live = fdb::expedited_live(&app.db, &owner).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 292 | + | if live >= cap { |
|
| 293 | + | let msg = format!("{owner} already has {live} expedited run(s) going, the most at once ({cap}); wait for one to end."); |
|
| 294 | + | return Ok(Redirect::to(&format!("/{owner}/{name}/flow?error={}", urlencoding(&msg))).into_response()); |
|
| 295 | + | } |
|
| 288 | 296 | let note = format!("expedited by {}: {reason}", me.account.username); |
|
| 289 | 297 | if fdb::expedite(&app.db, run.id, ¬e).await.map_err(|e| Error::from_db(shell.clone(), e))? { |
|
| 290 | 298 | db::accounts::audit(&app.db, Some(&me.account), "flow.expedite", &format!("{owner}/{name}"), &format!("#{number}: {reason}")) |
|
| … | 651 unchanged lines |
modified crates/hub/src/web/mod.rs +1 −0
| … | 127 unchanged lines |
||
| 128 | 128 | .route("/orgs/{name}/settings/variables", post(orgs::variables)) |
|
| 129 | 129 | .route("/orgs/{name}/settings/secrets", post(orgs::secrets)) |
|
| 130 | 130 | .route("/orgs/{name}/settings/storage", post(orgs::storage)) |
|
| 131 | + | .route("/orgs/{name}/settings/flow-priority", post(orgs::flow_priority)) |
|
| 131 | 132 | .route("/orgs/{name}/settings/runners/token", post(orgs::runner_token)) |
|
| 132 | 133 | .route("/orgs/{name}/settings/runners/{id}/remove", post(orgs::runner_remove)) |
|
| 133 | 134 | .route("/orgs/{name}/settings/hooks/{id}/deliveries", get(orgs::deliveries)) |
|
| … | 660 unchanged lines |
modified crates/hub/src/web/orgs.rs +33 −0
| … | 176 unchanged lines |
||
| 177 | 177 | } |
|
| 178 | 178 | ||
| 179 | 179 | #[derive(Deserialize)] |
|
| 180 | + | pub struct PriorityForm { |
|
| 181 | + | #[serde(rename = "_csrf")] |
|
| 182 | + | csrf_token: String, |
|
| 183 | + | priority: String, |
|
| 184 | + | } |
|
| 185 | + | ||
| 186 | + | /// `POST /orgs/{name}/settings/flow-priority` (oxygenhub#244): its |
|
| 187 | + | /// repositories' Flow level unless one sets its own. Owners choose low, |
|
| 188 | + | /// normal or the site's; only site administrators raise one to high. |
|
| 189 | + | pub async fn flow_priority( |
|
| 190 | + | State(app): State<Shared>, |
|
| 191 | + | viewer: Viewer, |
|
| 192 | + | Path(name): Path<String>, |
|
| 193 | + | Form(f): Form<PriorityForm>, |
|
| 194 | + | ) -> Result<Page<OrgSettingsPage>, Error> { |
|
| 195 | + | let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer); |
|
| 196 | + | let (me, org) = owned_org(&app, &shell, &viewer, &name).await?; |
|
| 197 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 198 | + | let level = match f.priority.as_str() { |
|
| 199 | + | "low" | "normal" | "high" => f.priority.as_str(), |
|
| 200 | + | "default" => "", |
|
| 201 | + | _ => return org_page(&app, shell, org, None, Some("Pick low, normal, high or the site's.".into())).await, |
|
| 202 | + | }; |
|
| 203 | + | if level == "high" && !viewer.is_admin() { |
|
| 204 | + | return org_page(&app, shell, org, None, Some("Only site administrators raise an organisation to high.".into())).await; |
|
| 205 | + | } |
|
| 206 | + | accounts::set_org_flow_priority(&app.db, org.id, level).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 207 | + | accounts::audit(&app.db, Some(&me), "org.flow_priority", &org.name, if level.is_empty() { "default" } else { level }).await.ok(); |
|
| 208 | + | let org = accounts::org_by_name(&app.db, &name).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(org); |
|
| 209 | + | org_page(&app, shell, org, Some("Saved.".into()), None).await |
|
| 210 | + | } |
|
| 211 | + | ||
| 212 | + | #[derive(Deserialize)] |
|
| 180 | 213 | pub struct MemberForm { |
|
| 181 | 214 | #[serde(rename = "_csrf")] |
|
| 182 | 215 | csrf_token: String, |
|
| … | 313 unchanged lines |
modified crates/hub/src/web/runners.rs +15 −0
| … | 447 unchanged lines |
||
| 448 | 448 | flow_repos: Vec<String>, |
|
| 449 | 449 | /// The site's priority for jobs (oxygenhub#244): low, normal or high. |
|
| 450 | 450 | priority: String, |
|
| 451 | + | /// The most expedited runs one owner may have going at once. |
|
| 452 | + | expedite_cap: i64, |
|
| 451 | 453 | notice: Option<String>, |
|
| 452 | 454 | error: Option<String>, |
|
| 453 | 455 | } |
|
| … | 7 unchanged lines |
||
| 461 | 463 | grace_m: l.grace_ms / 60_000, |
|
| 462 | 464 | file_default_m: app.config.flow.job_timeout_minutes, |
|
| 463 | 465 | flow_repos: app.config.flow.repos.clone(), |
|
| 466 | + | expedite_cap: accounts::setting(&app.db, "flow_expedite_cap").await.ok().flatten().and_then(|v| v.parse().ok()).unwrap_or(2), |
|
| 464 | 467 | priority: accounts::setting(&app.db, "flow_priority") |
|
| 465 | 468 | .await |
|
| 466 | 469 | .ok() |
|
| … | 10 unchanged lines |
||
| 477 | 480 | #[serde(rename = "_csrf")] |
|
| 478 | 481 | csrf_token: String, |
|
| 479 | 482 | priority: String, |
|
| 483 | + | #[serde(default)] |
|
| 484 | + | expedite_cap: Option<String>, |
|
| 480 | 485 | } |
|
| 481 | 486 | ||
| 482 | 487 | /// `POST /admin/runners/priority` (oxygenhub#244): the level every job has |
|
| … | 5 unchanged lines |
||
| 488 | 493 | if !["low", "normal", "high"].contains(&f.priority.as_str()) { |
|
| 489 | 494 | return Ok(settings_page(&app, shell, None, Some("Pick low, normal or high (urgent is for expediting one run).".into())).await); |
|
| 490 | 495 | } |
|
| 496 | + | let cap = match f.expedite_cap.as_deref().map(str::trim) { |
|
| 497 | + | None | Some("") => None, |
|
| 498 | + | Some(v) => match v.parse::<i64>() { |
|
| 499 | + | Ok(n) if (0..=100).contains(&n) => Some(n), |
|
| 500 | + | _ => return Ok(settings_page(&app, shell, None, Some("The cap is a whole number from 0 to 100.".into())).await), |
|
| 501 | + | }, |
|
| 502 | + | }; |
|
| 491 | 503 | accounts::set_setting(&app.db, "flow_priority", &f.priority).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 504 | + | if let Some(n) = cap { |
|
| 505 | + | accounts::set_setting(&app.db, "flow_expedite_cap", &n.to_string()).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 506 | + | } |
|
| 492 | 507 | accounts::audit(&app.db, Some(&me), "flow.priority", "site", &f.priority).await.ok(); |
|
| 493 | 508 | Ok(settings_page( |
|
| 494 | 509 | &app, |
|
| … | 199 unchanged lines |
modified crates/hub/tests/flow.rs +3 −0
| … | 4651 unchanged lines |
||
| 4652 | 4652 | // Expedited, early's run goes before anything else. |
|
| 4653 | 4653 | let run = &runs_of(&h.app, "andy", "early").await[0]; |
|
| 4654 | 4654 | assert!(db::flow::expedite(&h.app.db, run.id, "expedited by andy: an incident").await.unwrap()); |
|
| 4655 | + | // What the cap on expedited runs counts: andy's live urgent runs. |
|
| 4656 | + | assert_eq!(db::flow::expedited_live(&h.app.db, "andy").await.unwrap(), 1); |
|
| 4657 | + | assert_eq!(db::flow::expedited_live(&h.app.db, "someone-else").await.unwrap(), 0); |
|
| 4655 | 4658 | assert_eq!(repo_of(claim(&h.app).await.unwrap()), "andy/early"); |
|
| 4656 | 4659 | let q = db::flow::queue(&h.app.db).await.unwrap(); |
|
| 4657 | 4660 | assert!( |
|
| … | 9 unchanged lines |
modified templates/admin_runner_settings.html +2 −0
| … | 25 unchanged lines |
||
| 26 | 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 | 27 | <label for="priority-site">The site's level</label> |
|
| 28 | 28 | <select id="priority-site" name="priority">{% for p in ["low", "normal", "high"] %}<option{% if *p == priority %} selected{% endif %}>{{ p }}</option>{% endfor %}</select> |
|
| 29 | + | <label for="expedite-cap">The most expedited runs one owner may have going at once</label> |
|
| 30 | + | <input type="number" id="expedite-cap" name="expedite_cap" min="0" max="100" value="{{ expedite_cap }}"> |
|
| 29 | 31 | <button type="submit">Save</button> |
|
| 30 | 32 | </form> |
|
| 31 | 33 | <section class="panel"> |
|
| … | 4 unchanged lines |
modified templates/org_settings.html +12 −0
| … | 55 unchanged lines |
||
| 56 | 56 | <button type="submit">ADD OR CHANGE</button> |
|
| 57 | 57 | </form> |
|
| 58 | 58 | </section> |
|
| 59 | + | <form class="card" id="flow-priority" method="post" action="/orgs/{{ org.name }}/settings/flow-priority"> |
|
| 60 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 61 | + | <h2>OxygenFlow priority</h2> |
|
| 62 | + | <p class="small">Runners take higher levels first; within a level, repositories take turns. A repository may set its own level, and a maintainer can expedite one run.</p> |
|
| 63 | + | <select name="priority" aria-label="Priority"> |
|
| 64 | + | <option value="default"{% if org.flow_priority.is_empty() %} selected{% endif %}>the site's</option> |
|
| 65 | + | {% if shell.is_admin %}<option value="high"{% if org.flow_priority == "high" %} selected{% endif %}>high</option>{% endif %} |
|
| 66 | + | <option value="normal"{% if org.flow_priority == "normal" %} selected{% endif %}>normal</option> |
|
| 67 | + | <option value="low"{% if org.flow_priority == "low" %} selected{% endif %}>low</option> |
|
| 68 | + | </select> |
|
| 69 | + | <button type="submit">Save</button> |
|
| 70 | + | </form> |
|
| 59 | 71 | <form class="card" id="storage" method="post" action="/orgs/{{ org.name }}/settings/storage"> |
|
| 60 | 72 | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 61 | 73 | <h2>Storage</h2> |
|
| … | 39 unchanged lines |