OXYGENHUB

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