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 "always": 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 |