Run pipeline from bookmarks and changes, and say why it is unavailable 4f6925337848
Run pipeline is a primary button on the Flow tab. Viewers without it are
told why: Flow is off, they are not signed in, or they lack write access.
Each bookmark has Run pipeline beside it. A change page runs the state
whose only head is that change; such runs carry no bookmark and are
labelled "change". POST /flow/runs takes {"change"} too. Runs still only
take committed states in the repository.
closes #40
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-29 09:41 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
4f6925337848640d000d79db6b828d4855b36f694db276cb9474a253fd7d2a46- Depends on
- 145306044896 1edac4b68c6d 42fa95540468 9008bf6c2b8c 90326c9601f9 93be9ce4653e 9df855f29948 a0d4bbfbedd1 af9c6ca11c49 bf3d07541368 cc19697f8871 d23464c0ac60 e271a37b629a
- Depended on by
- 367f34b8e590 c7067c7bda56 a7aa76ac52d7 b644e1757711 d9b3b84605a4 0650d94ede82 348f8c4baccf c889911bec12 df15b74774ca
- 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
- 41 atoms, 0 tree operations, 12 files
Browse the tree at this change
modified crates/hub/src/api/mod.rs +16 −4
| … | 1541 unchanged lines |
||
| 1542 | 1542 | /// Default: the repository's default bookmark. |
|
| 1543 | 1543 | #[serde(default)] |
|
| 1544 | 1544 | bookmark: Option<String>, |
|
| 1545 | + | /// A change's full hash: run the state whose only head it is, instead |
|
| 1546 | + | /// of a bookmark's. |
|
| 1547 | + | #[serde(default)] |
|
| 1548 | + | change: Option<String>, |
|
| 1545 | 1549 | } |
|
| 1546 | 1550 | ||
| 1547 | 1551 | fn started(app: &Shared, row: &db::repos::RepoRow, number: i64) -> (StatusCode, Json<serde_json::Value>) { |
|
| … | 2 unchanged lines |
||
| 1550 | 1554 | } |
|
| 1551 | 1555 | ||
| 1552 | 1556 | /// `POST /repos/{o}/{n}/flow/runs` (repo:write): run a bookmark's current |
|
| 1553 | - | /// state without moving it (hub spec §13.2). |
|
| 1557 | + | /// state, or one change's, without moving anything (hub spec §13.2). |
|
| 1554 | 1558 | async fn start_flow_run( |
|
| 1555 | 1559 | State(app): State<Shared>, |
|
| 1556 | 1560 | headers: HeaderMap, |
|
| … | 3 unchanged lines |
||
| 1560 | 1564 | let c = caller(&app, &headers).await?; |
|
| 1561 | 1565 | let me = c.require("repo:write")?.clone(); |
|
| 1562 | 1566 | let (_, row, _) = reach(&app, &c, &owner, &name, Access::Write).await?; |
|
| 1563 | - | let bookmark = body.bookmark.unwrap_or_else(|| row.default_bookmark.clone()); |
|
| 1564 | - | let n = crate::flow::run_bookmark(&app, &row, &bookmark, &me.username).await.map_err(|m| ApiError(StatusCode::CONFLICT, m))?; |
|
| 1565 | - | db::accounts::audit(&app.db, Some(&me), "flow.run", &format!("{owner}/{name}"), &format!("#{n} {bookmark}")).await.ok(); |
|
| 1567 | + | let (n, what) = match body.change.filter(|c| !c.trim().is_empty()) { |
|
| 1568 | + | Some(change) => { |
|
| 1569 | + | let n = crate::flow::run_change(&app, &row, &change, &me.username).await.map_err(|m| ApiError(StatusCode::CONFLICT, m))?; |
|
| 1570 | + | (n, format!("change {}", change.trim())) |
|
| 1571 | + | } |
|
| 1572 | + | None => { |
|
| 1573 | + | let bookmark = body.bookmark.unwrap_or_else(|| row.default_bookmark.clone()); |
|
| 1574 | + | (crate::flow::run_bookmark(&app, &row, &bookmark, &me.username).await.map_err(|m| ApiError(StatusCode::CONFLICT, m))?, bookmark) |
|
| 1575 | + | } |
|
| 1576 | + | }; |
|
| 1577 | + | db::accounts::audit(&app.db, Some(&me), "flow.run", &format!("{owner}/{name}"), &format!("#{n} {what}")).await.ok(); |
|
| 1566 | 1578 | Ok(started(&app, &row, n)) |
|
| 1567 | 1579 | } |
|
| 1568 | 1580 | ||
| … | 80 unchanged lines |
modified crates/hub/src/flow.rs +18 −0
| … | 64 unchanged lines |
||
| 65 | 65 | .ok_or_else(|| format!("{bookmark} has no {PIPELINE_PATH}")) |
|
| 66 | 66 | } |
|
| 67 | 67 | ||
| 68 | + | /// "Run pipeline" on one committed change: the state whose only head is |
|
| 69 | + | /// `change`, whatever bookmarks hold it. Returns the new run's number. |
|
| 70 | + | pub async fn run_change(app: &Shared, row: &db::repos::RepoRow, change: &str, actor: &str) -> Result<i64, String> { |
|
| 71 | + | if !enabled(app, row) { |
|
| 72 | + | return Err("OxygenFlow is not enabled for this repository".into()); |
|
| 73 | + | } |
|
| 74 | + | let h = ChangeHash::from_hex(change.trim().trim_start_matches('#')).ok_or("give the full hash of a change")?; |
|
| 75 | + | let repo = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?; |
|
| 76 | + | let known = app.repos.read(&repo, move |r, _| Ok(r.store().get_change(&h).is_ok())).await.map_err(|e| e.to_string())?; |
|
| 77 | + | if !known { |
|
| 78 | + | return Err(format!("no change {} in this repository", &change.trim()[..12.min(change.trim().len())])); |
|
| 79 | + | } |
|
| 80 | + | let state = oxy_core::hash::hash_object(oxy_core::hash::ObjectType::State, &oxy_core::objects::StateObject::new(vec![h]).encode()); |
|
| 81 | + | start_run(app, row, "", &state.to_hex(), &[h], actor, "manual") |
|
| 82 | + | .await? |
|
| 83 | + | .ok_or_else(|| format!("that change's state has no {PIPELINE_PATH}")) |
|
| 84 | + | } |
|
| 85 | + | ||
| 68 | 86 | /// Re-run a finished run: the same state, bookmark and pipeline, as a new |
|
| 69 | 87 | /// run whose checks replace the old ones. Returns the new run's number. |
|
| 70 | 88 | pub async fn rerun(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run, actor: &str) -> Result<i64, String> { |
|
| … | 501 unchanged lines |
modified crates/hub/src/web/flow.rs +31 −6
| … | 31 unchanged lines |
||
| 32 | 32 | can_run: bool, |
|
| 33 | 33 | bookmarks: Vec<String>, |
|
| 34 | 34 | error: Option<String>, |
|
| 35 | + | /// Why this viewer is not offered "Run pipeline", when they are not. |
|
| 36 | + | why_not: Option<String>, |
|
| 35 | 37 | } |
|
| 36 | 38 | ||
| 37 | 39 | #[derive(Deserialize)] |
|
| … | 3 unchanged lines |
||
| 41 | 43 | error: Option<String>, |
|
| 42 | 44 | } |
|
| 43 | 45 | ||
| 46 | + | /// Why `viewer` may not start runs here, or `None` when they may. |
|
| 47 | + | pub fn why_not_run(enabled: bool, signed_in: bool, role: Role) -> Option<String> { |
|
| 48 | + | if !enabled { |
|
| 49 | + | Some("Flow is off for this repository; a site administrator can turn it on in Settings → Flow.".into()) |
|
| 50 | + | } else if !signed_in { |
|
| 51 | + | Some("Sign in with write access to run the pipeline.".into()) |
|
| 52 | + | } else if role < Role::Write { |
|
| 53 | + | Some("Running the pipeline needs write access to this repository.".into()) |
|
| 54 | + | } else { |
|
| 55 | + | None |
|
| 56 | + | } |
|
| 57 | + | } |
|
| 58 | + | ||
| 44 | 59 | const PER_PAGE: i64 = 30; |
|
| 45 | 60 | ||
| 46 | 61 | pub async fn runs( |
|
| … | 19 unchanged lines |
||
| 66 | 81 | runs.push(RunRow { jobs: jobs.into_iter().map(|j| (j.name, j.status)).collect(), duration, r, place }); |
|
| 67 | 82 | } |
|
| 68 | 83 | let enabled = crate::flow::enabled(&app, &row); |
|
| 69 | - | let can_run = enabled && viewer.account().is_some() && role >= Role::Write; |
|
| 84 | + | let why_not = why_not_run(enabled, viewer.account().is_some(), role); |
|
| 85 | + | let can_run = why_not.is_none(); |
|
| 70 | 86 | let bookmarks = if can_run { bookmark_names(&app, &row).await } else { vec![] }; |
|
| 71 | 87 | Ok(Page(RunsPage { |
|
| 72 | 88 | shell, |
|
| … | 4 unchanged lines |
||
| 77 | 93 | can_run, |
|
| 78 | 94 | bookmarks, |
|
| 79 | 95 | error: q.error, |
|
| 96 | + | why_not, |
|
| 80 | 97 | })) |
|
| 81 | 98 | } |
|
| 82 | 99 | ||
| … | 9 unchanged lines |
||
| 92 | 109 | pub struct StartForm { |
|
| 93 | 110 | #[serde(rename = "_csrf")] |
|
| 94 | 111 | csrf_token: String, |
|
| 112 | + | #[serde(default)] |
|
| 95 | 113 | bookmark: String, |
|
| 114 | + | /// A change's full hash: run the state whose only head it is. |
|
| 115 | + | #[serde(default)] |
|
| 116 | + | change: String, |
|
| 96 | 117 | } |
|
| 97 | 118 | ||
| 98 | - | /// `POST /{owner}/{name}/flow/start`: "Run pipeline" on a bookmark. |
|
| 119 | + | /// `POST /{owner}/{name}/flow/start`: "Run pipeline" on a bookmark's |
|
| 120 | + | /// current state, or on one committed change. |
|
| 99 | 121 | pub async fn start( |
|
| 100 | 122 | State(app): State<Shared>, |
|
| 101 | 123 | viewer: Viewer, |
|
| … | 4 unchanged lines |
||
| 106 | 128 | let me = require(&viewer, &shell)?; |
|
| 107 | 129 | let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?; |
|
| 108 | 130 | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 109 | - | Ok(match crate::flow::run_bookmark(&app, &row, f.bookmark.trim(), &me.account.username).await { |
|
| 131 | + | let (started, what) = if f.change.trim().is_empty() { |
|
| 132 | + | (crate::flow::run_bookmark(&app, &row, f.bookmark.trim(), &me.account.username).await, f.bookmark.trim().to_string()) |
|
| 133 | + | } else { |
|
| 134 | + | (crate::flow::run_change(&app, &row, &f.change, &me.account.username).await, format!("change {}", f.change.trim())) |
|
| 135 | + | }; |
|
| 136 | + | Ok(match started { |
|
| 110 | 137 | Ok(n) => { |
|
| 111 | - | db::accounts::audit(&app.db, Some(&me.account), "flow.run", &format!("{owner}/{name}"), &format!("#{n} {}", f.bookmark.trim())) |
|
| 112 | - | .await |
|
| 113 | - | .ok(); |
|
| 138 | + | db::accounts::audit(&app.db, Some(&me.account), "flow.run", &format!("{owner}/{name}"), &format!("#{n} {what}")).await.ok(); |
|
| 114 | 139 | Redirect::to(&format!("/{owner}/{name}/flow/{n}")).into_response() |
|
| 115 | 140 | } |
|
| 116 | 141 | Err(msg) => Redirect::to(&format!("/{owner}/{name}/flow?error={}", urlencoding(&msg))).into_response(), |
|
| … | 429 unchanged lines |
modified crates/hub/src/web/history.rs +13 −1
| … | 172 unchanged lines |
||
| 173 | 173 | ignore_ws: bool, |
|
| 174 | 174 | /// Receipts runners signed for the state whose only head is this change. |
|
| 175 | 175 | receipts: Vec<super::flow::ReceiptRow>, |
|
| 176 | + | /// Whether the viewer may run the pipeline on this change. |
|
| 177 | + | can_run: bool, |
|
| 176 | 178 | } |
|
| 177 | 179 | ||
| 178 | 180 | /// `?w=1` on a diff page hides whitespace-only changes. |
|
| … | 41 unchanged lines |
||
| 220 | 222 | Some(h) => super::flow::receipts_of(&app, &repo, vec![h]).await, |
|
| 221 | 223 | None => vec![], |
|
| 222 | 224 | }; |
|
| 223 | - | Ok(Page(ChangePage { shell, repo: RepoShell::new(&app, &row, "changes").with_role(role), c, files, body_html, ignore_ws, receipts })) |
|
| 225 | + | let can_run = super::flow::why_not_run(crate::flow::enabled(&app, &row), viewer.account().is_some(), role).is_none(); |
|
| 226 | + | Ok(Page(ChangePage { |
|
| 227 | + | shell, |
|
| 228 | + | repo: RepoShell::new(&app, &row, "changes").with_role(role), |
|
| 229 | + | c, |
|
| 230 | + | files, |
|
| 231 | + | body_html, |
|
| 232 | + | ignore_ws, |
|
| 233 | + | receipts, |
|
| 234 | + | can_run, |
|
| 235 | + | })) |
|
| 224 | 236 | } |
|
| 225 | 237 | ||
| 226 | 238 | #[derive(Template)] |
|
| … | 110 unchanged lines |
modified crates/hub/src/web/refs.rs +12 −1
| … | 16 unchanged lines |
||
| 17 | 17 | bookmarks: Vec<view::BookmarkView>, |
|
| 18 | 18 | /// Whether the viewer may delete bookmarks (write access). |
|
| 19 | 19 | can_delete: bool, |
|
| 20 | + | /// Whether the viewer may run the pipeline of a bookmark. |
|
| 21 | + | can_run: bool, |
|
| 20 | 22 | notice: Option<String>, |
|
| 21 | 23 | error: Option<String>, |
|
| 22 | 24 | } |
|
| … | 11 unchanged lines |
||
| 34 | 36 | let bookmarks = |
|
| 35 | 37 | app.repos.read(&repo, move |r, cache| view::bookmarks(r, cache, &default)).await.map_err(|e| Error::from_repo(shell.clone(), e))?; |
|
| 36 | 38 | let can_delete = shell.user.is_some() && role >= Role::Write; |
|
| 37 | - | Ok(Page(BookmarksPage { shell, repo: RepoShell::new(app, &row, "bookmarks").with_role(role), bookmarks, can_delete, notice, error })) |
|
| 39 | + | let can_run = super::flow::why_not_run(crate::flow::enabled(app, &row), shell.user.is_some(), role).is_none(); |
|
| 40 | + | Ok(Page(BookmarksPage { |
|
| 41 | + | shell, |
|
| 42 | + | repo: RepoShell::new(app, &row, "bookmarks").with_role(role), |
|
| 43 | + | bookmarks, |
|
| 44 | + | can_delete, |
|
| 45 | + | can_run, |
|
| 46 | + | notice, |
|
| 47 | + | error, |
|
| 48 | + | })) |
|
| 38 | 49 | } |
|
| 39 | 50 | ||
| 40 | 51 | pub async fn bookmarks( |
|
| … | 164 unchanged lines |
modified crates/hub/tests/flow.rs +27 −3
| … | 594 unchanged lines |
||
| 595 | 595 | let (_, page) = call(&h.app, "GET", "/andy/demo/flow/2", None, None).await; |
|
| 596 | 596 | assert!(page.contains("started by andy"), "the run page says what started it"); |
|
| 597 | 597 | ||
| 598 | + | // Run pipeline on one change: its state, with no bookmark. |
|
| 599 | + | let head = first[0].head_list()[0].to_string(); |
|
| 600 | + | let (st, body) = |
|
| 601 | + | call(&h.app, "POST", "/api/v1/repos/andy/demo/flow/runs", Some("site"), Some(format!(r#"{{"change":"{head}"}}"#))).await; |
|
| 602 | + | assert_eq!(st, StatusCode::CREATED, "{body}"); |
|
| 603 | + | let on_change = db::flow::run(&h.app.db, first[0].repo_id, 3).await.unwrap().unwrap(); |
|
| 604 | + | assert_eq!( |
|
| 605 | + | (on_change.bookmark.as_str(), on_change.heads.as_str(), on_change.state.as_str()), |
|
| 606 | + | ("", head.as_str(), first[0].state.as_str()) |
|
| 607 | + | ); |
|
| 608 | + | let (_, page) = call(&h.app, "GET", "/andy/demo/flow/3", None, None).await; |
|
| 609 | + | assert!(page.contains("title=\"Run on one change, not a bookmark\">change</span>"), "run page"); |
|
| 610 | + | let (st, body) = |
|
| 611 | + | call(&h.app, "POST", "/api/v1/repos/andy/demo/flow/runs", Some("site"), Some(format!(r#"{{"change":"{}"}}"#, "ab".repeat(32)))) |
|
| 612 | + | .await; |
|
| 613 | + | assert!(st == StatusCode::CONFLICT && body.contains("no change"), "{st} {body}"); |
|
| 614 | + | // Viewers who cannot start runs are told why, and get no button. |
|
| 615 | + | let (_, page) = call(&h.app, "GET", "/andy/demo/flow", None, None).await; |
|
| 616 | + | assert!(page.contains("Sign in with write access to run the pipeline.") && !page.contains("primary-btn")); |
|
| 617 | + | let (_, page) = call(&h.app, "GET", "/andy/demo/bookmarks", None, None).await; |
|
| 618 | + | assert!(!page.contains("/flow/start"), "no run buttons for anonymous viewers"); |
|
| 619 | + | ||
| 598 | 620 | // A live run is not re-run; unknown bookmarks and runs are refused. |
|
| 599 | 621 | let (st, body) = call(&h.app, "POST", "/api/v1/repos/andy/demo/flow/runs/1/rerun", Some("site"), None).await; |
|
| 600 | 622 | assert!(st == StatusCode::CONFLICT && body.contains("still queued"), "{st} {body}"); |
|
| … | 15 unchanged lines |
||
| 616 | 638 | } |
|
| 617 | 639 | let (st, body) = call(&h.app, "POST", "/api/v1/repos/andy/demo/flow/runs/1/rerun", Some("site"), None).await; |
|
| 618 | 640 | assert_eq!(st, StatusCode::CREATED, "{body}"); |
|
| 619 | - | let third = db::flow::run(&h.app.db, row.id, 3).await.unwrap().unwrap(); |
|
| 641 | + | let third = db::flow::run(&h.app.db, row.id, 4).await.unwrap().unwrap(); |
|
| 620 | 642 | assert_eq!((third.trigger.as_str(), third.status.as_str(), third.state.as_str()), ("rerun:1", "queued", first[0].state.as_str())); |
|
| 621 | 643 | let checks = db::hooks::checks_for_state(&h.app.db, row.id, &third.state).await.unwrap(); |
|
| 622 | 644 | assert!( |
|
| 623 | - | checks.iter().all(|c| c.status == "queued" && c.url.ends_with("/flow/3")), |
|
| 645 | + | checks.iter().all(|c| c.status == "queued" && c.url.ends_with("/flow/4")), |
|
| 624 | 646 | "the re-run's checks replace the old ones: {checks:?}" |
|
| 625 | 647 | ); |
|
| 626 | - | let (_, page) = call(&h.app, "GET", "/andy/demo/flow/3", None, None).await; |
|
| 648 | + | let (_, page) = call(&h.app, "GET", "/andy/demo/flow/4", None, None).await; |
|
| 627 | 649 | assert!(page.contains("re-run of #1 by andy")); |
|
| 628 | 650 | assert!(!page.contains("RE-RUN"), "anonymous viewers get no button"); |
|
| 629 | 651 | ||
| … | 1 unchanged lines |
||
| 631 | 653 | db::repos::set_flow(&h.app.db, row.id, "off").await.unwrap(); |
|
| 632 | 654 | let (st, body) = call(&h.app, "POST", "/api/v1/repos/andy/demo/flow/runs", Some("site"), Some("{}".into())).await; |
|
| 633 | 655 | assert!(st == StatusCode::CONFLICT && body.contains("not enabled"), "{body}"); |
|
| 656 | + | let (_, page) = call(&h.app, "GET", "/andy/demo/flow", None, None).await; |
|
| 657 | + | assert!(page.contains("Flow is off for this repository"), "and the Flow tab says so"); |
|
| 634 | 658 | } |
|
| 635 | 659 | ||
| 636 | 660 | /// The Flow API (hub spec §13.2): runs, a run with its jobs, a job's log, |
|
| … | 154 unchanged lines |
modified docs/spec.md +8 −1
| … | 421 unchanged lines |
||
| 422 | 422 | ||
| 423 | 423 | - **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). |
|
| 424 | 424 | - **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. |
|
| 425 | - | - **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** 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). |
|
| 425 | + | - **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: |
|
| 426 | + | - On the Flow tab and beside each bookmark on the bookmarks page, it runs a bookmark's current state. |
|
| 427 | + | - On a change page, it runs the state whose only head is that change. That run has no bookmark and is labelled "change". |
|
| 428 | + | - The API does the same with `POST /flow/runs` and `{"bookmark"}` or `{"change"}`. |
|
| 429 | + | ||
| 430 | + | Viewers who may not start runs get no button. The Flow tab says why: Flow is off for the repository, they are not signed in, or they lack write access. **Re-run** on a finished run's page runs its state again. |
|
| 431 | + | ||
| 432 | + | Runs only ever take committed states already in the repository, never uncommitted work. A run writes no history: its receipts are signed statements about a state (Oxygen spec §14.6). Running a state again adds another statement. The environment hash shows whether it ran in the same environment, and a matching result is independent confirmation. 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). |
|
| 426 | 433 | - **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`. |
|
| 427 | 434 | - **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. |
|
| 428 | 435 | - **Signed receipts** (Oxygen spec §14.6): `finish` may carry `signed_receipt`, the runner's signed receipt as hex. The hub keeps it in the repository when four things hold: |
|
| … | 165 unchanged lines |
modified templates/bookmarks.html +1 −1
| … | 14 unchanged lines |
||
| 15 | 15 | <td>{% for h in b.heads %}<a class="chip" href="{{ repo.url }}/change/{{ h }}">{{ h|short }}</a> {% endfor %}{% if b.heads.len() > 1 %}<span class="tag warn">{{ b.heads.len() }} heads</span>{% endif %}</td> |
|
| 16 | 16 | <td>{{ b.changes }}</td> |
|
| 17 | 17 | <td><time title="{{ b.latest_ms|when }}">{{ b.latest_ms|ago }}</time></td> |
|
| 18 | - | <td class="small"><a href="{{ repo.url }}/changes/{{ b.name }}">history</a>{% if !b.default %} · <a href="{{ repo.url }}/compare/{{ repo.default_bookmark }}...{{ b.name }}">compare with {{ repo.default_bookmark }}</a>{% if can_delete %} |
|
| 18 | + | <td class="small">{% if can_run %}<form class="inline" method="post" action="{{ repo.url }}/flow/start"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="bookmark" value="{{ b.name }}"><button type="submit" class="small" title="Run the pipeline of {{ b.name }}'s current state">Run pipeline</button></form> · {% endif %}<a href="{{ repo.url }}/changes/{{ b.name }}">history</a>{% if !b.default %} · <a href="{{ repo.url }}/compare/{{ repo.default_bookmark }}...{{ b.name }}">compare with {{ repo.default_bookmark }}</a>{% if can_delete %} |
|
| 19 | 19 | <form class="inline" method="post" action="{{ repo.url }}/bookmarks/delete"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="bookmark" value="{{ b.name }}"><button type="submit" class="small danger-btn" onclick="return confirm('Delete the bookmark {{ b.name }}? Its changes stay while anything else reaches them.')">DELETE</button></form>{% endif %}{% endif %}</td> |
|
| 20 | 20 | </tr> |
|
| 21 | 21 | {% endfor %} |
|
| … | 2 unchanged lines |
modified templates/change.html +1 −1
| … | 19 unchanged lines |
||
| 20 | 20 | {% if !receipts.is_empty() %}<dt>Receipts</dt><dd class="receipts">{% for r in receipts %}<div><span class="flow-glyph st-{% if r.passed() %}passed{% else %}failed{% endif %}">{% if r.passed() %}✔{% else %}✘{% endif %}</span> {% if r.run.is_empty() %}<strong>{{ r.job }}</strong>{% else %}<a href="{{ r.run }}"><strong>{{ r.job }}</strong></a>{% endif %} <span class="small mute">{% if r.passed() %}passed{% else %}exit {{ r.exit_code }}{% endif %} in {{ r.duration }} · signed by {{ r.runner }} · <time title="{{ r.made_ms|when }}">{{ r.made_ms|ago }}</time></span>{% if !r.problem.is_empty() %} <span class="small warn">{{ r.problem }}</span>{% endif %} <a class="small" href="{{ repo.url }}/receipts/{{ r.attest }}" title="The signed receipt, for oxy receipt verify">download</a></div>{% endfor %}</dd>{% endif %} |
|
| 21 | 21 | <dt>Edits</dt><dd>{{ c.atoms|plural("atom", "atoms") }}, {{ c.tree_ops|plural("tree operation", "tree operations") }}, {{ files.len()|plural("file", "files") }}</dd> |
|
| 22 | 22 | </dl> |
|
| 23 | - | <p class="small"><a href="{{ repo.url }}/tree/{{ c.view.hash }}">Browse the tree at this change</a></p> |
|
| 23 | + | <p class="small"><a href="{{ repo.url }}/tree/{{ c.view.hash }}">Browse the tree at this change</a>{% if can_run %} · <form class="inline" method="post" action="{{ repo.url }}/flow/start"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="change" value="{{ c.view.hash }}"><button type="submit" class="small" title="Run the pipeline on the state whose only head is this change">Run pipeline on this change</button></form>{% endif %}</p> |
|
| 24 | 24 | <nav class="files small">{% for f in files %}<a href="#{{ f.anchor }}">{{ f.path }}</a>{% endfor %}</nav> |
|
| 25 | 25 | {% call m::diff(files, ignore_ws, "") %} |
|
| 26 | 26 | </article> |
|
| … | 1 unchanged lines |
modified templates/flow_run.html +1 −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> · {{ 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> |
|
| 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 | 12 | </div> |
|
| 13 | 13 | {% if let Some(q) = queue %} |
|
| … | 30 unchanged lines |
modified templates/flow_runs.html +4 −3
| … | 4 unchanged lines |
||
| 5 | 5 | {% block tabs %}{% call m::tabs(repo, "flow") %}{% endblock %} |
|
| 6 | 6 | {% block content %} |
|
| 7 | 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 %} |
|
| 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="primary-btn">Run pipeline</button></form>{% endif %} |
|
| 9 | 9 | </div> |
|
| 10 | + | {% if let Some(w) = why_not %}<p class="small mute">{{ w }}</p>{% endif %} |
|
| 10 | 11 | {% if let Some(e) = error %}<p class="error">Could not start a run: {{ e }}</p>{% endif %} |
|
| 11 | 12 | {% if runs.is_empty() %} |
|
| 12 | 13 | <section class="panel"> |
|
| 13 | 14 | {% if enabled %} |
|
| 14 | - | <p>No runs yet. Add <code>.flow/pipeline.kdl</code> to a bookmark and push: every push that moves a bookmark runs it in OxygenFlow microVMs.</p> |
|
| 15 | + | <p>No runs yet. Add <code>.flow/pipeline.kdl</code> to a bookmark and push: every push that moves a bookmark runs it in OxygenFlow microVMs. Once it is there, <strong>Run pipeline</strong> above runs a bookmark's current state without a push, as does the button beside each bookmark and on each change.</p> |
|
| 15 | 16 | {% else %} |
|
| 16 | 17 | <p>OxygenFlow is not enabled for this repository. A site administrator turns it on under <a href="{{ repo.url }}/settings#flow">Settings → OxygenFlow</a>.</p> |
|
| 17 | 18 | {% endif %} |
|
| … | 19 unchanged lines |
||
| 37 | 38 | <td class="st"><span class="flow-glyph st-{{ row.r.status }}" title="{{ row.r.status }}">{{ crate::web::flow::glyph(row.r.status) }}</span></td> |
|
| 38 | 39 | <td><a href="{{ repo.url }}/flow/{{ row.r.number }}"><strong>#{{ row.r.number }}</strong></a> <span class="small mute">{{ row.r.status }}{% if let Some(p) = row.place %}, {{ p }}{% endif %}</span></td> |
|
| 39 | 40 | <td class="flow-dots">{% for (n, s) in row.jobs %}<span class="flow-glyph st-{{ s }}" title="{{ n }}: {{ s }}">{{ crate::web::flow::glyph(s) }}</span>{% endfor %}{% if row.r.status == "error" %}<span class="small bad">pipeline invalid</span>{% endif %}</td> |
|
| 40 | - | <td><span class="lbl lbl-bm">{{ row.r.bookmark }}</span></td> |
|
| 41 | + | <td>{% if row.r.bookmark.is_empty() %}<span class="tag" title="Run on one change, not a bookmark">change</span>{% else %}<span class="lbl lbl-bm">{{ row.r.bookmark }}</span>{% endif %}</td> |
|
| 41 | 42 | <td><a href="{{ repo.url }}/tree/{{ row.r.heads }}" title="state {{ row.r.state }}"><code>{{ row.r.state|short }}</code></a></td> |
|
| 42 | 43 | <td>{{ row.r.actor }}</td> |
|
| 43 | 44 | <td><time title="{{ row.r.created_ms|when_i }}">{{ row.r.created_ms|ago_i }}</time></td> |
|
| … | 8 unchanged lines |
modified templates/style.css +2 −0
| … | 323 unchanged lines |
||
| 324 | 324 | .cm-host { border: 1px solid var(--line); margin: 8px 0; min-height: 360px; max-height: 70vh; overflow: auto; font-size: 13px; } |
|
| 325 | 325 | .cm-host .cm-editor { min-height: 360px; } |
|
| 326 | 326 | button.danger-btn { color: var(--bad); border-color: var(--bad); } |
|
| 327 | + | button.primary-btn { background: var(--acc); color: var(--now-ink); } |
|
| 328 | + | button.primary-btn:hover { filter: brightness(1.1); } |
|
| 327 | 329 | button:disabled { opacity: .45; cursor: not-allowed; } |
|
| 328 | 330 | ||
| 329 | 331 | /* EasyMDE in the hub's colours */ |
|
| … | 192 unchanged lines |