Run pipeline and Re-run: runs that move no bookmark 1edac4b68c6d
Writers start a run of a bookmark's current state from the Flow tab (RUN
PIPELINE) and run a finished run's state again from its page (RE-RUN),
also as POST /api/v1/repos/{o}/{n}/flow/runs and .../runs/{n}/rerun
(repo:write; 201 with the run's number and url, 409 with why nothing
started). The new run's checks replace the old ones for that state. Runs
record what started them (migration 0020: push, manual, rerun:N) and the
run page says so. Pushes, Run pipeline and Re-run share one start_run.
closes #25
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-28 23:27 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
1edac4b68c6d1707648e8ff567dba43dd78ef0d7c7aba710ac415cf29d5e1a72- Depends on
- 42fa95540468 736f6813543d 9008bf6c2b8c 93be9ce4653e 961c10bee174 a0d4bbfbedd1 af9c6ca11c49 b64e2537d702 d23464c0ac60
- Depended on by
- c26267b3aba6 fac6853c4e46 3d086bc3d69b 5f3fa151688e 5588bb3631d2 367f34b8e590 a9f1c0bf403a 107eaba52c9d 3b1bc27fe805 5d90e0e045e2 b644e1757711 e8fa53694f68 0650d94ede82 745d225de344 348f8c4baccf c889911bec12 d7ce41acf22c 4f6925337848 cc19697f8871 bf3d07541368 90326c9601f9
- In bookmarks
- api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
- In tags
- v0.1.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 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, 1 tree operation, 10 files
Browse the tree at this change
added crates/hub/migrations/0020_flow_trigger.sql +2 −0
| 1 | + | -- What started a run: 'push', 'manual' (Run pipeline) or 'rerun:N' (a re-run of run N). |
|
| 2 | + | ALTER TABLE flow_runs ADD COLUMN trigger TEXT NOT NULL DEFAULT 'push'; |
modified crates/hub/src/api/mod.rs +46 −0
| … | 61 unchanged lines |
||
| 62 | 62 | .route("/repos/{owner}/{name}/hooks", get(hooks).post(create_hook)) |
|
| 63 | 63 | .route("/repos/{owner}/{name}/hooks/{id}/delete", post(delete_hook)) |
|
| 64 | 64 | .route("/repos/{owner}/{name}/checks", post(post_check)) |
|
| 65 | + | .route("/repos/{owner}/{name}/flow/runs", post(start_flow_run)) |
|
| 66 | + | .route("/repos/{owner}/{name}/flow/runs/{number}/rerun", post(rerun_flow_run)) |
|
| 65 | 67 | .route("/repos/{owner}/{name}/checks/required", get(required_checks).put(set_required_checks)) |
|
| 66 | 68 | .route("/repos/{owner}/{name}/checks/{state}", get(checks_for_state)) |
|
| 67 | 69 | .route("/repos/{owner}/{name}/releases", get(releases).post(create_release)) |
|
| … | 1355 unchanged lines |
||
| 1423 | 1425 | db::hooks::set_required_checks(&app.db, row.id, &names).await?; |
|
| 1424 | 1426 | db::accounts::audit(&app.db, c.account.as_ref(), "repo.required_checks", &format!("{owner}/{name}"), &names.join(", ")).await.ok(); |
|
| 1425 | 1427 | Ok(Json(RequiredChecks { names })) |
|
| 1428 | + | } |
|
| 1429 | + | ||
| 1430 | + | #[derive(Deserialize)] |
|
| 1431 | + | struct StartRun { |
|
| 1432 | + | /// Default: the repository's default bookmark. |
|
| 1433 | + | #[serde(default)] |
|
| 1434 | + | bookmark: Option<String>, |
|
| 1435 | + | } |
|
| 1436 | + | ||
| 1437 | + | fn started(app: &Shared, row: &db::repos::RepoRow, number: i64) -> (StatusCode, Json<serde_json::Value>) { |
|
| 1438 | + | let url = format!("{}/{}/{}/flow/{number}", app.config.base_url(), row.owner, row.name); |
|
| 1439 | + | (StatusCode::CREATED, Json(serde_json::json!({ "number": number, "url": url }))) |
|
| 1440 | + | } |
|
| 1441 | + | ||
| 1442 | + | /// `POST /repos/{o}/{n}/flow/runs` (repo:write): run a bookmark's current |
|
| 1443 | + | /// state without moving it (hub spec §13.2). |
|
| 1444 | + | async fn start_flow_run( |
|
| 1445 | + | State(app): State<Shared>, |
|
| 1446 | + | headers: HeaderMap, |
|
| 1447 | + | Path((owner, name)): Path<(String, String)>, |
|
| 1448 | + | Json(body): Json<StartRun>, |
|
| 1449 | + | ) -> Result<(StatusCode, Json<serde_json::Value>), ApiError> { |
|
| 1450 | + | let c = caller(&app, &headers).await?; |
|
| 1451 | + | let me = c.require("repo:write")?.clone(); |
|
| 1452 | + | let (_, row, _) = reach(&app, &c, &owner, &name, Access::Write).await?; |
|
| 1453 | + | let bookmark = body.bookmark.unwrap_or_else(|| row.default_bookmark.clone()); |
|
| 1454 | + | let n = crate::flow::run_bookmark(&app, &row, &bookmark, &me.username).await.map_err(|m| ApiError(StatusCode::CONFLICT, m))?; |
|
| 1455 | + | db::accounts::audit(&app.db, Some(&me), "flow.run", &format!("{owner}/{name}"), &format!("#{n} {bookmark}")).await.ok(); |
|
| 1456 | + | Ok(started(&app, &row, n)) |
|
| 1457 | + | } |
|
| 1458 | + | ||
| 1459 | + | /// `POST /repos/{o}/{n}/flow/runs/{number}/rerun` (repo:write). |
|
| 1460 | + | async fn rerun_flow_run( |
|
| 1461 | + | State(app): State<Shared>, |
|
| 1462 | + | headers: HeaderMap, |
|
| 1463 | + | Path((owner, name, number)): Path<(String, String, i64)>, |
|
| 1464 | + | ) -> Result<(StatusCode, Json<serde_json::Value>), ApiError> { |
|
| 1465 | + | let c = caller(&app, &headers).await?; |
|
| 1466 | + | let me = c.require("repo:write")?.clone(); |
|
| 1467 | + | let (_, row, _) = reach(&app, &c, &owner, &name, Access::Write).await?; |
|
| 1468 | + | let run = db::flow::run(&app.db, row.id, number).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?; |
|
| 1469 | + | let n = crate::flow::rerun(&app, &row, &run, &me.username).await.map_err(|m| ApiError(StatusCode::CONFLICT, m))?; |
|
| 1470 | + | db::accounts::audit(&app.db, Some(&me), "flow.rerun", &format!("{owner}/{name}"), &format!("#{n} of #{number}")).await.ok(); |
|
| 1471 | + | Ok(started(&app, &row, n)) |
|
| 1426 | 1472 | } |
|
| 1427 | 1473 | ||
| 1428 | 1474 | /// `POST /repos/{o}/{n}/checks`: any `checks:write` (or write) token may |
|
| … | 43 unchanged lines |
modified crates/hub/src/db/flow.rs +17 −2
| … | 9 unchanged lines |
||
| 10 | 10 | /// The run's number within its repository, from 1 (migration 0019). |
|
| 11 | 11 | #[sqlx(default)] |
|
| 12 | 12 | pub number: i64, |
|
| 13 | + | /// `push`, `manual` or `rerun:N` (migration 0020). |
|
| 14 | + | #[sqlx(default)] |
|
| 15 | + | pub trigger: String, |
|
| 13 | 16 | pub bookmark: String, |
|
| 14 | 17 | pub state: String, |
|
| 15 | 18 | pub heads: String, |
|
| … | 6 unchanged lines |
||
| 22 | 25 | } |
|
| 23 | 26 | ||
| 24 | 27 | impl Run { |
|
| 28 | + | /// How the run page says what started it. |
|
| 29 | + | pub fn started_by(&self) -> String { |
|
| 30 | + | match self.trigger.as_str() { |
|
| 31 | + | "manual" => format!("started by {}", self.actor), |
|
| 32 | + | t if t.starts_with("rerun:") => format!("re-run of #{} by {}", &t[6..], self.actor), |
|
| 33 | + | _ => format!("pushed by {}", self.actor), |
|
| 34 | + | } |
|
| 35 | + | } |
|
| 36 | + | ||
| 25 | 37 | pub fn head_list(&self) -> Vec<&str> { |
|
| 26 | 38 | self.heads.split('+').filter(|h| !h.is_empty()).collect() |
|
| 27 | 39 | } |
|
| … | 62 unchanged lines |
||
| 90 | 102 | pub heads: &'a str, |
|
| 91 | 103 | pub pipeline_hash: &'a str, |
|
| 92 | 104 | pub actor: &'a str, |
|
| 105 | + | /// `push`, `manual` or `rerun:N`. |
|
| 106 | + | pub trigger: &'a str, |
|
| 93 | 107 | /// Problems when the pipeline is invalid: the run is recorded in error. |
|
| 94 | 108 | pub error: Option<&'a str>, |
|
| 95 | 109 | } |
|
| … | 3 unchanged lines |
||
| 99 | 113 | let now = now_ms(); |
|
| 100 | 114 | let status = if r.error.is_some() { "error" } else { "queued" }; |
|
| 101 | 115 | let id: i64 = sqlx::query_scalar( |
|
| 102 | - | "INSERT INTO flow_runs (repo_id, number, bookmark, state, heads, pipeline_hash, status, error, actor, created_ms, finished_ms) \ |
|
| 103 | - | VALUES (?, (SELECT COALESCE(MAX(number), 0) + 1 FROM flow_runs WHERE repo_id = ?), ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id", |
|
| 116 | + | "INSERT INTO flow_runs (repo_id, number, bookmark, state, heads, pipeline_hash, status, error, actor, trigger, created_ms, finished_ms) \ |
|
| 117 | + | VALUES (?, (SELECT COALESCE(MAX(number), 0) + 1 FROM flow_runs WHERE repo_id = ?), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id", |
|
| 104 | 118 | ) |
|
| 105 | 119 | .bind(r.repo_id) |
|
| 106 | 120 | .bind(r.repo_id) |
|
| … | 4 unchanged lines |
||
| 111 | 125 | .bind(status) |
|
| 112 | 126 | .bind(r.error.unwrap_or("")) |
|
| 113 | 127 | .bind(r.actor) |
|
| 128 | + | .bind(r.trigger) |
|
| 114 | 129 | .bind(now) |
|
| 115 | 130 | .bind(r.error.map(|_| now)) |
|
| 116 | 131 | .fetch_one(&mut *tx) |
|
| … | 253 unchanged lines |
modified crates/hub/src/flow.rs +75 −21
| … | 34 unchanged lines |
||
| 35 | 35 | if bookmark.starts_with("tags/") || heads.is_empty() || !enabled(app, row) { |
|
| 36 | 36 | return; |
|
| 37 | 37 | } |
|
| 38 | - | let Some(repo) = RepoName::new(&row.owner, &row.name) else { return }; |
|
| 38 | + | if let Err(e) = start_run(app, row, bookmark, state, heads, actor, "push").await { |
|
| 39 | + | tracing::warn!("flow: starting a run of {}/{}: {e}", row.owner, row.name); |
|
| 40 | + | } |
|
| 41 | + | } |
|
| 42 | + | ||
| 43 | + | /// "Run pipeline" (hub spec §13.2): run a bookmark's current state without |
|
| 44 | + | /// moving it. Returns the new run's number. |
|
| 45 | + | pub async fn run_bookmark(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, actor: &str) -> Result<i64, String> { |
|
| 46 | + | if !enabled(app, row) { |
|
| 47 | + | return Err("OxygenFlow is not enabled for this repository".into()); |
|
| 48 | + | } |
|
| 49 | + | let repo = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?; |
|
| 50 | + | let b = bookmark.to_string(); |
|
| 51 | + | let found = app |
|
| 52 | + | .repos |
|
| 53 | + | .read(&repo, move |r, _| { |
|
| 54 | + | let Some(st) = r.view()?.bookmarks.get(&b).copied() else { return Ok(None) }; |
|
| 55 | + | Ok(Some((st, r.store().get_state(&st)?.heads))) |
|
| 56 | + | }) |
|
| 57 | + | .await |
|
| 58 | + | .map_err(|e| e.to_string())?; |
|
| 59 | + | let Some((state, heads)) = found else { return Err(format!("no bookmark {bookmark}")) }; |
|
| 60 | + | if heads.is_empty() { |
|
| 61 | + | return Err(format!("{bookmark} has no changes yet")); |
|
| 62 | + | } |
|
| 63 | + | start_run(app, row, bookmark, &state.to_hex(), &heads, actor, "manual") |
|
| 64 | + | .await? |
|
| 65 | + | .ok_or_else(|| format!("{bookmark} has no {PIPELINE_PATH}")) |
|
| 66 | + | } |
|
| 67 | + | ||
| 68 | + | /// Re-run a finished run: the same state, bookmark and pipeline, as a new |
|
| 69 | + | /// run whose checks replace the old ones. Returns the new run's number. |
|
| 70 | + | pub async fn rerun(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run, actor: &str) -> Result<i64, String> { |
|
| 71 | + | if !enabled(app, row) { |
|
| 72 | + | return Err("OxygenFlow is not enabled for this repository".into()); |
|
| 73 | + | } |
|
| 74 | + | if run.live() { |
|
| 75 | + | return Err(format!("run #{} is still {}", run.number, run.status)); |
|
| 76 | + | } |
|
| 77 | + | let heads: Vec<ChangeHash> = run.head_list().into_iter().filter_map(ChangeHash::from_hex).collect(); |
|
| 78 | + | if heads.is_empty() { |
|
| 79 | + | return Err("the run names no changes".into()); |
|
| 80 | + | } |
|
| 81 | + | start_run(app, row, &run.bookmark, &run.state, &heads, actor, &format!("rerun:{}", run.number)) |
|
| 82 | + | .await? |
|
| 83 | + | .ok_or_else(|| format!("that state has no {PIPELINE_PATH}")) |
|
| 84 | + | } |
|
| 85 | + | ||
| 86 | + | /// Start a run of the pipeline in the state `heads` make (whose state |
|
| 87 | + | /// hash is `state`), recorded on `bookmark`: for a push, "Run pipeline" |
|
| 88 | + | /// (`trigger` = `manual`) and re-runs (`rerun:N`). `actor` is the account |
|
| 89 | + | /// whose access the run's subrepo reads are checked against. Returns the |
|
| 90 | + | /// run's number, or `None` when the state has no pipeline. The caller |
|
| 91 | + | /// checks that Flow is enabled. |
|
| 92 | + | pub async fn start_run( |
|
| 93 | + | app: &Shared, |
|
| 94 | + | row: &db::repos::RepoRow, |
|
| 95 | + | bookmark: &str, |
|
| 96 | + | state: &str, |
|
| 97 | + | heads: &[ChangeHash], |
|
| 98 | + | actor: &str, |
|
| 99 | + | trigger: &str, |
|
| 100 | + | ) -> Result<Option<i64>, String> { |
|
| 101 | + | let repo = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?; |
|
| 39 | 102 | let h = heads.to_vec(); |
|
| 40 | 103 | let text = app |
|
| 41 | 104 | .repos |
|
| … | 4 unchanged lines |
||
| 46 | 109 | _ => None, |
|
| 47 | 110 | }) |
|
| 48 | 111 | }) |
|
| 49 | - | .await; |
|
| 50 | - | let text = match text { |
|
| 51 | - | Ok(Some(t)) => t, |
|
| 52 | - | Ok(None) => return, |
|
| 53 | - | Err(e) => { |
|
| 54 | - | tracing::warn!("flow: reading the pipeline of {repo}: {e}"); |
|
| 55 | - | return; |
|
| 56 | - | } |
|
| 57 | - | }; |
|
| 112 | + | .await |
|
| 113 | + | .map_err(|e| format!("reading the pipeline: {e}"))?; |
|
| 114 | + | let Some(text) = text else { return Ok(None) }; |
|
| 58 | 115 | let mut hex: Vec<String> = heads.iter().map(|h| h.to_hex()).collect(); |
|
| 59 | 116 | hex.sort(); |
|
| 60 | 117 | let heads_s = hex.join("+"); |
|
| 61 | - | let base = db::flow::NewRun { repo_id: row.id, bookmark, state, heads: &heads_s, pipeline_hash: "", actor, error: None }; |
|
| 62 | - | let result = match Pipeline::parse(&text) { |
|
| 118 | + | let base = db::flow::NewRun { repo_id: row.id, bookmark, state, heads: &heads_s, pipeline_hash: "", actor, trigger, error: None }; |
|
| 119 | + | let id = match Pipeline::parse(&text) { |
|
| 63 | 120 | Err(problems) => { |
|
| 64 | 121 | let msg = problems.to_string(); |
|
| 65 | - | db::flow::create_run(&app.db, db::flow::NewRun { error: Some(&msg), ..base }, &[]).await |
|
| 122 | + | db::flow::create_run(&app.db, db::flow::NewRun { error: Some(&msg), ..base }, &[]).await.map_err(|e| e.to_string())? |
|
| 66 | 123 | } |
|
| 67 | 124 | Ok(p) => { |
|
| 68 | 125 | let depths = p.depths(); |
|
| … | 13 unchanged lines |
||
| 82 | 139 | }) |
|
| 83 | 140 | .collect(); |
|
| 84 | 141 | let hash = hex::encode(p.hash()); |
|
| 85 | - | let id = db::flow::create_run(&app.db, db::flow::NewRun { pipeline_hash: &hash, ..base }, &jobs).await; |
|
| 86 | - | if let Ok(Some(run)) = match id { |
|
| 87 | - | Ok(id) => fdb::run_by_id(&app.db, id).await, |
|
| 88 | - | Err(_) => Ok(None), |
|
| 89 | - | } { |
|
| 142 | + | let id = |
|
| 143 | + | db::flow::create_run(&app.db, db::flow::NewRun { pipeline_hash: &hash, ..base }, &jobs).await.map_err(|e| e.to_string())?; |
|
| 144 | + | if let Ok(Some(run)) = fdb::run_by_id(&app.db, id).await { |
|
| 90 | 145 | for j in &jobs { |
|
| 91 | 146 | report_check(app, row, state, run.number, &j.name, "queued", "waiting for a runner").await; |
|
| 92 | 147 | } |
|
| … | 1 unchanged lines |
||
| 94 | 149 | id |
|
| 95 | 150 | } |
|
| 96 | 151 | }; |
|
| 97 | - | if let Err(e) = result { |
|
| 98 | - | tracing::warn!("flow: recording a run of {repo}: {e}"); |
|
| 99 | - | } |
|
| 152 | + | let run = fdb::run_by_id(&app.db, id).await.map_err(|e| e.to_string())?.ok_or("the run vanished")?; |
|
| 153 | + | Ok(Some(run.number)) |
|
| 100 | 154 | } |
|
| 101 | 155 | ||
| 102 | 156 | /// Record a job's check; it links to the run's page by the run's number. |
|
| … | 254 unchanged lines |
modified crates/hub/src/web/flow.rs +106 −6
| 1 | 1 | //! The Flow tab (hub spec §13.2): runs of a repository, and a run's job |
|
| 2 | 2 | //! graph with live status and logs. |
|
| 3 | 3 | ||
| 4 | - | use super::{filters, lookup, Error, Page, RepoShell, Shell}; |
|
| 5 | - | use crate::auth::Viewer; |
|
| 4 | + | use super::settings::{csrf, require}; |
|
| 5 | + | use super::{filters, lookup, lookup_role, Error, Page, RepoShell, Shell}; |
|
| 6 | + | use crate::auth::{Access, Role, Viewer}; |
|
| 6 | 7 | use crate::db::{self, flow as fdb}; |
|
| 7 | 8 | use crate::Shared; |
|
| 8 | 9 | use askama::Template; |
|
| 9 | - | use axum::extract::{Path, Query, State}; |
|
| 10 | - | use axum::response::{IntoResponse, Response}; |
|
| 10 | + | use axum::extract::{Form, Path, Query, State}; |
|
| 11 | + | use axum::response::{IntoResponse, Redirect, Response}; |
|
| 11 | 12 | use axum::Json; |
|
| 12 | 13 | use serde::Deserialize; |
|
| 13 | 14 | ||
| … | 11 unchanged lines |
||
| 25 | 26 | runs: Vec<RunRow>, |
|
| 26 | 27 | enabled: bool, |
|
| 27 | 28 | older: Option<i64>, |
|
| 29 | + | /// Writers may start runs: the bookmarks to offer, default first. |
|
| 30 | + | can_run: bool, |
|
| 31 | + | bookmarks: Vec<String>, |
|
| 32 | + | error: Option<String>, |
|
| 28 | 33 | } |
|
| 29 | 34 | ||
| 30 | 35 | #[derive(Deserialize)] |
|
| 31 | 36 | pub struct RunsQuery { |
|
| 32 | 37 | before: Option<i64>, |
|
| 38 | + | /// Why a "Run pipeline" or "Re-run" did not start. |
|
| 39 | + | error: Option<String>, |
|
| 33 | 40 | } |
|
| 34 | 41 | ||
| 35 | 42 | const PER_PAGE: i64 = 30; |
|
| … | 19 unchanged lines |
||
| 55 | 62 | runs.push(RunRow { jobs: jobs.into_iter().map(|j| (j.name, j.status)).collect(), duration, r }); |
|
| 56 | 63 | } |
|
| 57 | 64 | let enabled = crate::flow::enabled(&app, &row); |
|
| 58 | - | Ok(Page(RunsPage { shell, repo: RepoShell::new(&app, &row, "flow").with_role(role), runs, enabled, older })) |
|
| 65 | + | let can_run = enabled && viewer.account().is_some() && role >= Role::Write; |
|
| 66 | + | let bookmarks = if can_run { bookmark_names(&app, &row).await } else { vec![] }; |
|
| 67 | + | Ok(Page(RunsPage { |
|
| 68 | + | shell, |
|
| 69 | + | repo: RepoShell::new(&app, &row, "flow").with_role(role), |
|
| 70 | + | runs, |
|
| 71 | + | enabled, |
|
| 72 | + | older, |
|
| 73 | + | can_run, |
|
| 74 | + | bookmarks, |
|
| 75 | + | error: q.error, |
|
| 76 | + | })) |
|
| 77 | + | } |
|
| 78 | + | ||
| 79 | + | /// The repository's bookmarks, its default first. |
|
| 80 | + | async fn bookmark_names(app: &Shared, row: &db::repos::RepoRow) -> Vec<String> { |
|
| 81 | + | let Some(repo) = crate::repos::RepoName::new(&row.owner, &row.name) else { return vec![] }; |
|
| 82 | + | let mut names: Vec<String> = app.repos.read(&repo, |r, _| Ok(r.view()?.bookmarks.keys().cloned().collect())).await.unwrap_or_default(); |
|
| 83 | + | names.sort_by_key(|n| (n != &row.default_bookmark, n.clone())); |
|
| 84 | + | names |
|
| 85 | + | } |
|
| 86 | + | ||
| 87 | + | #[derive(Deserialize)] |
|
| 88 | + | pub struct StartForm { |
|
| 89 | + | #[serde(rename = "_csrf")] |
|
| 90 | + | csrf_token: String, |
|
| 91 | + | bookmark: String, |
|
| 92 | + | } |
|
| 93 | + | ||
| 94 | + | /// `POST /{owner}/{name}/flow/start`: "Run pipeline" on a bookmark. |
|
| 95 | + | pub async fn start( |
|
| 96 | + | State(app): State<Shared>, |
|
| 97 | + | viewer: Viewer, |
|
| 98 | + | Path((owner, name)): Path<(String, String)>, |
|
| 99 | + | Form(f): Form<StartForm>, |
|
| 100 | + | ) -> Result<Response, Error> { |
|
| 101 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/flow"), &viewer); |
|
| 102 | + | let me = require(&viewer, &shell)?; |
|
| 103 | + | let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?; |
|
| 104 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 105 | + | Ok(match crate::flow::run_bookmark(&app, &row, f.bookmark.trim(), &me.account.username).await { |
|
| 106 | + | Ok(n) => { |
|
| 107 | + | db::accounts::audit(&app.db, Some(&me.account), "flow.run", &format!("{owner}/{name}"), &format!("#{n} {}", f.bookmark.trim())) |
|
| 108 | + | .await |
|
| 109 | + | .ok(); |
|
| 110 | + | Redirect::to(&format!("/{owner}/{name}/flow/{n}")).into_response() |
|
| 111 | + | } |
|
| 112 | + | Err(msg) => Redirect::to(&format!("/{owner}/{name}/flow?error={}", urlencoding(&msg))).into_response(), |
|
| 113 | + | }) |
|
| 114 | + | } |
|
| 115 | + | ||
| 116 | + | #[derive(Deserialize)] |
|
| 117 | + | pub struct RerunForm { |
|
| 118 | + | #[serde(rename = "_csrf")] |
|
| 119 | + | csrf_token: String, |
|
| 120 | + | } |
|
| 121 | + | ||
| 122 | + | /// `POST /{owner}/{name}/flow/{number}/rerun`. |
|
| 123 | + | pub async fn rerun( |
|
| 124 | + | State(app): State<Shared>, |
|
| 125 | + | viewer: Viewer, |
|
| 126 | + | Path((owner, name, number)): Path<(String, String, i64)>, |
|
| 127 | + | Form(f): Form<RerunForm>, |
|
| 128 | + | ) -> Result<Response, Error> { |
|
| 129 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/flow/{number}"), &viewer); |
|
| 130 | + | let me = require(&viewer, &shell)?; |
|
| 131 | + | let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?; |
|
| 132 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 133 | + | let run = fdb::run(&app.db, row.id, number) |
|
| 134 | + | .await |
|
| 135 | + | .map_err(|e| Error::from_db(shell.clone(), e))? |
|
| 136 | + | .ok_or_else(|| Error::not_found(shell.clone()))?; |
|
| 137 | + | Ok(match crate::flow::rerun(&app, &row, &run, &me.account.username).await { |
|
| 138 | + | Ok(n) => { |
|
| 139 | + | db::accounts::audit(&app.db, Some(&me.account), "flow.rerun", &format!("{owner}/{name}"), &format!("#{n} of #{number}")) |
|
| 140 | + | .await |
|
| 141 | + | .ok(); |
|
| 142 | + | Redirect::to(&format!("/{owner}/{name}/flow/{n}")).into_response() |
|
| 143 | + | } |
|
| 144 | + | Err(msg) => Redirect::to(&format!("/{owner}/{name}/flow?error={}", urlencoding(&msg))).into_response(), |
|
| 145 | + | }) |
|
| 146 | + | } |
|
| 147 | + | ||
| 148 | + | /// Percent-encode a message for a query string. |
|
| 149 | + | fn urlencoding(s: &str) -> String { |
|
| 150 | + | s.bytes() |
|
| 151 | + | .map(|b| match b { |
|
| 152 | + | b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => (b as char).to_string(), |
|
| 153 | + | _ => format!("%{b:02X}"), |
|
| 154 | + | }) |
|
| 155 | + | .collect() |
|
| 59 | 156 | } |
|
| 60 | 157 | ||
| 61 | 158 | /// A job placed in the graph. |
|
| … | 84 unchanged lines |
||
| 146 | 243 | graph: Graph, |
|
| 147 | 244 | logs: Vec<JobLog>, |
|
| 148 | 245 | duration: String, |
|
| 246 | + | /// Whether the viewer may re-run it. |
|
| 247 | + | can_rerun: bool, |
|
| 149 | 248 | } |
|
| 150 | 249 | ||
| 151 | 250 | /// Lines of a job's log a page shows before "the rest is loaded as it comes". |
|
| … | 22 unchanged lines |
||
| 174 | 273 | None => crate::flow::human_ms(db::now_ms() - run.created_ms), |
|
| 175 | 274 | }; |
|
| 176 | 275 | let graph = layout(jobs); |
|
| 177 | - | Ok(Page(RunPage { shell, repo: RepoShell::new(&app, &row, "flow").with_role(role), run, graph, logs, duration })) |
|
| 276 | + | let can_rerun = !run.live() && viewer.account().is_some() && role >= Role::Write && crate::flow::enabled(&app, &row); |
|
| 277 | + | Ok(Page(RunPage { shell, repo: RepoShell::new(&app, &row, "flow").with_role(role), run, graph, logs, duration, can_rerun })) |
|
| 178 | 278 | } |
|
| 179 | 279 | ||
| 180 | 280 | #[derive(Deserialize)] |
|
| … | 174 unchanged lines |
modified crates/hub/src/web/mod.rs +2 −0
| … | 141 unchanged lines |
||
| 142 | 142 | .route("/{owner}/{name}/flow", get(flow::runs)) |
|
| 143 | 143 | .route("/{owner}/{name}/flow/{id}", get(flow::run)) |
|
| 144 | 144 | .route("/{owner}/{name}/flow/{id}/live", get(flow::live)) |
|
| 145 | + | .route("/{owner}/{name}/flow/start", post(flow::start)) |
|
| 146 | + | .route("/{owner}/{name}/flow/{id}/rerun", post(flow::rerun)) |
|
| 145 | 147 | .route("/{owner}/{name}/flow/stamp/{r}", get(flow::stamp)) |
|
| 146 | 148 | .route("/{owner}/{name}/flow/at/{r}", get(flow::at)) |
|
| 147 | 149 | .route("/{owner}/{name}/releases", get(releases::list)) |
|
| … | 433 unchanged lines |
modified crates/hub/tests/flow.rs +68 −0
| … | 536 unchanged lines |
||
| 537 | 537 | assert!(st == StatusCode::OK && page.contains("Run #1"), "{st}"); |
|
| 538 | 538 | assert!(runs_of(&h.app, "andy", "quiet").await.is_empty(), "turned off despite andy/*"); |
|
| 539 | 539 | } |
|
| 540 | + | ||
| 541 | + | /// "Run pipeline" and "Re-run" (hub spec §13.2): runs that move no |
|
| 542 | + | /// bookmark, through the API and the pages. |
|
| 543 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 544 | + | async fn runs_start_without_a_push_and_finished_runs_re_run() { |
|
| 545 | + | let h = hub().await; |
|
| 546 | + | // The site token acts as the first site administrator. |
|
| 547 | + | db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", true).await.unwrap(); |
|
| 548 | + | create(&h.app, "andy", "demo").await; |
|
| 549 | + | let url = format!("{}/andy/demo", h.base); |
|
| 550 | + | let work = tempfile::tempdir().unwrap(); |
|
| 551 | + | let c = client(&url, work.path()); |
|
| 552 | + | write(work.path(), ".flow/pipeline.kdl", &pipeline("")); |
|
| 553 | + | write(work.path(), "test.sh", "echo ok\n"); |
|
| 554 | + | c.commit("Add a pipeline", &[]).unwrap(); |
|
| 555 | + | assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 556 | + | let first = runs_of(&h.app, "andy", "demo").await; |
|
| 557 | + | assert_eq!((first.len(), first[0].trigger.as_str()), (1, "push")); |
|
| 558 | + | ||
| 559 | + | // Run pipeline on the default bookmark: a new run of the same state. |
|
| 560 | + | let (st, body) = call(&h.app, "POST", "/api/v1/repos/andy/demo/flow/runs", Some("site"), Some("{}".into())).await; |
|
| 561 | + | assert_eq!(st, StatusCode::CREATED, "{body}"); |
|
| 562 | + | let v: serde_json::Value = serde_json::from_str(&body).unwrap(); |
|
| 563 | + | assert_eq!(v["number"], 2); |
|
| 564 | + | assert!(v["url"].as_str().unwrap().ends_with("/andy/demo/flow/2")); |
|
| 565 | + | let second = db::flow::run(&h.app.db, first[0].repo_id, 2).await.unwrap().unwrap(); |
|
| 566 | + | assert_eq!((second.trigger.as_str(), second.actor.as_str(), second.state.as_str()), ("manual", "andy", first[0].state.as_str())); |
|
| 567 | + | assert_eq!(db::flow::jobs(&h.app.db, second.id).await.unwrap().len(), 3); |
|
| 568 | + | let (_, page) = call(&h.app, "GET", "/andy/demo/flow/2", None, None).await; |
|
| 569 | + | assert!(page.contains("started by andy"), "the run page says what started it"); |
|
| 570 | + | ||
| 571 | + | // A live run is not re-run; unknown bookmarks and runs are refused. |
|
| 572 | + | let (st, body) = call(&h.app, "POST", "/api/v1/repos/andy/demo/flow/runs/1/rerun", Some("site"), None).await; |
|
| 573 | + | assert!(st == StatusCode::CONFLICT && body.contains("still queued"), "{st} {body}"); |
|
| 574 | + | let (st, _) = call(&h.app, "POST", "/api/v1/repos/andy/demo/flow/runs", Some("site"), Some(r#"{"bookmark":"nope"}"#.into())).await; |
|
| 575 | + | assert_eq!(st, StatusCode::CONFLICT); |
|
| 576 | + | let (st, _) = call(&h.app, "POST", "/api/v1/repos/andy/demo/flow/runs/99/rerun", Some("site"), None).await; |
|
| 577 | + | assert_eq!(st, StatusCode::NOT_FOUND); |
|
| 578 | + | // Anonymous callers cannot start runs. |
|
| 579 | + | let (st, _) = call(&h.app, "POST", "/api/v1/repos/andy/demo/flow/runs", None, Some("{}".into())).await; |
|
| 580 | + | assert_ne!(st, StatusCode::CREATED); |
|
| 581 | + | ||
| 582 | + | // A finished run re-runs as a new run of the same state. |
|
| 583 | + | let row = db::repos::get(&h.app.db, "andy", "demo").await.unwrap().unwrap(); |
|
| 584 | + | for r in [&first[0], &second] { |
|
| 585 | + | for j in db::flow::jobs(&h.app.db, r.id).await.unwrap() { |
|
| 586 | + | sqlx::query("UPDATE flow_jobs SET status = 'passed' WHERE id = ?").bind(j.id).execute(&h.app.db).await.unwrap(); |
|
| 587 | + | } |
|
| 588 | + | sqlx::query("UPDATE flow_runs SET status = 'passed', finished_ms = 1 WHERE id = ?").bind(r.id).execute(&h.app.db).await.unwrap(); |
|
| 589 | + | } |
|
| 590 | + | let (st, body) = call(&h.app, "POST", "/api/v1/repos/andy/demo/flow/runs/1/rerun", Some("site"), None).await; |
|
| 591 | + | assert_eq!(st, StatusCode::CREATED, "{body}"); |
|
| 592 | + | let third = db::flow::run(&h.app.db, row.id, 3).await.unwrap().unwrap(); |
|
| 593 | + | assert_eq!((third.trigger.as_str(), third.status.as_str(), third.state.as_str()), ("rerun:1", "queued", first[0].state.as_str())); |
|
| 594 | + | let checks = db::hooks::checks_for_state(&h.app.db, row.id, &third.state).await.unwrap(); |
|
| 595 | + | assert!( |
|
| 596 | + | checks.iter().all(|c| c.status == "queued" && c.url.ends_with("/flow/3")), |
|
| 597 | + | "the re-run's checks replace the old ones: {checks:?}" |
|
| 598 | + | ); |
|
| 599 | + | let (_, page) = call(&h.app, "GET", "/andy/demo/flow/3", None, None).await; |
|
| 600 | + | assert!(page.contains("re-run of #1 by andy")); |
|
| 601 | + | assert!(!page.contains("RE-RUN"), "anonymous viewers get no button"); |
|
| 602 | + | ||
| 603 | + | // With Flow off, nothing starts. |
|
| 604 | + | db::repos::set_flow(&h.app.db, row.id, "off").await.unwrap(); |
|
| 605 | + | let (st, body) = call(&h.app, "POST", "/api/v1/repos/andy/demo/flow/runs", Some("site"), Some("{}".into())).await; |
|
| 606 | + | assert!(st == StatusCode::CONFLICT && body.contains("not enabled"), "{body}"); |
|
| 607 | + | } |
modified docs/spec.md +2 −2
| … | 399 unchanged lines |
||
| 400 | 400 | ||
| 401 | 401 | ### 12.1 API |
|
| 402 | 402 | ||
| 403 | - | JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses, cursor pagination (`?after=`), ETags on cacheable resources, and rate limits per token. Resources mirror the pages: `/repos/{owner}/{name}`, `/bookmarks`, `/changes/{hash}`, `/tree/{ref}/{path}`, `/file/{ref}/{path}`, `/blame`, `/history`, `/compare`, `/conflicts`, `/proposals`, `/proposals/{n}/comments|reviews|checks|files|land`, `/proposals/{n}/content/{base|head}/{path}` (a changed file's bytes in the target's state or the projected state, for editor diff views), `/issues` (creating an issue can set its labels, assignees and milestone; `PATCH /issues/{n}` edits the title and body for the author or triage, and replaces labels, assignees or the milestone for triage, recording each on the timeline; unknown names are refused before anything changes), `/labels` (`POST` creates or changes one, `DELETE /labels/{name}` removes it; triage), `/milestones`, `/releases`, `/tags`, `/trust`, `/pushes`, `/hooks`, `/checks`, `/user`, `/notifications` (the caller's inbox and unread count; `/notifications/{id}/read` and `/notifications/read` mark read; scope `user:read`), `/users/{name}`, `/orgs/{name}`, `/checks/required` (`GET`, and `PUT` of the whole list by a repository admin: the checks a proposal needs, passed, to land), `/projects/{owner}` (list, create) and `/projects/{owner}/{n}` (the board as the caller may see it, `PATCH` for title, description, privacy and closing, `/cards` to add a reference or note to a column named by id or name, `/cards/{id}` to move or remove one; `repo:write` and the page's rights to change), `/search`. Shapes reuse `oxy --json` shapes wherever one exists (change, status, blame, diff) so tools written against the CLI work against the hub. `crates/hub-api` holds the types with `serde` derives and is what the runner and the VS Code extension will link. |
|
| 403 | + | JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses, cursor pagination (`?after=`), ETags on cacheable resources, and rate limits per token. Resources mirror the pages: `/repos/{owner}/{name}`, `/bookmarks`, `/changes/{hash}`, `/tree/{ref}/{path}`, `/file/{ref}/{path}`, `/blame`, `/history`, `/compare`, `/conflicts`, `/proposals`, `/proposals/{n}/comments|reviews|checks|files|land`, `/proposals/{n}/content/{base|head}/{path}` (a changed file's bytes in the target's state or the projected state, for editor diff views), `/issues` (creating an issue can set its labels, assignees and milestone; `PATCH /issues/{n}` edits the title and body for the author or triage, and replaces labels, assignees or the milestone for triage, recording each on the timeline; unknown names are refused before anything changes), `/labels` (`POST` creates or changes one, `DELETE /labels/{name}` removes it; triage), `/milestones`, `/releases`, `/tags`, `/trust`, `/pushes`, `/hooks`, `/checks`, `/user`, `/notifications` (the caller's inbox and unread count; `/notifications/{id}/read` and `/notifications/read` mark read; scope `user:read`), `/users/{name}`, `/orgs/{name}`, `/checks/required` (`GET`, and `PUT` of the whole list by a repository admin: the checks a proposal needs, passed, to land), `/projects/{owner}` (list, create) and `/projects/{owner}/{n}` (the board as the caller may see it, `PATCH` for title, description, privacy and closing, `/cards` to add a reference or note to a column named by id or name, `/cards/{id}` to move or remove one; `repo:write` and the page's rights to change), `/flow/runs` (`POST {"bookmark"}`, repo:write: run a bookmark's current state, the default bookmark when none is named) and `/flow/runs/{n}/rerun` (repo:write: a finished run again), both answering `201 {"number", "url"}` or `409` with why nothing started, `/search`. Shapes reuse `oxy --json` shapes wherever one exists (change, status, blame, diff) so tools written against the CLI work against the hub. `crates/hub-api` holds the types with `serde` derives and is what the runner and the VS Code extension will link. |
|
| 404 | 404 | ||
| 405 | 405 | ### 12.2 Webhooks |
|
| 406 | 406 | ||
| … | 13 unchanged lines |
||
| 420 | 420 | ||
| 421 | 421 | - **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) and `job_timeout_minutes` (60). |
|
| 422 | 422 | - **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. |
|
| 423 | - | - **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. |
|
| 423 | + | - **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. Writers also start runs without moving a bookmark: **Run pipeline** on the Flow tab runs a bookmark's current state, and **Re-run** on a finished run's page runs its state again. The new run's checks replace the old ones for that state, and the run page says what started it (pushed by, started by, re-run of #N by). |
|
| 424 | 424 | - **Runs and jobs.** A run holds the repository, bookmark, state hash and heads, pipeline hash and pusher; one job per pipeline job with its compiled form, sandbox and dependencies. Job states: `queued`, `running`, `passed`, `failed`, `skipped` (a dependency did not pass), `error` (the runner could not run it). A run is `running` while any job is, then `passed` when every job passed, else `failed`. |
|
| 425 | 425 | - **Runner API** (runner token): `POST /api/v1/flow/claim` hands out the oldest queued job whose dependencies passed and whose tier the runner offers, marking it `running`, with a **job token**; `204` when there is none. `POST /api/v1/flow/jobs/{id}/log` appends log records (text cut at 16 KiB, at most 50 000 per job); `POST /api/v1/flow/jobs/{id}/finish` records the exit code and receipt (the hub recomputes the receipt hash) and settles dependents. Only the runner holding the job may report on it. |
|
| 426 | 426 | - **Job tokens.** Random, stored as a hash, valid only for reading the job's repository over the wire protocol while the job is running and for at most two hours. They cannot push, call the API or read other repositories. |
|
| … | 158 unchanged lines |
modified templates/flow_run.html +2 −1
| … | 6 unchanged lines |
||
| 7 | 7 | {% block content %} |
|
| 8 | 8 | <div class="head"> |
|
| 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 | - | <p class="small mute"><span class="lbl lbl-bm">{{ run.bookmark }}</span> at <a href="{{ repo.url }}/tree/{{ run.heads }}" title="state {{ run.state }}"><code>{{ run.state|short }}</code></a> · pushed by {{ run.actor }} <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> |
|
| 10 | + | <p class="small mute"><span class="lbl lbl-bm">{{ run.bookmark }}</span> 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 | + | {% 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 %} |
|
| 11 | 12 | </div> |
|
| 12 | 13 | {% if run.status == "error" %} |
|
| 13 | 14 | <section class="panel"><div class="strip bad">The pipeline is not valid, so nothing ran.</div><pre class="snippet">{{ run.error }}</pre><p class="small mute">Run <code>flow check</code> locally to see the same problems.</p></section> |
|
| … | 26 unchanged lines |
modified templates/flow_runs.html +4 −1
| … | 3 unchanged lines |
||
| 4 | 4 | {% block bodyattrs %} data-repo="{{ repo.url }}"{% endblock %} |
|
| 5 | 5 | {% block tabs %}{% call m::tabs(repo, "flow") %}{% endblock %} |
|
| 6 | 6 | {% block content %} |
|
| 7 | - | <div class="head"><h1>Flow <small>pipeline runs</small></h1></div> |
|
| 7 | + | <div class="head"><h1>Flow <small>pipeline runs</small></h1> |
|
| 8 | + | {% if can_run %}<form class="inline" method="post" action="{{ repo.url }}/flow/start"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><label for="run-bookmark" class="small mute">Run the pipeline of</label> <select id="run-bookmark" name="bookmark">{% for b in bookmarks %}<option value="{{ b }}">{{ b }}</option>{% endfor %}</select> <button type="submit" class="small">RUN PIPELINE</button></form>{% endif %} |
|
| 9 | + | </div> |
|
| 10 | + | {% if let Some(e) = error %}<p class="error">Could not start a run: {{ e }}</p>{% endif %} |
|
| 8 | 11 | {% if runs.is_empty() %} |
|
| 9 | 12 | <section class="panel"> |
|
| 10 | 13 | {% if enabled %} |
|
| … | 38 unchanged lines |