OXYGENHUB

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, &note).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