Cancel runs from oh, the run page, and newer pushes 745d225de344
- A run still going can be cancelled with oh run cancel N,
POST /flow/runs/{n}/cancel, or the Cancel button on its page. Its
unfinished jobs end as cancelled, and so do their checks.
- A push to a bookmark cancels that bookmark's push runs still going.
The new run's scope reaches back past them, so what they would have
checked is still checked.
- A runner learns of the cancel through 409 on its log batches. An
empty batch is now answered without a write.
- Migration 0033 rebuilds flow_runs and flow_jobs to allow the
cancelled status. Migrations now run on their own connection with
foreign keys off, then a foreign_key_check, since SQLite needs that
to rebuild a table that has children.
closes andymccall/oxygenflow#32
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-02 22:53 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
745d225de3440f319d14ec371e609ef92a75aebe29802ffd110452153de41a07- Depends on
- 070a7b64b368 10708ab8f54d 1edac4b68c6d 2291f8cc9c96 42fa95540468 5c6e92fd8d71 5dead1f4f26d 736f6813543d 93be9ce4653e 961c10bee174 af9c6ca11c49 b64e2537d702 bf3d07541368 c889911bec12 d23464c0ac60 d7ce41acf22c e9bfea8e26db eae4a97475f1
- Depended on by
- 50928fab5ffc ba0a55bb5b3c 367f34b8e590 a9f1c0bf403a 2abea30809dd a7aa76ac52d7 107eaba52c9d 3b1bc27fe805 5d90e0e045e2 b644e1757711 dc59d1b56c3d 417184c73f4e e8fa53694f68 3a01077da29a 4ac01a732552 5ea4872f23e8 d94449e42718
- 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.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
- 38 atoms, 1 tree operation, 14 files
Browse the tree at this change
added crates/hub/migrations/0033_flow_cancelled.sql +66 −0
| 1 | + | -- Cancelled runs and jobs (oxygenflow#32). SQLite cannot change a CHECK |
|
| 2 | + | -- constraint in place, so both tables are rebuilt. Migrations run with |
|
| 3 | + | -- foreign keys off (db::migrate), or dropping flow_jobs would take its |
|
| 4 | + | -- logs and artifacts with it. |
|
| 5 | + | ||
| 6 | + | CREATE TABLE flow_runs_new ( |
|
| 7 | + | id INTEGER PRIMARY KEY, |
|
| 8 | + | repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, |
|
| 9 | + | bookmark TEXT NOT NULL, |
|
| 10 | + | -- The bookmark's state hash (checks are keyed by it) and its heads, joined with '+'. |
|
| 11 | + | state TEXT NOT NULL, |
|
| 12 | + | heads TEXT NOT NULL, |
|
| 13 | + | pipeline_hash TEXT NOT NULL DEFAULT '', |
|
| 14 | + | status TEXT NOT NULL CHECK (status IN ('queued', 'running', 'passed', 'failed', 'error', 'cancelled')), |
|
| 15 | + | -- Pipeline problems, one per line, for a run in error. |
|
| 16 | + | error TEXT NOT NULL DEFAULT '', |
|
| 17 | + | actor TEXT NOT NULL DEFAULT '', |
|
| 18 | + | created_ms INTEGER NOT NULL, |
|
| 19 | + | finished_ms INTEGER, |
|
| 20 | + | number INTEGER NOT NULL DEFAULT 0, |
|
| 21 | + | trigger TEXT NOT NULL DEFAULT 'push' |
|
| 22 | + | ); |
|
| 23 | + | INSERT INTO flow_runs_new (id, repo_id, bookmark, state, heads, pipeline_hash, status, error, actor, created_ms, finished_ms, number, trigger) |
|
| 24 | + | SELECT id, repo_id, bookmark, state, heads, pipeline_hash, status, error, actor, created_ms, finished_ms, number, trigger FROM flow_runs; |
|
| 25 | + | DROP TABLE flow_runs; |
|
| 26 | + | ALTER TABLE flow_runs_new RENAME TO flow_runs; |
|
| 27 | + | CREATE INDEX flow_runs_by_repo ON flow_runs (repo_id, id DESC); |
|
| 28 | + | CREATE UNIQUE INDEX flow_runs_number ON flow_runs (repo_id, number); |
|
| 29 | + | ||
| 30 | + | CREATE TABLE flow_jobs_new ( |
|
| 31 | + | id INTEGER PRIMARY KEY, |
|
| 32 | + | run_id INTEGER NOT NULL REFERENCES flow_runs(id) ON DELETE CASCADE, |
|
| 33 | + | name TEXT NOT NULL, |
|
| 34 | + | depth INTEGER NOT NULL, |
|
| 35 | + | -- JSON: the job's dependencies, compiled form and sandbox (flow-core types). |
|
| 36 | + | depends TEXT NOT NULL, |
|
| 37 | + | spec TEXT NOT NULL, |
|
| 38 | + | sandbox TEXT NOT NULL, |
|
| 39 | + | runtime TEXT NOT NULL, |
|
| 40 | + | status TEXT NOT NULL CHECK (status IN ('queued', 'running', 'passed', 'failed', 'skipped', 'error', 'cancelled')), |
|
| 41 | + | runner TEXT NOT NULL DEFAULT '', |
|
| 42 | + | token_hash TEXT NOT NULL DEFAULT '', |
|
| 43 | + | started_ms INTEGER, |
|
| 44 | + | finished_ms INTEGER, |
|
| 45 | + | exit_code INTEGER, |
|
| 46 | + | error TEXT NOT NULL DEFAULT '', |
|
| 47 | + | receipt TEXT NOT NULL DEFAULT '', |
|
| 48 | + | receipt_hash TEXT NOT NULL DEFAULT '', |
|
| 49 | + | log_count INTEGER NOT NULL DEFAULT 0, |
|
| 50 | + | also_read TEXT NOT NULL DEFAULT '', |
|
| 51 | + | receipt_attest TEXT NOT NULL DEFAULT '', |
|
| 52 | + | receipt_signer TEXT NOT NULL DEFAULT '', |
|
| 53 | + | receipt_note TEXT NOT NULL DEFAULT '', |
|
| 54 | + | runner_id INTEGER, |
|
| 55 | + | timeout_s INTEGER NOT NULL DEFAULT 0, |
|
| 56 | + | UNIQUE (run_id, name) |
|
| 57 | + | ); |
|
| 58 | + | INSERT INTO flow_jobs_new (id, run_id, name, depth, depends, spec, sandbox, runtime, status, runner, token_hash, started_ms, finished_ms, |
|
| 59 | + | exit_code, error, receipt, receipt_hash, log_count, also_read, receipt_attest, receipt_signer, receipt_note, runner_id, timeout_s) |
|
| 60 | + | SELECT id, run_id, name, depth, depends, spec, sandbox, runtime, status, runner, token_hash, started_ms, finished_ms, |
|
| 61 | + | exit_code, error, receipt, receipt_hash, log_count, also_read, receipt_attest, receipt_signer, receipt_note, runner_id, timeout_s FROM flow_jobs; |
|
| 62 | + | DROP TABLE flow_jobs; |
|
| 63 | + | ALTER TABLE flow_jobs_new RENAME TO flow_jobs; |
|
| 64 | + | CREATE INDEX flow_jobs_queued ON flow_jobs (status, id); |
|
| 65 | + | CREATE INDEX flow_jobs_token ON flow_jobs (token_hash) WHERE token_hash <> ''; |
|
| 66 | + | CREATE INDEX flow_jobs_runner ON flow_jobs (runner_id); |
modified crates/hub/build.rs +2 −0
| … | 3 unchanged lines |
||
| 4 | 4 | fn main() { |
|
| 5 | 5 | println!("cargo:rerun-if-env-changed=OXY_BUILD"); |
|
| 6 | 6 | println!("cargo:rerun-if-env-changed=FLOW_HEADS"); |
|
| 7 | + | // The migrations `sqlx::migrate!` embeds. |
|
| 8 | + | println!("cargo:rerun-if-changed=migrations"); |
|
| 7 | 9 | let build = std::env::var("OXY_BUILD") |
|
| 8 | 10 | .ok() |
|
| 9 | 11 | .map(|b| b.trim().to_string()) |
|
| … | 11 unchanged lines |
modified crates/hub/src/api/mod.rs +17 −0
| … | 96 unchanged lines |
||
| 97 | 97 | .route("/repos/{owner}/{name}/flow/runs/{number}", get(flow_run)) |
|
| 98 | 98 | .route("/repos/{owner}/{name}/flow/runs/{number}/jobs/{job}/log", get(flow_job_log)) |
|
| 99 | 99 | .route("/repos/{owner}/{name}/flow/runs/{number}/rerun", post(rerun_flow_run)) |
|
| 100 | + | .route("/repos/{owner}/{name}/flow/runs/{number}/cancel", post(cancel_flow_run)) |
|
| 100 | 101 | .route("/repos/{owner}/{name}/checks/required", get(required_checks).put(set_required_checks)) |
|
| 101 | 102 | .route("/repos/{owner}/{name}/checks/{state}", get(checks_for_state)) |
|
| 102 | 103 | .route("/repos/{owner}/{name}/releases", get(releases).post(create_release)) |
|
| … | 1736 unchanged lines |
||
| 1839 | 1840 | Ok(started(&app, &row, n)) |
|
| 1840 | 1841 | } |
|
| 1841 | 1842 | ||
| 1843 | + | /// `POST /repos/{o}/{n}/flow/runs/{number}/cancel` (repo:write): answers |
|
| 1844 | + | /// with the run as it now stands. |
|
| 1845 | + | async fn cancel_flow_run( |
|
| 1846 | + | State(app): State<Shared>, |
|
| 1847 | + | headers: HeaderMap, |
|
| 1848 | + | Path((owner, name, number)): Path<(String, String, i64)>, |
|
| 1849 | + | ) -> Result<Json<serde_json::Value>, ApiError> { |
|
| 1850 | + | let c = caller(&app, &headers).await?; |
|
| 1851 | + | let me = c.require("repo:write")?.clone(); |
|
| 1852 | + | let (_, row, _) = reach(&app, &c, &owner, &name, Access::Write).await?; |
|
| 1853 | + | let run = db::flow::run(&app.db, row.id, number).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?; |
|
| 1854 | + | crate::flow::cancel(&app, &row, &run, &me.username).await.map_err(|m| ApiError(StatusCode::CONFLICT, m))?; |
|
| 1855 | + | db::accounts::audit(&app.db, Some(&me), "flow.cancel", &format!("{owner}/{name}"), &format!("#{number}")).await.ok(); |
|
| 1856 | + | Ok(Json(serde_json::json!({ "number": number, "status": "cancelled" }))) |
|
| 1857 | + | } |
|
| 1858 | + | ||
| 1842 | 1859 | /// `POST /repos/{o}/{n}/checks`: any `checks:write` (or write) token may |
|
| 1843 | 1860 | /// report; the state hash names what was checked. |
|
| 1844 | 1861 | async fn post_check( |
|
| … | 161 unchanged lines |
modified crates/hub/src/db/flow.rs +59 −6
| … | 94 unchanged lines |
||
| 95 | 95 | } |
|
| 96 | 96 | ||
| 97 | 97 | pub fn done(&self) -> bool { |
|
| 98 | - | matches!(self.status.as_str(), "passed" | "failed" | "skipped" | "error") |
|
| 98 | + | matches!(self.status.as_str(), "passed" | "failed" | "skipped" | "error" | "cancelled") |
|
| 99 | 99 | } |
|
| 100 | 100 | } |
|
| 101 | 101 | ||
| … | 138 unchanged lines |
||
| 240 | 240 | ||
| 241 | 241 | /// The latest run on this bookmark of this repository, for what a push |
|
| 242 | 242 | /// changed since (a job's scope). |
|
| 243 | + | /// The run a push to `bookmark` is scoped against: the newest that was not |
|
| 244 | + | /// cancelled, nor a push run still going (a new push supersedes it), so |
|
| 245 | + | /// that what a cancelled run would have checked is checked by the next. |
|
| 243 | 246 | pub async fn latest_on(pool: &Pool, repo_id: i64, bookmark: &str) -> sqlx::Result<Option<Run>> { |
|
| 244 | - | sqlx::query_as("SELECT * FROM flow_runs WHERE repo_id = ? AND bookmark = ? AND heads != '' ORDER BY id DESC LIMIT 1") |
|
| 245 | - | .bind(repo_id) |
|
| 246 | - | .bind(bookmark) |
|
| 247 | - | .fetch_optional(pool) |
|
| 248 | - | .await |
|
| 247 | + | sqlx::query_as( |
|
| 248 | + | "SELECT * FROM flow_runs WHERE repo_id = ? AND bookmark = ? AND heads != '' AND status != 'cancelled' |
|
| 249 | + | AND NOT (trigger = 'push' AND status IN ('queued', 'running')) ORDER BY id DESC LIMIT 1", |
|
| 250 | + | ) |
|
| 251 | + | .bind(repo_id) |
|
| 252 | + | .bind(bookmark) |
|
| 253 | + | .fetch_optional(pool) |
|
| 254 | + | .await |
|
| 249 | 255 | } |
|
| 250 | 256 | ||
| 251 | 257 | pub async fn runs(pool: &Pool, repo_id: i64, limit: i64, before: Option<i64>) -> sqlx::Result<Vec<Run>> { |
|
| … | 276 unchanged lines |
||
| 528 | 534 | tx.commit().await?; |
|
| 529 | 535 | let mut out = Vec::new(); |
|
| 530 | 536 | for id in changed { |
|
| 537 | + | if let Some(x) = job(pool, id).await? { |
|
| 538 | + | out.push(x); |
|
| 539 | + | } |
|
| 540 | + | } |
|
| 541 | + | Ok(out) |
|
| 542 | + | } |
|
| 543 | + | ||
| 544 | + | /// Cancel a run (oxygenflow#32): its jobs not yet done end as cancelled, |
|
| 545 | + | /// and so does the run. A runner learns of it when the hub next refuses |
|
| 546 | + | /// its log lines, and stops the job. Returns the jobs whose status changed. |
|
| 547 | + | pub async fn cancel_run(pool: &Pool, run_id: i64, why: &str) -> sqlx::Result<Vec<Job>> { |
|
| 548 | + | let now = now_ms(); |
|
| 549 | + | let mut tx = pool.begin().await?; |
|
| 550 | + | let ids: Vec<(i64,)> = sqlx::query_as("SELECT id FROM flow_jobs WHERE run_id = ? AND status IN ('queued', 'running')") |
|
| 551 | + | .bind(run_id) |
|
| 552 | + | .fetch_all(&mut *tx) |
|
| 553 | + | .await?; |
|
| 554 | + | sqlx::query( |
|
| 555 | + | "UPDATE flow_jobs SET status = 'cancelled', error = ?, finished_ms = ?, token_hash = '' |
|
| 556 | + | WHERE run_id = ? AND status IN ('queued', 'running')", |
|
| 557 | + | ) |
|
| 558 | + | .bind(why) |
|
| 559 | + | .bind(now) |
|
| 560 | + | .bind(run_id) |
|
| 561 | + | .execute(&mut *tx) |
|
| 562 | + | .await?; |
|
| 563 | + | sqlx::query("UPDATE flow_runs SET status = 'cancelled', finished_ms = ? WHERE id = ? AND status IN ('queued', 'running')") |
|
| 564 | + | .bind(now) |
|
| 565 | + | .bind(run_id) |
|
| 566 | + | .execute(&mut *tx) |
|
| 567 | + | .await?; |
|
| 568 | + | tx.commit().await?; |
|
| 569 | + | let mut out = Vec::new(); |
|
| 570 | + | for (id,) in ids { |
|
| 531 | 571 | if let Some(x) = job(pool, id).await? { |
|
| 532 | 572 | out.push(x); |
|
| 533 | 573 | } |
|
| 534 | 574 | } |
|
| 535 | 575 | Ok(out) |
|
| 576 | + | } |
|
| 577 | + | ||
| 578 | + | /// The push runs of `bookmark` still going, older than run `number`. |
|
| 579 | + | pub async fn live_push_runs_before(pool: &Pool, repo_id: i64, bookmark: &str, number: i64) -> sqlx::Result<Vec<Run>> { |
|
| 580 | + | sqlx::query_as( |
|
| 581 | + | "SELECT * FROM flow_runs WHERE repo_id = ? AND bookmark = ? AND number < ? AND trigger = 'push' |
|
| 582 | + | AND status IN ('queued', 'running') ORDER BY id", |
|
| 583 | + | ) |
|
| 584 | + | .bind(repo_id) |
|
| 585 | + | .bind(bookmark) |
|
| 586 | + | .bind(number) |
|
| 587 | + | .fetch_all(pool) |
|
| 588 | + | .await |
|
| 536 | 589 | } |
|
| 537 | 590 | ||
| 538 | 591 | /// The newest run of exactly this state (heads joined with `+`, sorted). |
|
| … | 78 unchanged lines |
modified crates/hub/src/db/mod.rs +17 −1
| … | 33 unchanged lines |
||
| 34 | 34 | .synchronous(SqliteSynchronous::Normal) |
|
| 35 | 35 | .foreign_keys(true) |
|
| 36 | 36 | .busy_timeout(BUSY_TIMEOUT); |
|
| 37 | + | migrate(options.clone()).await?; |
|
| 37 | 38 | let pool = SqlitePoolOptions::new().max_connections(8).connect_with(options).await?; |
|
| 38 | - | sqlx::migrate!("./migrations").run(&pool).await?; |
|
| 39 | 39 | Ok(pool) |
|
| 40 | 40 | } |
|
| 41 | 41 | ||
| 42 | + | /// Apply the migrations on a connection of their own with foreign keys |
|
| 43 | + | /// off, as SQLite's way to change a table (rebuild it, drop the old one, |
|
| 44 | + | /// rename) needs: with them on, dropping a table its children reference |
|
| 45 | + | /// deletes the children too. Every reference must hold afterwards. |
|
| 46 | + | async fn migrate(options: SqliteConnectOptions) -> anyhow::Result<()> { |
|
| 47 | + | use sqlx::Connection; |
|
| 48 | + | let mut conn = sqlx::SqliteConnection::connect_with(&options.foreign_keys(false)).await?; |
|
| 49 | + | sqlx::migrate!("./migrations").run(&mut conn).await?; |
|
| 50 | + | let broken: Vec<(String,)> = sqlx::query_as("SELECT \"table\" FROM pragma_foreign_key_check").fetch_all(&mut conn).await?; |
|
| 51 | + | if let Some((table,)) = broken.first() { |
|
| 52 | + | anyhow::bail!("after the migrations, {} rows break a foreign key (the first in {table})", broken.len()); |
|
| 53 | + | } |
|
| 54 | + | conn.close().await?; |
|
| 55 | + | Ok(()) |
|
| 56 | + | } |
|
| 57 | + | ||
| 42 | 58 | /// Milliseconds since the epoch, the hub's timestamp unit (as `oxy`'s). |
|
| 43 | 59 | pub fn now_ms() -> i64 { |
|
| 44 | 60 | std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0) |
|
| … | 9 unchanged lines |
modified crates/hub/src/flow.rs +39 −1
| … | 39 unchanged lines |
||
| 40 | 40 | Some(tag) => start_run_with(app, row, bookmark, state, heads, actor, &format!("tag:{tag}"), None).await, |
|
| 41 | 41 | None => { |
|
| 42 | 42 | let touched = touched_since_last_run(app, row, bookmark, heads).await; |
|
| 43 | - | start_run_scoped(app, row, bookmark, state, heads, actor, "push", touched.as_deref()).await |
|
| 43 | + | let started = start_run_scoped(app, row, bookmark, state, heads, actor, "push", touched.as_deref()).await; |
|
| 44 | + | if let Ok(Some(n)) = started { |
|
| 45 | + | supersede(app, row, bookmark, n).await; |
|
| 46 | + | } |
|
| 47 | + | started |
|
| 44 | 48 | } |
|
| 45 | 49 | }; |
|
| 46 | 50 | if let Err(e) = started { |
|
| … | 62 unchanged lines |
||
| 109 | 113 | start_run(app, row, &run.bookmark, &run.state, &heads, actor, &trigger) |
|
| 110 | 114 | .await? |
|
| 111 | 115 | .ok_or_else(|| format!("that state has no {PIPELINE_PATH}")) |
|
| 116 | + | } |
|
| 117 | + | ||
| 118 | + | /// Cancel a run still going (oxygenflow#32), its checks with it. |
|
| 119 | + | pub async fn cancel(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run, actor: &str) -> Result<(), String> { |
|
| 120 | + | if !run.live() { |
|
| 121 | + | return Err(format!("run #{} is already {}", run.number, run.status)); |
|
| 122 | + | } |
|
| 123 | + | cancel_with(app, run, &format!("cancelled by {actor}")).await.map_err(|e| { |
|
| 124 | + | tracing::warn!("flow: cancelling run #{} of {}/{}: {e}", run.number, row.owner, row.name); |
|
| 125 | + | "the run could not be cancelled".to_string() |
|
| 126 | + | }) |
|
| 127 | + | } |
|
| 128 | + | ||
| 129 | + | async fn cancel_with(app: &Shared, run: &fdb::Run, why: &str) -> sqlx::Result<()> { |
|
| 130 | + | let changed = fdb::cancel_run(&app.db, run.id, why).await?; |
|
| 131 | + | report_changed(app, run.id, changed).await |
|
| 112 | 132 | } |
|
| 113 | 133 | ||
| 134 | + | /// A push makes the push runs still going on its bookmark moot: they are |
|
| 135 | + | /// cancelled, and the new run's scope reaches back past them. |
|
| 136 | + | async fn supersede(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, number: i64) { |
|
| 137 | + | let Ok(older) = fdb::live_push_runs_before(&app.db, row.id, bookmark, number).await else { return }; |
|
| 138 | + | for run in older { |
|
| 139 | + | if let Err(e) = cancel_with(app, &run, &format!("superseded by run #{number}")).await { |
|
| 140 | + | tracing::warn!("flow: superseding run #{} of {}/{}: {e}", run.number, row.owner, row.name); |
|
| 141 | + | } |
|
| 142 | + | } |
|
| 143 | + | } |
|
| 144 | + | ||
| 114 | 145 | /// The event a run's trigger stands for: `push`, `tag:NAME`, `manual`, |
|
| 115 | 146 | /// `schedule`, and `rerun:N:EVENT` repeating EVENT. |
|
| 116 | 147 | fn event_of<'a>(trigger: &'a str, bookmark: &'a str) -> flow_core::pipeline::Event<'a> { |
|
| … | 229 unchanged lines |
||
| 346 | 377 | "running" => "running", |
|
| 347 | 378 | "passed" => "passed", |
|
| 348 | 379 | "skipped" => "skipped", |
|
| 380 | + | "cancelled" => "cancelled", |
|
| 349 | 381 | _ => "failed", |
|
| 350 | 382 | } |
|
| 351 | 383 | } |
|
| … | 209 unchanged lines |
||
| 561 | 593 | ) -> Result<StatusCode, ApiError> { |
|
| 562 | 594 | runner_auth(&app, &headers).await?; |
|
| 563 | 595 | let job = running_job(&app, id).await?; |
|
| 596 | + | // An empty batch asks only whether the job still runs: a cancelled |
|
| 597 | + | // one answers 409 and its runner stops it (oxygenflow#32). |
|
| 598 | + | if batch.records.is_empty() { |
|
| 599 | + | return Ok(StatusCode::NO_CONTENT); |
|
| 600 | + | } |
|
| 564 | 601 | for r in &mut batch.records { |
|
| 565 | 602 | if r.text.len() > MAX_RECORD_TEXT { |
|
| 566 | 603 | let mut cut = MAX_RECORD_TEXT; |
|
| … | 112 unchanged lines |
||
| 679 | 716 | "passed" => format!("passed in {}", duration(&j)), |
|
| 680 | 717 | "failed" => format!("exit {} after {}", j.exit_code.unwrap_or(-1), duration(&j)), |
|
| 681 | 718 | "skipped" => "skipped: a dependency did not pass".into(), |
|
| 719 | + | "cancelled" => j.error.clone(), |
|
| 682 | 720 | _ => format!("could not run: {}", j.error), |
|
| 683 | 721 | }; |
|
| 684 | 722 | report_check(app, &row, &run.state, run.number, &j.name, check_status(&j.status), &summary).await; |
|
| … | 251 unchanged lines |
modified crates/hub/src/web/flow.rs +31 −1
| … | 173 unchanged lines |
||
| 174 | 174 | }) |
|
| 175 | 175 | } |
|
| 176 | 176 | ||
| 177 | + | /// `POST /{owner}/{name}/flow/{number}/cancel` (oxygenflow#32). |
|
| 178 | + | pub async fn cancel( |
|
| 179 | + | State(app): State<Shared>, |
|
| 180 | + | viewer: Viewer, |
|
| 181 | + | Path((owner, name, number)): Path<(String, String, i64)>, |
|
| 182 | + | Form(f): Form<RerunForm>, |
|
| 183 | + | ) -> Result<Response, Error> { |
|
| 184 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/flow/{number}"), &viewer); |
|
| 185 | + | let me = require(&viewer, &shell)?; |
|
| 186 | + | let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?; |
|
| 187 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 188 | + | let run = fdb::run(&app.db, row.id, number) |
|
| 189 | + | .await |
|
| 190 | + | .map_err(|e| Error::from_db(shell.clone(), e))? |
|
| 191 | + | .ok_or_else(|| Error::not_found(shell.clone()))?; |
|
| 192 | + | Ok(match crate::flow::cancel(&app, &row, &run, &me.account.username).await { |
|
| 193 | + | Ok(()) => { |
|
| 194 | + | db::accounts::audit(&app.db, Some(&me.account), "flow.cancel", &format!("{owner}/{name}"), &format!("#{number}")).await.ok(); |
|
| 195 | + | Redirect::to(&format!("/{owner}/{name}/flow/{number}")).into_response() |
|
| 196 | + | } |
|
| 197 | + | Err(msg) => Redirect::to(&format!("/{owner}/{name}/flow?error={}", urlencoding(&msg))).into_response(), |
|
| 198 | + | }) |
|
| 199 | + | } |
|
| 200 | + | ||
| 177 | 201 | /// Percent-encode a message for a query string. |
|
| 178 | 202 | fn urlencoding(s: &str) -> String { |
|
| 179 | 203 | s.bytes() |
|
| … | 70 unchanged lines |
||
| 250 | 274 | "failed" => "✕", |
|
| 251 | 275 | "running" => "●", |
|
| 252 | 276 | "skipped" => "⤼", |
|
| 277 | + | "cancelled" => "⊘", |
|
| 253 | 278 | "error" => "!", |
|
| 254 | 279 | _ => "○", |
|
| 255 | 280 | } |
|
| … | 19 unchanged lines |
||
| 275 | 300 | duration: String, |
|
| 276 | 301 | /// Whether the viewer may re-run it. |
|
| 277 | 302 | can_rerun: bool, |
|
| 303 | + | /// Whether the viewer may cancel it. |
|
| 304 | + | can_cancel: bool, |
|
| 278 | 305 | /// Where a live run stands among the runners' work. |
|
| 279 | 306 | queue: Option<crate::flow::QueueInfo>, |
|
| 280 | 307 | /// Releases a writer may attach this run's artifacts to. |
|
| … | 27 unchanged lines |
||
| 308 | 335 | None => crate::flow::human_ms(db::now_ms() - run.created_ms), |
|
| 309 | 336 | }; |
|
| 310 | 337 | let graph = layout(jobs); |
|
| 311 | - | let can_rerun = !run.live() && viewer.account().is_some() && role >= Role::Write && crate::flow::enabled(&app, &row); |
|
| 338 | + | let can_write = viewer.account().is_some() && role >= Role::Write; |
|
| 339 | + | let can_rerun = !run.live() && can_write && crate::flow::enabled(&app, &row); |
|
| 340 | + | let can_cancel = run.live() && can_write; |
|
| 312 | 341 | let queue = if run.live() { |
|
| 313 | 342 | let q = fdb::queue(&app.db).await.unwrap_or_default(); |
|
| 314 | 343 | Some(crate::flow::queue_info(&q, row.id, run.id)).filter(|i| i.position.is_some()) |
|
| … | 13 unchanged lines |
||
| 328 | 357 | logs, |
|
| 329 | 358 | duration, |
|
| 330 | 359 | can_rerun, |
|
| 360 | + | can_cancel, |
|
| 331 | 361 | queue, |
|
| 332 | 362 | releases, |
|
| 333 | 363 | })) |
|
| … | 324 unchanged lines |
modified crates/hub/src/web/mod.rs +1 −0
| … | 173 unchanged lines |
||
| 174 | 174 | .route("/{owner}/{name}/flow/{id}/live", get(flow::live)) |
|
| 175 | 175 | .route("/{owner}/{name}/flow/start", post(flow::start)) |
|
| 176 | 176 | .route("/{owner}/{name}/flow/{id}/rerun", post(flow::rerun)) |
|
| 177 | + | .route("/{owner}/{name}/flow/{id}/cancel", post(flow::cancel)) |
|
| 177 | 178 | .route("/{owner}/{name}/flow/stamp/{r}", get(flow::stamp)) |
|
| 178 | 179 | .route("/{owner}/{name}/flow/at/{r}", get(flow::at)) |
|
| 179 | 180 | .route("/{owner}/{name}/receipts/{attest}", get(flow::receipt_file)) |
|
| … | 443 unchanged lines |
modified crates/hub/tests/flow.rs +87 −0
| … | 1472 unchanged lines |
||
| 1473 | 1473 | assert_eq!(runs.len(), n, "runs"); |
|
| 1474 | 1474 | let mut j: Vec<String> = db::flow::jobs(&app.db, runs[0].id).await.unwrap().into_iter().map(|j| j.name).collect(); |
|
| 1475 | 1475 | j.sort(); |
|
| 1476 | + | // Passed, so the next push is scoped against it, not superseding it. |
|
| 1477 | + | sqlx::query("UPDATE flow_runs SET status = 'passed' WHERE id = ?").bind(runs[0].id).execute(&app.db).await.unwrap(); |
|
| 1476 | 1478 | j |
|
| 1477 | 1479 | }; |
|
| 1478 | 1480 | assert_eq!(names(h.app.clone(), 1).await, ["build", "dist", "lint", "test"], "the first run has every job"); |
|
| … | 7 unchanged lines |
||
| 1486 | 1488 | assert_eq!(names(h.app.clone(), 3).await, ["build", "dist", "lint", "test"], "a version bump runs dist"); |
|
| 1487 | 1489 | } |
|
| 1488 | 1490 | ||
| 1491 | + | /// oxygenflow#32: a run still going is cancelled from the API, the web or |
|
| 1492 | + | /// by a newer push to its bookmark; its runner is refused from then on, |
|
| 1493 | + | /// and the next push is scoped past the cancelled run. |
|
| 1494 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 1495 | + | async fn runs_are_cancelled_by_hand_or_by_a_newer_push() { |
|
| 1496 | + | let h = hub().await; |
|
| 1497 | + | db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", true).await.unwrap(); |
|
| 1498 | + | create(&h.app, "andy", "stop").await; |
|
| 1499 | + | let url = format!("{}/andy/stop", h.base); |
|
| 1500 | + | let work = tempfile::tempdir().unwrap(); |
|
| 1501 | + | let c = client(&url, work.path()); |
|
| 1502 | + | let extra = "job \"dist\" {\n sandbox \"alpine\"\n scope \"Cargo.toml\"\n exec \"true\"\n}\n"; |
|
| 1503 | + | write(work.path(), ".flow/pipeline.kdl", &pipeline(extra)); |
|
| 1504 | + | write(work.path(), "test.sh", "echo ok\n"); |
|
| 1505 | + | write(work.path(), "Cargo.toml", "version = \"0.1.0\"\n"); |
|
| 1506 | + | c.commit("Start", &[]).unwrap(); |
|
| 1507 | + | assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 1508 | + | let wait_runs = |app: Shared, n: usize| async move { |
|
| 1509 | + | for _ in 0..100 { |
|
| 1510 | + | let runs = runs_of(&app, "andy", "stop").await; |
|
| 1511 | + | if runs.len() >= n { |
|
| 1512 | + | return runs; |
|
| 1513 | + | } |
|
| 1514 | + | tokio::time::sleep(std::time::Duration::from_millis(50)).await; |
|
| 1515 | + | } |
|
| 1516 | + | panic!("no run {n}"); |
|
| 1517 | + | }; |
|
| 1518 | + | let first = wait_runs(h.app.clone(), 1).await; |
|
| 1519 | + | sqlx::query("UPDATE flow_runs SET status = 'passed' WHERE id = ?").bind(first[0].id).execute(&h.app.db).await.unwrap(); |
|
| 1520 | + | sqlx::query("UPDATE flow_jobs SET status = 'passed' WHERE run_id = ?").bind(first[0].id).execute(&h.app.db).await.unwrap(); |
|
| 1521 | + | ||
| 1522 | + | // Run 2 (a version bump) has a job running when run 3 is pushed. |
|
| 1523 | + | write(work.path(), "Cargo.toml", "version = \"0.1.1\"\n"); |
|
| 1524 | + | c.commit("Release 0.1.1", &[]).unwrap(); |
|
| 1525 | + | assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 1526 | + | wait_runs(h.app.clone(), 2).await; |
|
| 1527 | + | let job = claim(&h.app).await.expect("a job of run 2"); |
|
| 1528 | + | let id = job["job_id"].as_i64().unwrap(); |
|
| 1529 | + | write(work.path(), "test.sh", "echo still ok\n"); |
|
| 1530 | + | c.commit("Edit the test", &[]).unwrap(); |
|
| 1531 | + | assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 1532 | + | let runs = wait_runs(h.app.clone(), 3).await; |
|
| 1533 | + | let third = &runs[0]; |
|
| 1534 | + | let mut second = runs[1].clone(); |
|
| 1535 | + | for _ in 0..100 { |
|
| 1536 | + | second = db::flow::run_by_id(&h.app.db, runs[1].id).await.unwrap().unwrap(); |
|
| 1537 | + | if !second.live() { |
|
| 1538 | + | break; |
|
| 1539 | + | } |
|
| 1540 | + | tokio::time::sleep(std::time::Duration::from_millis(50)).await; |
|
| 1541 | + | } |
|
| 1542 | + | assert_eq!(second.status, "cancelled", "a newer push supersedes a run still going"); |
|
| 1543 | + | let jobs = db::flow::jobs(&h.app.db, second.id).await.unwrap(); |
|
| 1544 | + | assert!(jobs.iter().all(|j| j.status == "cancelled" && j.error == "superseded by run #3"), "{jobs:?}"); |
|
| 1545 | + | let mut names: Vec<String> = db::flow::jobs(&h.app.db, third.id).await.unwrap().into_iter().map(|j| j.name).collect(); |
|
| 1546 | + | names.sort(); |
|
| 1547 | + | assert_eq!(names, ["build", "dist", "lint", "test"], "run 3 also checks what run 2 would have: the version bump"); |
|
| 1548 | + | let mut checks = vec![]; |
|
| 1549 | + | for _ in 0..100 { |
|
| 1550 | + | checks = db::hooks::checks_for_state(&h.app.db, second.repo_id, &second.state).await.unwrap(); |
|
| 1551 | + | if checks.iter().all(|c| c.status == "cancelled") { |
|
| 1552 | + | break; |
|
| 1553 | + | } |
|
| 1554 | + | tokio::time::sleep(std::time::Duration::from_millis(50)).await; |
|
| 1555 | + | } |
|
| 1556 | + | assert!(checks.iter().all(|c| c.status == "cancelled"), "{checks:?}"); |
|
| 1557 | + | // The runner of the cancelled job is refused, so it stops the job. |
|
| 1558 | + | let (st, _) = |
|
| 1559 | + | call(&h.app, "POST", &format!("/api/v1/flow/jobs/{id}/log"), Some("runner-secret"), Some(r#"{"records":[]}"#.into())).await; |
|
| 1560 | + | assert_eq!(st, StatusCode::CONFLICT); |
|
| 1561 | + | ||
| 1562 | + | // By hand: the API, then a run already ended is refused. |
|
| 1563 | + | let (st, body) = call(&h.app, "POST", "/api/v1/repos/andy/stop/flow/runs/3/cancel", Some("site"), None).await; |
|
| 1564 | + | assert_eq!(st, StatusCode::OK, "{body}"); |
|
| 1565 | + | let third = db::flow::run_by_id(&h.app.db, third.id).await.unwrap().unwrap(); |
|
| 1566 | + | assert_eq!(third.status, "cancelled"); |
|
| 1567 | + | assert!(db::flow::jobs(&h.app.db, third.id).await.unwrap().iter().all(|j| j.error == "cancelled by andy")); |
|
| 1568 | + | let (st, body) = call(&h.app, "POST", "/api/v1/repos/andy/stop/flow/runs/3/cancel", Some("site"), None).await; |
|
| 1569 | + | assert!(st == StatusCode::CONFLICT && body.contains("already cancelled"), "{st} {body}"); |
|
| 1570 | + | let (st, _) = call(&h.app, "POST", "/api/v1/repos/andy/stop/flow/runs/3/cancel", None, None).await; |
|
| 1571 | + | assert_ne!(st, StatusCode::OK, "anonymous callers cannot cancel"); |
|
| 1572 | + | let (_, page) = call(&h.app, "GET", "/andy/stop/flow/3", None, None).await; |
|
| 1573 | + | assert!(page.contains("⊘") && !page.contains("CANCEL</button>"), "a cancelled run shows so, with no button"); |
|
| 1574 | + | } |
|
| 1575 | + | ||
| 1489 | 1576 | /// oxygenflow#23 and oxygenhub#89: a job runs under its pipeline's |
|
| 1490 | 1577 | /// timeout, capped at the site's maximum, else the site's default; and the |
|
| 1491 | 1578 | /// hub gives a job up only past its own limit. |
|
| … | 106 unchanged lines |
modified crates/oh/src/main.rs +10 −0
| … | 285 unchanged lines |
||
| 286 | 286 | Log { number: i64, job: String }, |
|
| 287 | 287 | /// Run a finished run's state again |
|
| 288 | 288 | Rerun { number: i64 }, |
|
| 289 | + | /// Cancel a run still queued or running; its running jobs are stopped |
|
| 290 | + | Cancel { number: i64 }, |
|
| 289 | 291 | } |
|
| 290 | 292 | ||
| 291 | 293 | #[derive(Subcommand)] |
|
| … | 557 unchanged lines |
||
| 849 | 851 | println!("{} started run #{} {}", ui::ok("✔"), started["number"], ui::dim(started["url"].as_str().unwrap_or(""))); |
|
| 850 | 852 | } |
|
| 851 | 853 | } |
|
| 854 | + | RunCmd::Cancel { number } => { |
|
| 855 | + | let done: serde_json::Value = c.send("POST", &format!("{base}/flow/runs/{number}/cancel"), &serde_json::json!({}))?; |
|
| 856 | + | if json { |
|
| 857 | + | println!("{done}"); |
|
| 858 | + | } else { |
|
| 859 | + | println!("{} cancelled run #{number}", ui::ok("✔")); |
|
| 860 | + | } |
|
| 861 | + | } |
|
| 852 | 862 | } |
|
| 853 | 863 | Ok(0) |
|
| 854 | 864 | } |
|
| … | 126 unchanged lines |
modified docs/oh.md +1 −0
| … | 48 unchanged lines |
||
| 49 | 49 | | `oh run watch N` | Follow a run; exit 0 if it passes, 1 if not | |
|
| 50 | 50 | | `oh run log N JOB` | A job's log | |
|
| 51 | 51 | | `oh run rerun N` | Run a finished run's state again | |
|
| 52 | + | | `oh run cancel N` | Cancel a run still queued or running; its running jobs are stopped | |
|
| 52 | 53 | | `oh release list`, `oh release create TAG [-t TITLE] [-n NOTES]` | Releases | |
|
| 53 | 54 | | `oh release upload TAG FILE…` | Attach assets | |
|
| 54 | 55 | | `oh release trust TAG` | The release's trust audit; exit 1 on a failure | |
|
| … | 8 unchanged lines |
modified docs/spec.md +1 −1
| … | 440 unchanged lines |
||
| 441 | 441 | ||
| 442 | 442 | ### 12.1 API |
|
| 443 | 443 | ||
| 444 | - | JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses (an unknown route too), 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` (`?state=open|draft|landed|closed|all`, open ones and drafts by default; a source bookmark may hold `/`, and `owner/name:bookmark` naming the repository itself is its own bookmark), `/proposals/{n}/comments|reviews|checks|files`, `PATCH /proposals/{n}` (the title and description, for its author or a maintainer, and `labels`, the whole set by name, for triage; what is not given stays), `POST /proposals/{n}/land` (repo:write: what the Land button does, `409` with the blockers when it cannot; a landed proposal keeps the changes it landed and reports `landed` with who, how (`web`, or `api (token NAME)`) and when, which its event, the audit log and the push record also say, adding when nobody approved), `/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` (`PATCH /releases/{tag}` changes the title, notes or prerelease flag, repo:write), `/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/{state}` (the checks on a state, named by its hash, by its heads joined with `+`, by the one head of a single-head state, or by a bookmark), `/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` (`GET`: runs newest first, `?before=N&limit=`; `POST {"bookmark"}` or `{"change"}`, repo:write: run a bookmark's current state, the default bookmark when none is named, or one change's state), `/flow/runs/{n}` (a run with its jobs), `/flow/runs/{n}/jobs/{job}/log` (`?after=seq&limit=`) and `/flow/runs/{n}/rerun` (repo:write: a finished run again), both answering `201 {"number", "url"}` or `409` with why nothing started, `/search`. A change's author carries the account its key is linked to, or `null` and a `note` saying the key is not linked and where its owner links it (the change page shows a "key not linked" tag, with a link to Settings → Keys for signed-in viewers). 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. |
|
| 444 | + | JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses (an unknown route too), 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` (`?state=open|draft|landed|closed|all`, open ones and drafts by default; a source bookmark may hold `/`, and `owner/name:bookmark` naming the repository itself is its own bookmark), `/proposals/{n}/comments|reviews|checks|files`, `PATCH /proposals/{n}` (the title and description, for its author or a maintainer, and `labels`, the whole set by name, for triage; what is not given stays), `POST /proposals/{n}/land` (repo:write: what the Land button does, `409` with the blockers when it cannot; a landed proposal keeps the changes it landed and reports `landed` with who, how (`web`, or `api (token NAME)`) and when, which its event, the audit log and the push record also say, adding when nobody approved), `/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` (`PATCH /releases/{tag}` changes the title, notes or prerelease flag, repo:write), `/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/{state}` (the checks on a state, named by its hash, by its heads joined with `+`, by the one head of a single-head state, or by a bookmark), `/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` (`GET`: runs newest first, `?before=N&limit=`; `POST {"bookmark"}` or `{"change"}`, repo:write: run a bookmark's current state, the default bookmark when none is named, or one change's state), `/flow/runs/{n}` (a run with its jobs), `/flow/runs/{n}/jobs/{job}/log` (`?after=seq&limit=`) and `/flow/runs/{n}/rerun` (repo:write: a finished run again), both answering `201 {"number", "url"}` or `409` with why nothing started, `/flow/runs/{n}/cancel` (repo:write: a run still going ends as `cancelled`, its running jobs stopped when their runner next reports; `409` when it had already ended; a push to a bookmark cancels the push runs of it still going), `/search`. A change's author carries the account its key is linked to, or `null` and a `note` saying the key is not linked and where its owner links it (the change page shows a "key not linked" tag, with a link to Settings → Keys for signed-in viewers). 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. |
|
| 445 | 445 | ||
| 446 | 446 | ### 12.2 Webhooks |
|
| 447 | 447 | ||
| … | 215 unchanged lines |
modified templates/flow_run.html +1 −0
| … | 8 unchanged lines |
||
| 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 | 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 | + | {% if can_cancel %}<form class="inline" method="post" action="{{ repo.url }}/flow/{{ run.number }}/cancel" onsubmit="return confirm('Cancel run #{{ run.number }}? Jobs still running are stopped.')"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit" class="small danger-btn">CANCEL</button></form>{% endif %} |
|
| 12 | 13 | </div> |
|
| 13 | 14 | {% if let Some(q) = queue %} |
|
| 14 | 15 | <section class="panel"><div class="strip">Waiting for a runner: {{ q.place().unwrap_or_default() }}.{% if !q.running.is_empty() %} Running now: {{ q.running.join(", ") }}.{% endif %}{% if !q.ahead.is_empty() %} Queued ahead: {{ q.ahead.join(", ") }}.{% endif %}{% if q.hidden > 0 %} Also ahead or running: {{ q.hidden }} job{% if q.hidden != 1 %}s{% endif %} in private repositories.{% endif %}</div></section> |
|
| … | 30 unchanged lines |
modified templates/style.css +2 −2
| … | 436 unchanged lines |
||
| 437 | 437 | .flow-glyph.st-passed, .node.st-passed .g { color: var(--ok); fill: var(--ok); } |
|
| 438 | 438 | .flow-glyph.st-failed, .flow-glyph.st-error, .node.st-failed .g, .node.st-error .g { color: var(--bad); fill: var(--bad); } |
|
| 439 | 439 | .flow-glyph.st-running, .node.st-running .g { color: var(--acc); fill: var(--acc); } |
|
| 440 | - | .flow-glyph.st-queued, .flow-glyph.st-skipped, .node.st-queued .g, .node.st-skipped .g { color: var(--mute); fill: var(--mute); } |
|
| 440 | + | .flow-glyph.st-queued, .flow-glyph.st-skipped, .flow-glyph.st-cancelled, .node.st-queued .g, .node.st-skipped .g, .node.st-cancelled .g { color: var(--mute); fill: var(--mute); } |
|
| 441 | 441 | .flow-glyph.st-running, .node.st-running .g { animation: flow-pulse 1.2s ease-in-out infinite; } |
|
| 442 | 442 | @keyframes flow-pulse { 50% { opacity: .35; } } |
|
| 443 | 443 | @media (prefers-reduced-motion: reduce) { .flow-glyph.st-running, .node.st-running .g { animation: none; } } |
|
| … | 8 unchanged lines |
||
| 452 | 452 | .flow-graph .node.st-passed rect { stroke: var(--ok); } |
|
| 453 | 453 | .flow-graph .node.st-failed rect, .flow-graph .node.st-error rect { stroke: var(--bad); fill: color-mix(in srgb, var(--bad) 8%, var(--panel)); } |
|
| 454 | 454 | .flow-graph .node.st-running rect { stroke: var(--acc); fill: color-mix(in srgb, var(--acc) 8%, var(--panel)); } |
|
| 455 | - | .flow-graph .node.st-skipped rect { stroke-dasharray: 4 3; } |
|
| 455 | + | .flow-graph .node.st-skipped rect, .flow-graph .node.st-cancelled rect { stroke-dasharray: 4 3; } |
|
| 456 | 456 | .flow-graph .node .g { font-size: 18px; } |
|
| 457 | 457 | .flow-graph text { font-family: "Barlow", sans-serif; stroke: none; } |
|
| 458 | 458 | .flow-graph .node .name { fill: var(--ink); font-size: 14px; font-weight: 600; } |
|
| … | 322 unchanged lines |