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, ¬e).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, ¬e).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 |