OXYGENHUB

Rerun one job of a run, with what depends on it e8fa53694f68

oxygenflow#31 (first part). A finished run's job runs again in the same
run, with every job that depends on it; the jobs it depends on keep their
results, so a flaky test reruns without its build. Their logs, artifacts
and reports go, their checks return to queued, and the run is running
again until they finish. POST /flow/runs/{n}/jobs/{job}/rerun
(repo:write), RE-RUN THIS JOB on each finished job of the run page, and
oh run rerun N --job NAME. Refused while the run is live, and for a job
it lacks.

Tested: build and lint pass and test fails; the test reruns alone, its
old log gone and its check queued, and passes, making the run pass; the
build reruns with the test that depends on it while lint keeps its
result. Also: two test RemoteOptions literals take Default for the rest,
ready for oxygen's fallback_tokens.

refs andymccall/oxygenflow#31
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-14 04:14 UTC
Signature
✔ signed by Andy McCall
Hash
e8fa53694f686d9df2fe4805e81ceec44b0b5c90b56ac9a9be16f3ce8fec14ee
Depends on
1edac4b68c6d 40dd3f86379a 42fa95540468 501d8b268dd4 5c6e92fd8d71 745d225de344 8a3334b7a364 93be9ce4653e d23464c0ac60 f4d6ef418a96
Depended on by
fac6853c4e46 0468394734e9 166f6e9bf4f1 a9f1c0bf403a e97500b70f9a 5d90e0e045e2 417184c73f4e bff9106edcba d9b3b84605a4 4a4400c5812d
In bookmarks
api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
In tags
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
17 atoms, 0 tree operations, 9 files

Browse the tree at this change

modified crates/hub/src/api/mod.rs +18 −0
…
138 unchanged lines
139 139
        .route("/repos/{owner}/{name}/flow/runs/{number}", get(flow_run))
140 140
        .route("/repos/{owner}/{name}/flow/runs/{number}/jobs/{job}/log", get(flow_job_log))
141 141
        .route("/repos/{owner}/{name}/flow/runs/{number}/rerun", post(rerun_flow_run))
142 +
        .route("/repos/{owner}/{name}/flow/runs/{number}/jobs/{job}/rerun", post(rerun_flow_job))
142 143
        .route("/repos/{owner}/{name}/flow/runs/{number}/cancel", post(cancel_flow_run))
143 144
        .route("/repos/{owner}/{name}/checks/required", get(required_checks).put(set_required_checks))
144 145
        .route("/repos/{owner}/{name}/checks/{state}", get(checks_for_state))
…
1974 unchanged lines
2119 2120
    Ok(started(&app, &row, n))
2120 2121
}
2121 2122
2123 +
/// `POST /repos/{o}/{n}/flow/runs/{number}/jobs/{job}/rerun` (repo:write,
2124 +
/// oxygenflow#31): that job again, with the jobs that depend on it, in the
2125 +
/// same run. Answers with how many jobs were queued.
2126 +
async fn rerun_flow_job(
2127 +
    State(app): State<Shared>,
2128 +
    headers: HeaderMap,
2129 +
    Path((owner, name, number, job)): Path<(String, String, i64, String)>,
2130 +
) -> Result<Json<serde_json::Value>, ApiError> {
2131 +
    let c = caller(&app, &headers).await?;
2132 +
    let me = c.require("repo:write")?.clone();
2133 +
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Write).await?;
2134 +
    let run = db::flow::run(&app.db, row.id, number).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
2135 +
    let n = crate::flow::rerun_job(&app, &row, &run, &job, &me.username).await.map_err(|m| ApiError(StatusCode::CONFLICT, m))?;
2136 +
    db::accounts::audit(&app.db, Some(&me), "flow.rerun", &format!("{owner}/{name}"), &format!("{job} of #{number}")).await.ok();
2137 +
    Ok(Json(serde_json::json!({ "run": number, "job": job, "queued": n })))
2138 +
}
2139 +
2122 2140
/// `POST /repos/{o}/{n}/flow/runs/{number}/cancel` (repo:write): answers
2123 2141
/// with the run as it now stands.
2124 2142
async fn cancel_flow_run(
…
397 unchanged lines
modified crates/hub/src/db/flow.rs +49 −0
…
898 unchanged lines
899 899
    Ok(out)
900 900
}
901 901
902 +
/// Run job `name` of finished run `run_id` again, with every job that
903 +
/// depends on it (oxygenflow#31): back to the queue as if new, their logs,
904 +
/// artifacts and reports gone, and the run running again. The jobs they
905 +
/// depend on keep their results. The jobs reset; none when there is no
906 +
/// such job.
907 +
pub async fn rerun_job(pool: &Pool, run_id: i64, name: &str) -> sqlx::Result<Vec<Job>> {
908 +
    let all: Vec<Job> = sqlx::query_as("SELECT * FROM flow_jobs WHERE run_id = ?").bind(run_id).fetch_all(pool).await?;
909 +
    if !all.iter().any(|j| j.name == name) {
910 +
        return Ok(Vec::new());
911 +
    }
912 +
    let mut again = vec![name.to_string()];
913 +
    loop {
914 +
        let more: Vec<String> = all
915 +
            .iter()
916 +
            .filter(|j| !again.contains(&j.name) && j.depends_on().iter().any(|d| again.contains(d)))
917 +
            .map(|j| j.name.clone())
918 +
            .collect();
919 +
        if more.is_empty() {
920 +
            break;
921 +
        }
922 +
        again.extend(more);
923 +
    }
924 +
    let ids: Vec<i64> = all.iter().filter(|j| again.contains(&j.name)).map(|j| j.id).collect();
925 +
    let mut tx = pool.begin().await?;
926 +
    for id in &ids {
927 +
        for table in ["flow_logs", "flow_artifacts", "flow_declines", "test_reports", "coverage_reports", "reports"] {
928 +
            sqlx::query(&format!("DELETE FROM {table} WHERE job_id = ?")).bind(id).execute(&mut *tx).await?;
929 +
        }
930 +
        sqlx::query(
931 +
            "UPDATE flow_jobs SET status = 'queued', runner = '', token_hash = '', started_ms = NULL, finished_ms = NULL,
932 +
             exit_code = NULL, error = '', receipt = '', receipt_hash = '', log_count = 0, also_read = '', receipt_attest = '',
933 +
             receipt_signer = '', receipt_note = '', runner_id = NULL, waiting = '', placed = '', peak_disk_mib = NULL,
934 +
             peak_memory_mib = NULL WHERE id = ?",
935 +
        )
936 +
        .bind(id)
937 +
        .execute(&mut *tx)
938 +
        .await?;
939 +
    }
940 +
    sqlx::query("UPDATE flow_runs SET status = 'running', finished_ms = NULL WHERE id = ?").bind(run_id).execute(&mut *tx).await?;
941 +
    tx.commit().await?;
942 +
    let mut out = Vec::new();
943 +
    for id in ids {
944 +
        if let Some(x) = job(pool, id).await? {
945 +
            out.push(x);
946 +
        }
947 +
    }
948 +
    Ok(out)
949 +
}
950 +
902 951
/// The push runs of `bookmark` still going, older than run `number`.
903 952
pub async fn live_push_runs_before(pool: &Pool, repo_id: i64, bookmark: &str, number: i64) -> sqlx::Result<Vec<Run>> {
904 953
    sqlx::query_as(
…
100 unchanged lines
modified crates/hub/src/flow.rs +24 −0
…
116 unchanged lines
117 117
        .ok_or_else(|| format!("that state has no {PIPELINE_PATH}"))
118 118
}
119 119
120 +
/// Run one job of a finished run again (oxygenflow#31), with the jobs
121 +
/// that depend on it; the jobs it depends on keep their results, so a
122 +
/// flaky test reruns without the build before it. Its checks go back to
123 +
/// queued. Returns how many jobs were queued again.
124 +
pub async fn rerun_job(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run, job: &str, actor: &str) -> Result<usize, String> {
125 +
    if !enabled(app, row) {
126 +
        return Err("OxygenFlow is not enabled for this repository".into());
127 +
    }
128 +
    if run.live() {
129 +
        return Err(format!("run #{} is still {}: wait for it, or cancel it, first", run.number, run.status));
130 +
    }
131 +
    let changed = fdb::rerun_job(&app.db, run.id, job).await.map_err(|e| {
132 +
        tracing::warn!("flow: rerunning {job} of run #{} of {}/{}: {e}", run.number, row.owner, row.name);
133 +
        "the job could not be queued again".to_string()
134 +
    })?;
135 +
    if changed.is_empty() {
136 +
        return Err(format!("run #{} has no job {job:?}", run.number));
137 +
    }
138 +
    for j in &changed {
139 +
        report_check(app, row, &run.state, run.number, &j.name, "queued", &format!("queued again by {actor}")).await;
140 +
    }
141 +
    Ok(changed.len())
142 +
}
143 +
120 144
/// Cancel a run still going (oxygenflow#32), its checks with it.
121 145
pub async fn cancel(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run, actor: &str) -> Result<(), String> {
122 146
    if !run.live() {
…
1855 unchanged lines
modified crates/hub/src/web/flow.rs +26 −0
…
173 unchanged lines
174 174
    })
175 175
}
176 176
177 +
/// `POST /{owner}/{name}/flow/{number}/jobs/{job}/rerun` (oxygenflow#31).
178 +
pub async fn rerun_job(
179 +
    State(app): State<Shared>,
180 +
    viewer: Viewer,
181 +
    Path((owner, name, number, job)): Path<(String, String, i64, String)>,
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::rerun_job(&app, &row, &run, &job, &me.account.username).await {
193 +
        Ok(_) => {
194 +
            db::accounts::audit(&app.db, Some(&me.account), "flow.rerun", &format!("{owner}/{name}"), &format!("{job} of #{number}"))
195 +
                .await
196 +
                .ok();
197 +
            Redirect::to(&format!("/{owner}/{name}/flow/{number}")).into_response()
198 +
        }
199 +
        Err(msg) => Redirect::to(&format!("/{owner}/{name}/flow?error={}", urlencoding(&msg))).into_response(),
200 +
    })
201 +
}
202 +
177 203
/// `POST /{owner}/{name}/flow/{number}/cancel` (oxygenflow#32).
178 204
pub async fn cancel(
179 205
    State(app): State<Shared>,
…
568 unchanged lines
modified crates/hub/src/web/mod.rs +1 −0
…
250 unchanged lines
251 251
        .route("/{owner}/{name}/flow/{id}/live", get(flow::live))
252 252
        .route("/{owner}/{name}/flow/start", post(flow::start))
253 253
        .route("/{owner}/{name}/flow/{id}/rerun", post(flow::rerun))
254 +
        .route("/{owner}/{name}/flow/{id}/jobs/{job}/rerun", post(flow::rerun_job))
254 255
        .route("/{owner}/{name}/flow/{id}/cancel", post(flow::cancel))
255 256
        .route("/{owner}/{name}/flow/stamp/{r}", get(flow::stamp))
256 257
        .route("/{owner}/{name}/flow/at/{r}", get(flow::at))
…
506 unchanged lines
modified crates/hub/tests/flow.rs +77 −2
…
209 unchanged lines
210 210
    // The job token reads this repository over the wire, and nothing else.
211 211
    let token = a["token"].as_str().unwrap().to_string();
212 212
    assert!(token.starts_with("flowjob_"));
213 -
    let opts = RemoteOptions { token: Some(token.clone()), ca: None, repo_id: None, cache_dir: None };
213 +
    let opts = RemoteOptions { token: Some(token.clone()), ..Default::default() };
214 214
    open_remote(&url, &opts).unwrap().refs().unwrap();
215 215
    assert!(open_remote(&format!("{}/andy/secret", h.base), &opts).unwrap().refs().is_err(), "another repository");
216 -
    let bad = RemoteOptions { token: Some("flowjob_00".into()), ca: None, repo_id: None, cache_dir: None };
216 +
    let bad = RemoteOptions { token: Some("flowjob_00".into()), ..Default::default() };
217 217
    assert!(open_remote(&url, &bad).unwrap().refs().is_err(), "an unknown job token");
218 218
    // … and cannot push.
219 219
    c.config.set("remote.origin.token", &token).unwrap();
…
3698 unchanged lines
3918 3918
    let open = db::proposals::list(&h.app.db, app_row.id, &["open"]).await.unwrap();
3919 3919
    assert!(open.iter().any(|p| p.source_bookmark == "diffusion/deps/lib"), "amber waits for a person");
3920 3920
}
3921 +
3922 +
/// oxygenflow#31: one finished job runs again in the same run, with the
3923 +
/// jobs that depend on it; what it depends on keeps its result, so a flaky
3924 +
/// test reruns without its build. Not while the run is live, and only a
3925 +
/// job the run has.
3926 +
#[tokio::test(flavor = "multi_thread")]
3927 +
async fn one_job_reruns_with_what_depends_on_it() {
3928 +
    let h = hub().await;
3929 +
    db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", true).await.unwrap();
3930 +
    create(&h.app, "andy", "flaky").await;
3931 +
    let work = tempfile::tempdir().unwrap();
3932 +
    let c = client(&format!("{}/andy/flaky", h.base), work.path());
3933 +
    write(work.path(), ".flow/pipeline.kdl", &pipeline(""));
3934 +
    write(work.path(), "test.sh", "echo ok\n");
3935 +
    c.commit("Add a pipeline", &[]).unwrap();
3936 +
    assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok));
3937 +
    let run = runs_of(&h.app, "andy", "flaky").await[0].clone();
3938 +
    // build and lint pass; test fails, as a flaky one does.
3939 +
    let (st, _) =
3940 +
        call(&h.app, "POST", &format!("/api/v1/repos/andy/flaky/flow/runs/{}/jobs/test/rerun", run.number), Some("site"), None).await;
3941 +
    assert_eq!(st, StatusCode::CONFLICT, "not while the run is live");
3942 +
    while let Some(j) = claim(&h.app).await {
3943 +
        let id = j["job_id"].as_i64().unwrap();
3944 +
        let (name, ph) = (j["job"]["name"].as_str().unwrap().to_string(), j["pipeline_hash"].as_str().unwrap().to_string());
3945 +
        let exit = if name == "test" { 1 } else { 0 };
3946 +
        let log = serde_json::json!({ "records": [{ "t": 1, "stream": "out", "text": format!("{name} ran") }] });
3947 +
        call(&h.app, "POST", &format!("/api/v1/flow/jobs/{id}/log"), Some("runner-secret"), Some(log.to_string())).await;
3948 +
        assert_eq!(
3949 +
            finish(&h.app, id, serde_json::json!({ "exit_code": exit, "duration_ms": 10, "receipt": receipt(&name, &ph, exit as u32) }))
3950 +
                .await,
3951 +
            StatusCode::NO_CONTENT
3952 +
        );
3953 +
    }
3954 +
    let status = |n: &'static str| {
3955 +
        let (app, run_id) = (h.app.clone(), run.id);
3956 +
        async move { db::flow::jobs(&app.db, run_id).await.unwrap().into_iter().find(|j| j.name == n).unwrap() }
3957 +
    };
3958 +
    assert_eq!(db::flow::run_by_id(&h.app.db, run.id).await.unwrap().unwrap().status, "failed");
3959 +
    let failed_test = status("test").await;
3960 +
3961 +
    // The test again: build and lint keep their results.
3962 +
    let (st, body) =
3963 +
        call(&h.app, "POST", &format!("/api/v1/repos/andy/flaky/flow/runs/{}/jobs/test/rerun", run.number), Some("site"), None).await;
3964 +
    assert_eq!(st, StatusCode::OK, "{body}");
3965 +
    assert!(body.contains(r#""queued":1"#), "{body}");
3966 +
    assert_eq!(
3967 +
        (status("build").await.status, status("lint").await.status, status("test").await.status),
3968 +
        ("passed".into(), "passed".into(), "queued".into())
3969 +
    );
3970 +
    assert!(db::flow::log(&h.app.db, failed_test.id, 0, 100).await.unwrap().is_empty(), "its old log is gone");
3971 +
    assert!(db::flow::run_by_id(&h.app.db, run.id).await.unwrap().unwrap().live());
3972 +
    let checks = checks_when(&h.app, run.repo_id, &run.state, 3).await;
3973 +
    assert_eq!(checks.iter().find(|c| c.name == "flow/test").map(|c| c.status.as_str()), Some("queued"));
3974 +
    let j = claim(&h.app).await.expect("the test, queued again");
3975 +
    assert_eq!(j["job"]["name"], "test");
3976 +
    let ph = j["pipeline_hash"].as_str().unwrap().to_string();
3977 +
    let id = j["job_id"].as_i64().unwrap();
3978 +
    assert_eq!(
3979 +
        finish(&h.app, id, serde_json::json!({ "exit_code": 0, "duration_ms": 10, "receipt": receipt("test", &ph, 0) })).await,
3980 +
        StatusCode::NO_CONTENT
3981 +
    );
3982 +
    assert_eq!(db::flow::run_by_id(&h.app.db, run.id).await.unwrap().unwrap().status, "passed", "the run is whole again");
3983 +
3984 +
    // The build again brings the test that depends on it; lint stays.
3985 +
    let (_, body) =
3986 +
        call(&h.app, "POST", &format!("/api/v1/repos/andy/flaky/flow/runs/{}/jobs/build/rerun", run.number), Some("site"), None).await;
3987 +
    assert!(body.contains(r#""queued":2"#), "{body}");
3988 +
    assert_eq!(
3989 +
        (status("build").await.status, status("lint").await.status, status("test").await.status),
3990 +
        ("queued".into(), "passed".into(), "queued".into())
3991 +
    );
3992 +
    let (st, _) =
3993 +
        call(&h.app, "POST", &format!("/api/v1/repos/andy/flaky/flow/runs/{}/jobs/deploy/rerun", run.number), Some("site"), None).await;
3994 +
    assert_eq!(st, StatusCode::CONFLICT, "live again, and no such job either way");
3995 +
}
modified crates/oh/src/main.rs +19 −3
…
504 unchanged lines
505 505
    Watch { number: i64 },
506 506
    /// A job's log
507 507
    Log { number: i64, job: String },
508 -
    /// Run a finished run's state again
509 -
    Rerun { number: i64 },
508 +
    /// Run a finished run's state again; with --job, only that job (and the
509 +
    /// jobs that depend on it) in the same run
510 +
    Rerun {
511 +
        number: i64,
512 +
        #[arg(long)]
513 +
        job: Option<String>,
514 +
    },
510 515
    /// Cancel a run still queued or running; its running jobs are stopped
511 516
    Cancel { number: i64 },
512 517
}
…
659 unchanged lines
1172 1177
                    }
1173 1178
                }
1174 1179
                after = lines.last().map(|l| l.seq).unwrap_or(after);
1180 +
            }
1181 +
        }
1182 +
        RunCmd::Rerun { number, job: Some(job) } => {
1183 +
            let queued: serde_json::Value =
1184 +
                c.send("POST", &format!("{base}/flow/runs/{number}/jobs/{job}/rerun"), &serde_json::json!({}))?;
1185 +
            if json {
1186 +
                println!("{queued}");
1187 +
            } else {
1188 +
                let n = queued["queued"].as_i64().unwrap_or(1);
1189 +
                let with = if n > 1 { format!(" with the {} that depend on it", n - 1) } else { String::new() };
1190 +
                println!("{} queued {job} of run #{number} again{with}", ui::ok("✔"));
1175 1191
            }
1176 1192
        }
1177 -
        RunCmd::Rerun { number } => {
1193 +
        RunCmd::Rerun { number, job: None } => {
1178 1194
            let started: serde_json::Value = c.send("POST", &format!("{base}/flow/runs/{number}/rerun"), &serde_json::json!({}))?;
1179 1195
            if json {
1180 1196
                println!("{started}");
…
393 unchanged lines
modified docs/spec.md +1 −1
…
459 unchanged lines
460 460
461 461
### 12.1 API
462 462
463 -
JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses (an unknown route, or a known one with the wrong method, 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/{ref}` (`?path=`, `?offset=&limit=`, or `?r=REVSET` for the changes a revset selects, evaluated under a two-second budget and answering 400 when it does not parse or goes past it; Oxygen's docs/revset_query_language.md), `/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` (`POST` creates one or changes the description and due date of the one with that title, `PATCH /milestones/{title}` changes title, description, due date or state; triage), `/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, and `owner` to transfer it to an owner the caller may make boards under (#125: it takes the next number there, and its old address redirects), `/cards` to list them by column or 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), `/coverage` and `/tests` (a bookmark's coverage and JUnit test reports from Flow jobs, newest first, `?bookmark=&limit=`), `POST /coverage` (repo:write, #136: `{"bookmark"}` or `{"change"}` and `content`, an LCOV or Cobertura report measured off the hub, kept for that state as uploaded by the caller, shown "uploaded by NAME" wherever a run number would be, and setting no check; `oh coverage upload FILE`), `/coverage/{id}` (a report with its files) and `/tests/{id}` (a report with its cases, `?status=failed` for the failed and errored ones), `/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; with `"jobs": [NAME..]` only those jobs and what they depend on, each one a manual run starts, else `409` naming the ones it does (#130); `oh run start --job NAME`), `/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; `via` names the delegate (an agent) that signed for the author when one did, with the delegation's scope and expiry and whether it has since been revoked, and the pages show the same as an `agent` badge, a `via` row under the author on the change page, and a count under the author in Contributors (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.
463 +
JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses (an unknown route, or a known one with the wrong method, 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/{ref}` (`?path=`, `?offset=&limit=`, or `?r=REVSET` for the changes a revset selects, evaluated under a two-second budget and answering 400 when it does not parse or goes past it; Oxygen's docs/revset_query_language.md), `/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` (`POST` creates one or changes the description and due date of the one with that title, `PATCH /milestones/{title}` changes title, description, due date or state; triage), `/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, and `owner` to transfer it to an owner the caller may make boards under (#125: it takes the next number there, and its old address redirects), `/cards` to list them by column or 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), `/coverage` and `/tests` (a bookmark's coverage and JUnit test reports from Flow jobs, newest first, `?bookmark=&limit=`), `POST /coverage` (repo:write, #136: `{"bookmark"}` or `{"change"}` and `content`, an LCOV or Cobertura report measured off the hub, kept for that state as uploaded by the caller, shown "uploaded by NAME" wherever a run number would be, and setting no check; `oh coverage upload FILE`), `/coverage/{id}` (a report with its files) and `/tests/{id}` (a report with its cases, `?status=failed` for the failed and errored ones), `/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; with `"jobs": [NAME..]` only those jobs and what they depend on, each one a manual run starts, else `409` naming the ones it does (#130); `oh run start --job NAME`), `/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}/jobs/{job}/rerun` (repo:write, oxygenflow#31: that job of a finished run again, in the same run, with the jobs that depend on it; what it depends on keeps its result, so a flaky test reruns without its build; their logs, artifacts and reports go, their checks return to queued and the run is running again; `{"queued": N}`, or `409` while the run is live or for a job it lacks; RE-RUN THIS JOB on the run page, `oh run rerun N --job NAME`), `/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; `via` names the delegate (an agent) that signed for the author when one did, with the delegation's scope and expiry and whether it has since been revoked, and the pages show the same as an `agent` badge, a `via` row under the author on the change page, and a count under the author in Contributors (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.
464 464
465 465
### 12.2 Webhooks
466 466
…
290 unchanged lines
modified templates/flow_run.html +1 −0
…
32 unchanged lines
33 33
{% for l in logs %}
34 34
<details class="panel flow-job st-{{ l.j.status }}" id="job-{{ l.j.id }}" data-job="{{ l.j.id }}"{% if l.j.status == "failed" || l.j.status == "error" || l.j.status == "running" || (l.j.status == "queued" && !l.j.waiting.is_empty()) %} open{% endif %}>
35 35
  <summary><span class="flow-glyph st-{{ l.j.status }}" data-glyph>{{ crate::web::flow::glyph(l.j.status) }}</span> <strong>{{ l.j.name }}</strong> <span class="small mute" data-meta>{{ l.j.status }}{% if let Some(c) = l.j.exit_code %} · exit {{ c }}{% endif %} · {{ l.duration }}{% if !l.j.runner.is_empty() %} · on <span{% if !l.j.placed.is_empty() %} title="Placed there: {{ l.j.placed }}"{% endif %}>{{ l.j.runner }}</span>{% endif %}</span>{% if !l.j.receipt_attest.is_empty() %} <span class="small mute">· <a href="{{ repo.url }}/receipts/{{ l.j.receipt_attest }}" title="Receipt {{ l.j.receipt_hash }}, kept in the repository; check it with oxy receipt verify">signed receipt</a> by {{ l.j.receipt_signer }}</span>{% else if !l.j.receipt_note.is_empty() %} <span class="small warn" title="{{ l.j.receipt_note }}">· receipt not kept: {{ l.j.receipt_note }}</span>{% else if !l.j.receipt_hash.is_empty() %} <span class="small mute">· unsigned receipt <code title="{{ l.j.receipt_hash }}">{{ l.j.receipt_hash|short }}</code></span>{% endif %}{% if !l.artifacts.is_empty() %} <span class="small mute">· {{ l.artifacts.len()|plural("artifact", "artifacts") }}</span>{% endif %}{% if let Some(t) = l.tests %} <span class="small{% if t.broken() > 0 %} warn{% endif %}">· <a href="#tests-{{ l.j.id }}">{{ t.summary() }}</a></span>{% endif %}{% if let Some((cid, pct)) = l.coverage %} <span class="small">· <a href="{{ repo.url }}/coverage?report={{ cid }}">coverage {{ pct }}</a></span>{% endif %}</summary>
36 +
  {% if can_rerun && l.j.done() %}<form class="tiny inline job-rerun" method="post" action="{{ repo.url }}/flow/{{ run.number }}/jobs/{{ l.j.name }}/rerun"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit" class="small" title="Run {{ l.j.name }} again in this run, with the jobs that depend on it; what it depends on keeps its results">RE-RUN THIS JOB</button></form>{% endif %}
36 37
  {% if !l.j.error.is_empty() %}<div class="strip bad">{{ l.j.error }}</div>{% endif %}
37 38
  {% if !l.artifacts.is_empty() %}<ul class="flow-artifacts small">{% for a in l.artifacts %}<li><a href="{{ repo.url }}/flow/{{ run.number }}/artifacts/{{ l.j.name }}/{{ a.name }}">{{ a.name }}</a> <span class="mute">{{ a.path }} · {{ a.size|bytes }} · blob <code title="{{ a.blob }}">{{ a.blob|short }}</code></span>{% if l.j.status != "passed" %} <span class="warn" title="Kept with when &quot;always&quot;: the job did not pass, so its receipt does not name this file">· from a job that did not pass, not in its receipt</span>{% endif %}{% if !releases.is_empty() && l.j.status == "passed" %} <form class="tiny inline" method="post" action="{{ repo.url }}/flow/{{ run.number }}/artifacts/{{ l.j.name }}/{{ a.name }}/attach"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><select name="release" aria-label="Release to attach {{ a.name }} to">{% for r in releases %}<option value="{{ r.id }}">{{ r.tag }}</option>{% endfor %}</select> <button>Attach to release</button></form>{% endif %}</li>{% endfor %}</ul>{% endif %}
38 39
  {% if let Some(t) = l.tests %}<div class="flow-tests" id="tests-{{ l.j.id }}">
…
16 unchanged lines