OXYGENHUB

Approval gates and expedite for clients: a run's jobs over the API say their approval, and POST …/flow/runs/N/expedite makes a run urgent as the run page does, sharing flow::expedite (refs oxygenhub-android#5) b1860d6090ec

Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key deb39c255cbc
When
· 2026-10-27 22:22 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
b1860d6090ec974c444d35bae685fd50cdd7748e2cf00222d5c83dbb79c7c20d
Depends on
05f1a85a2842 107eaba52c9d 417184c73f4e 42fa95540468 4a4400c5812d 501d8b268dd4 76f93eaefe45 794ff94f194a 90326c9601f9 a7e6e8e6094c a9088323c390 b5668108487f b644e1757711 d23464c0ac60
Depended on by
fac6853c4e46 06b756358f7e 58ac360b3345
In bookmarks
main
In tags
v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24
Edits
10 atoms, 0 tree operations, 6 files

Browse the tree at this change

modified CHANGELOG.md +2 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **Approval gates and expedite for clients (oxygenhub-android#5).** A run's jobs over the API say `approval` (`waiting` while one waits for a person, `approved` or `rejected` after), and `POST /api/v1/repos/OWNER/NAME/flow/runs/N/expedite {"reason": …}` makes a run urgent as the run page's Expedite does (a maintainer, with a reason, within the owner's cap of urgent runs); the page and the API share it.
8 +
7 9
## 0.9.10 (2026-10-27)
8 10
9 11
- **Phone alerts (oxygenhub-android#16, oxygenhub-gcp decision 0008).** A phone registers its Cloud Messaging token and an X25519 public key with `PUT /api/v1/user/push`, using the token it signed in with (`DELETE` unregisters; revoking the token ends it too, migration 0101). Each notification an account is told then also goes to its phones, its title and link sealed to each phone's key (`flow_core::alert`), as a data-only, high-priority message, so Google carries only ciphertext. The hub sends as the service account its VM runs as, with the metadata server's token: no key. Off until `[push] fcm_project` (`OXYHUB_PUSH_FCM_PROJECT`) is set; a registration Cloud Messaging no longer knows is forgotten. Pins OxygenFlow main (sealed alerts).
…
382 unchanged lines
modified crates/hub-api/src/lib.rs +5 −0
…
600 unchanged lines
601 601
    /// speed 100 (class 3)".
602 602
    #[serde(default, skip_serializing_if = "String::is_empty")]
603 603
    pub placed: String,
604 +
    /// Its approval gate (oxygenflow#57): `waiting` while it waits for a
605 +
    /// person to approve or reject it, `approved` or `rejected` after;
606 +
    /// empty for a job with no gate.
607 +
    #[serde(default, skip_serializing_if = "String::is_empty")]
608 +
    pub approval: String,
604 609
}
605 610
606 611
/// A line of a job's log.
…
1046 unchanged lines
modified crates/hub/src/api/mod.rs +24 −0
…
196 unchanged lines
197 197
        .route("/repos/{owner}/{name}/flow/runs/{number}/jobs/{job}/rerun", post(rerun_flow_job))
198 198
        .route("/repos/{owner}/{name}/flow/runs/{number}/cancel", post(cancel_flow_run))
199 199
        .route("/repos/{owner}/{name}/deployments", get(list_deployments))
200 +
        .route("/repos/{owner}/{name}/flow/runs/{number}/expedite", post(expedite_flow_run))
200 201
        .route("/repos/{owner}/{name}/flow/runs/{number}/jobs/{job}/approve", post(approve_flow_job))
201 202
        .route("/repos/{owner}/{name}/flow/runs/{number}/jobs/{job}/reject", post(reject_flow_job))
202 203
        .route("/repos/{owner}/{name}/flow/runs/{number}/jobs/{job}/approval", get(flow_job_statement))
…
2295 unchanged lines
2498 2499
                log_lines: j.log_count,
2499 2500
                waiting: j.waiting,
2500 2501
                placed: j.placed,
2502 +
                approval: j.approval,
2501 2503
            })
2502 2504
            .collect(),
2503 2505
    }
…
182 unchanged lines
2686 2688
    ))
2687 2689
}
2688 2690
2691 +
#[derive(Deserialize)]
2692 +
struct ExpediteBody {
2693 +
    reason: String,
2694 +
}
2695 +
2696 +
/// `POST /repos/{o}/{n}/flow/runs/{number}/expedite {"reason": …}`
2697 +
/// (oxygenhub#244): a maintainer makes a queued or running run urgent, as
2698 +
/// the run page's Expedite does, within the owner's cap of urgent runs.
2699 +
async fn expedite_flow_run(
2700 +
    State(app): State<Shared>,
2701 +
    headers: HeaderMap,
2702 +
    Path((owner, name, number)): Path<(String, String, i64)>,
2703 +
    Json(body): Json<ExpediteBody>,
2704 +
) -> Result<Json<serde_json::Value>, ApiError> {
2705 +
    let c = caller(&app, &headers).await?;
2706 +
    let me = c.require("repo:write")?.clone();
2707 +
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Maintain).await?;
2708 +
    let run = db::flow::run(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such run"))?;
2709 +
    crate::flow::expedite(&app, &row, &run, &me, &body.reason).await.map_err(|m| ApiError(StatusCode::CONFLICT, m))?;
2710 +
    Ok(Json(serde_json::json!({ "number": number, "expedited": true })))
2711 +
}
2712 +
2689 2713
/// `POST /repos/{o}/{n}/flow/runs/{number}/jobs/{job}/approve` and
2690 2714
/// `.../reject` (oxygenflow#57): the job's approval rules decide who may.
2691 2715
async fn approve_flow_job(
…
555 unchanged lines
modified crates/hub/src/flow.rs +38 −0
…
2551 unchanged lines
2552 2552
    Ok(())
2553 2553
}
2554 2554
2555 +
/// Make run `run` urgent (oxygenhub#244), saying why: refused without a
2556 +
/// reason, or when the owner already has as many urgent runs going as the
2557 +
/// site allows (Admin > Runners > Settings). Audited; every queued job's
2558 +
/// place is worked out again at once (#291). For the run page and the API.
2559 +
pub async fn expedite(
2560 +
    app: &Shared,
2561 +
    row: &db::repos::RepoRow,
2562 +
    run: &fdb::Run,
2563 +
    me: &db::accounts::Account,
2564 +
    reason: &str,
2565 +
) -> Result<(), String> {
2566 +
    let reason: String = reason.chars().filter(|c| !c.is_control()).take(200).collect();
2567 +
    let reason = reason.trim();
2568 +
    if reason.is_empty() {
2569 +
        return Err("Say why the run is urgent.".into());
2570 +
    }
2571 +
    let owner = &row.owner;
2572 +
    // A cap on one owner's urgent runs at once, so urgent can't starve
2573 +
    // everyone else.
2574 +
    let cap: i64 = db::accounts::setting(&app.db, "flow_expedite_cap").await.ok().flatten().and_then(|v| v.parse().ok()).unwrap_or(2);
2575 +
    let live = fdb::expedited_live(&app.db, owner).await.map_err(|e| e.to_string())?;
2576 +
    if live >= cap {
2577 +
        return Err(format!("{owner} already has {live} expedited run(s) going, the most at once ({cap}); wait for one to end."));
2578 +
    }
2579 +
    let note = format!("expedited by {}: {reason}", me.username);
2580 +
    if fdb::expedite(&app.db, run.id, &note).await.map_err(|e| e.to_string())? {
2581 +
        db::accounts::audit(&app.db, Some(me), "flow.expedite", &format!("{owner}/{}", row.name), &format!("#{}: {reason}", run.number))
2582 +
            .await
2583 +
            .ok();
2584 +
        // This run's page and the pages of the runs it moved down hear of it.
2585 +
        if let Err(e) = explain_waits(app).await {
2586 +
            tracing::warn!("flow: placing after an expedite: {e}");
2587 +
        }
2588 +
        app.bus.live(&crate::events::topic::run(run.id), "jobs");
2589 +
    }
2590 +
    Ok(())
2591 +
}
2592 +
2555 2593
/// The account OxygenFlow's notices come from, made on first use with no
2556 2594
/// way to sign in.
2557 2595
pub const ACCOUNT: &str = "oxygenflow";
…
507 unchanged lines
modified crates/hub/src/web/flow.rs +1 −24
…
279 unchanged lines
280 280
        .await
281 281
        .map_err(|e| Error::from_db(shell.clone(), e))?
282 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 -
    // 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.");
283 +
    if let Err(msg) = crate::flow::expedite(&app, &row, &run, &me.account, &f.reason).await {
294 284
        return Ok(Redirect::to(&format!("/{owner}/{name}/flow?error={}", urlencoding(&msg))).into_response());
295 -
    }
296 -
    let note = format!("expedited by {}: {reason}", me.account.username);
297 -
    if fdb::expedite(&app.db, run.id, &note).await.map_err(|e| Error::from_db(shell.clone(), e))? {
298 -
        db::accounts::audit(&app.db, Some(&me.account), "flow.expedite", &format!("{owner}/{name}"), &format!("#{number}: {reason}"))
299 -
            .await
300 -
            .ok();
301 -
        // Every queued job's place changes now, not at the next pass
302 -
        // (#291): this run's page and the pages of the runs it moved down
303 -
        // hear of it.
304 -
        if let Err(e) = crate::flow::explain_waits(&app).await {
305 -
            tracing::warn!("flow: placing after an expedite: {e}");
306 -
        }
307 -
        app.bus.live(&crate::events::topic::run(run.id), "jobs");
308 285
    }
309 286
    Ok(Redirect::to(&format!("/{owner}/{name}/flow/{number}")).into_response())
310 287
}
…
706 unchanged lines
modified crates/hub/tests/flow.rs +25 −0
…
3180 unchanged lines
3181 3181
    assert!(!build(urgent.id).await.waiting.contains("come"), "first now: {}", build(urgent.id).await.waiting);
3182 3182
    let w = build(first.id).await.waiting;
3183 3183
    assert!(w.contains("2 ready jobs come first (after expedited run andy/urgent #1)"), "{w}");
3184 +
3185 +
    // And over the API (oxygenhub-android#5): a reason is needed; then the
3186 +
    // first run is urgent too, within the cap of two.
3187 +
    let andy = db::accounts::account_by_name(&h.app.db, "andy").await.unwrap().unwrap();
3188 +
    let (token, hash) = oxygenhub::auth::new_token();
3189 +
    db::accounts::create_token(&h.app.db, andy.id, "phone", &hash, "repo:read,repo:write", "", None).await.unwrap();
3190 +
    let url = format!("/api/v1/repos/andy/first/flow/runs/{}/expedite", first.number);
3191 +
    let (st, body) = call(&h.app, "POST", &url, Some(&token), Some(r#"{"reason":"  "}"#.into())).await;
3192 +
    assert_eq!(st, StatusCode::CONFLICT, "{body}");
3193 +
    assert!(body.contains("Say why the run is urgent."), "{body}");
3194 +
    let (st, body) = call(&h.app, "POST", &url, Some(&token), Some(r#"{"reason":"the fix for the outage"}"#.into())).await;
3195 +
    assert_eq!(st, StatusCode::OK, "{body}");
3196 +
    let run = db::flow::run(&h.app.db, first.repo_id, first.number).await.unwrap().unwrap();
3197 +
    assert!(
3198 +
        run.priority == "urgent" && run.priority_note.contains("expedited by andy: the fix for the outage"),
3199 +
        "{} {}",
3200 +
        run.priority,
3201 +
        run.priority_note
3202 +
    );
3184 3203
}
3185 3204
3186 3205
async fn put_bytes(app: &Shared, path: &str, bearer: &str, bytes: Vec<u8>) -> (StatusCode, String) {
…
1937 unchanged lines
5124 5143
    assert_eq!((told(carol.id).await, told(andy.id).await, told(bob.id).await), (1, 0, 0));
5125 5144
    let (_, page) = call(&h.app, "GET", &format!("/andy/gated/flow/{}", run.number), None, None).await;
5126 5145
    assert!(page.contains("waits for approval by someone with the <em>maintain</em> role, other than andy"), "the run page says so");
5146 +
    // The API says which job waits for a person (oxygenhub-android#5).
5147 +
    let (_, v) = call(&h.app, "GET", &format!("/api/v1/repos/andy/gated/flow/runs/{}", run.number), None, None).await;
5148 +
    let v: serde_json::Value = serde_json::from_str(&v).unwrap();
5149 +
    let apply = v["jobs"].as_array().unwrap().iter().find(|j| j["name"] == "apply").unwrap().clone();
5150 +
    assert_eq!(apply["approval"], "waiting", "{v}");
5151 +
    assert!(v["jobs"].as_array().unwrap().iter().filter(|j| j["name"] != "apply").all(|j| j.get("approval").is_none()), "{v}");
5127 5152
    let own = oxygenhub::flow::decide(&h.app, &row, &run, "apply", &andy, true, None).await.unwrap_err();
5128 5153
    assert!(own.contains("someone other than andy"), "{own}");
5129 5154
    let no_role = oxygenhub::flow::decide(&h.app, &row, &run, "apply", &bob, true, None).await.unwrap_err();
…
1922 unchanged lines