OXYGENHUB

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