OXYGENHUB

Run pipeline and Re-run: runs that move no bookmark 1edac4b68c6d

Writers start a run of a bookmark's current state from the Flow tab (RUN
PIPELINE) and run a finished run's state again from its page (RE-RUN),
also as POST /api/v1/repos/{o}/{n}/flow/runs and .../runs/{n}/rerun
(repo:write; 201 with the run's number and url, 409 with why nothing
started). The new run's checks replace the old ones for that state. Runs
record what started them (migration 0020: push, manual, rerun:N) and the
run page says so. Pushes, Run pipeline and Re-run share one start_run.

closes #25
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-28 23:27 UTC
Signature
✔ signed by Andy McCall
Hash
1edac4b68c6d1707648e8ff567dba43dd78ef0d7c7aba710ac415cf29d5e1a72
Depends on
42fa95540468 736f6813543d 9008bf6c2b8c 93be9ce4653e 961c10bee174 a0d4bbfbedd1 af9c6ca11c49 b64e2537d702 d23464c0ac60
Depended on by
c26267b3aba6 fac6853c4e46 3d086bc3d69b 5f3fa151688e 5588bb3631d2 367f34b8e590 a9f1c0bf403a 107eaba52c9d 3b1bc27fe805 5d90e0e045e2 b644e1757711 e8fa53694f68 0650d94ede82 745d225de344 348f8c4baccf c889911bec12 d7ce41acf22c 4f6925337848 cc19697f8871 bf3d07541368 90326c9601f9
In bookmarks
api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
In tags
v0.1.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
33 atoms, 1 tree operation, 10 files

Browse the tree at this change

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