A push reuses a job's pass on the same state (closes #265) 5588bb3631d2
When main reaches a state a proposal's run already tested, a job that passed there with the same compiled spec and sandbox is not run again: it is created passed inside the run's transaction, noted reused from run #N, with that pass's receipt, and reports its check and the run as any pass does. Jobs with an approval gate, an environment, secrets or identity tokens, or whose artifacts another job takes, always run; push runs only. Settings > Runs turns it off (repositories.flow_reuse, migration 0090).
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 2f8e2b706da6
- When
- · 2026-10-21 00:43 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
5588bb3631d2016d3b5365c4665ded2f18ab9982fc70daf1599091cc90077ddb- Depends on
- 070a7b64b368 0ee5c3fb32eb 19bb31ff1f5c 1c8276b90314 1edac4b68c6d 2abea30809dd 367f34b8e590 3b1bc27fe805 3b3a51afde59 42fa95540468 44a3d2ebaddd 501d8b268dd4 858eca0d03c4 9041db4e4d92 93be9ce4653e a0d4bbfbedd1 a7e6e8e6094c a9f1c0bf403a b5668108487f c7067c7bda56 d23464c0ac60 d7ce41acf22c de505df5cf8d e97500b70f9a
- Depended on by
- 06b756358f7e 91c306aa4706 42cd46c9b09e 0468394734e9 ec84907043c2
- In bookmarks
- api-unknown-fields diffusion-pin-bookmark draft-ready main proposal-list-fast
- In tags
- 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
- 26 atoms, 2 tree operations, 13 files
Browse the tree at this change
added crates/hub/migrations-pg/0090_flow_reuse.sql +4 −0
| 1 | + | -- A push run reuses a job's pass on the same state, spec and sandbox |
|
| 2 | + | -- instead of running it again (oxygenhub#265); a maintainer can turn |
|
| 3 | + | -- that off for the repository. |
|
| 4 | + | ALTER TABLE repositories ADD COLUMN flow_reuse BOOLEAN NOT NULL DEFAULT TRUE; |
added crates/hub/migrations/0090_flow_reuse.sql +4 −0
| 1 | + | -- A push run reuses a job's pass on the same state, spec and sandbox |
|
| 2 | + | -- instead of running it again (oxygenhub#265); a maintainer can turn |
|
| 3 | + | -- that off for the repository. |
|
| 4 | + | ALTER TABLE repositories ADD COLUMN flow_reuse INTEGER NOT NULL DEFAULT 1; |
modified CHANGELOG.md +1 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | ## Unreleased |
|
| 6 | 6 | ||
| 7 | + | - **A push reuses a job's pass on the same state (#265):** when main reaches a state a proposal's run already tested (landing it), a job that passed there with the same spec and sandbox is not run again: it shows passed, reused from that run, with its receipt, and its check says so. Jobs with an approval gate, an environment, secrets or identity tokens, or whose artifacts another job takes, always run. Maintainers can turn reuse off under Settings › Runs. |
|
| 7 | 8 | - **Drops are backed up (#280):** an owner's backups now include its drops, each copied like a repository and restored as a drop (secret or public, with its title and description); Admin › Backups counts them with the repositories they cover. |
|
| 8 | 9 | - **A skip marker quoted in code does not skip:** a change message that mentions `[skip ci]` between backticks or in a fenced block (documenting the marker, say) now starts its run as usual; only a marker in the text itself skips it. |
|
| 9 | 10 | - **Administration has a sidebar, one page per section (#288):** the admin area was one long page plus separate pages for runners, images, Diffusion, backups and storage. Every page now has the same sidebar, grouped as Overview, People (accounts, registration and invitations, organisations), Sign-in (providers, security contact), Mail, Flow (runners, images, runner settings, insights, variables, secrets), Integrations (AI assistants, Diffusion), Data (storage and maintenance, backups) and Audit, each at its own address (`/admin/mail`, `/admin/audit`, …). `/admin` is an overview of the site's numbers. Forms keep their addresses and answer with their own page, and a kept `/admin#mail` link opens the Mail page. |
|
| … | 273 unchanged lines |
modified crates/hub/src/db/flow.rs +84 −3
| … | 171 unchanged lines |
||
| 172 | 172 | } |
|
| 173 | 173 | ||
| 174 | 174 | impl Job { |
|
| 175 | + | /// The run whose pass this job reused (#265), if it did. |
|
| 176 | + | pub fn reused_from(&self) -> Option<i64> { |
|
| 177 | + | self.error.strip_prefix("reused from run #")?.parse().ok() |
|
| 178 | + | } |
|
| 179 | + | ||
| 175 | 180 | pub fn depends_on(&self) -> Vec<String> { |
|
| 176 | 181 | serde_json::from_str(&self.depends).unwrap_or_default() |
|
| 177 | 182 | } |
|
| … | 21 unchanged lines |
||
| 199 | 204 | pub runtime: String, |
|
| 200 | 205 | /// The free disk it needs, from [`learned_disk_need`]. |
|
| 201 | 206 | pub need_disk_mib: Option<i64>, |
|
| 207 | + | /// A pass on the same state to reuse instead of running it (#265): the |
|
| 208 | + | /// job is created passed, with that pass's receipt. |
|
| 209 | + | pub reused: Option<Reused>, |
|
| 210 | + | } |
|
| 211 | + | ||
| 212 | + | /// A job's earlier pass on the same state, spec and sandbox (#265). |
|
| 213 | + | #[derive(Clone, Debug)] |
|
| 214 | + | pub struct Reused { |
|
| 215 | + | /// The run it passed in. |
|
| 216 | + | pub run_number: i64, |
|
| 217 | + | pub receipt: String, |
|
| 218 | + | pub receipt_hash: String, |
|
| 219 | + | pub receipt_attest: String, |
|
| 220 | + | pub receipt_signer: String, |
|
| 221 | + | pub receipt_note: String, |
|
| 222 | + | } |
|
| 223 | + | ||
| 224 | + | impl Reused { |
|
| 225 | + | /// What the reused job says, and how [`Job::reused_from`] reads it back. |
|
| 226 | + | pub fn note(&self) -> String { |
|
| 227 | + | format!("reused from run #{}", self.run_number) |
|
| 228 | + | } |
|
| 229 | + | } |
|
| 230 | + | ||
| 231 | + | /// The newest pass of job `name` with exactly this spec and sandbox on |
|
| 232 | + | /// `state` in this repository (#265). |
|
| 233 | + | pub async fn passed_on_state( |
|
| 234 | + | pool: &Pool, |
|
| 235 | + | repo_id: i64, |
|
| 236 | + | state: &str, |
|
| 237 | + | name: &str, |
|
| 238 | + | spec: &str, |
|
| 239 | + | sandbox: &str, |
|
| 240 | + | ) -> sqlx::Result<Option<Reused>> { |
|
| 241 | + | let row: Option<(i64, String, String, String, String, String)> = sqlx::query_as( |
|
| 242 | + | "SELECT r.number, j.receipt, j.receipt_hash, j.receipt_attest, j.receipt_signer, j.receipt_note \ |
|
| 243 | + | FROM flow_jobs j JOIN flow_runs r ON r.id = j.run_id \ |
|
| 244 | + | WHERE r.repo_id = $1 AND r.state = $2 AND j.name = $3 AND j.spec = $4 AND j.sandbox = $5 AND j.status = 'passed' \ |
|
| 245 | + | ORDER BY j.id DESC LIMIT 1", |
|
| 246 | + | ) |
|
| 247 | + | .bind(repo_id) |
|
| 248 | + | .bind(state) |
|
| 249 | + | .bind(name) |
|
| 250 | + | .bind(spec) |
|
| 251 | + | .bind(sandbox) |
|
| 252 | + | .fetch_optional(pool) |
|
| 253 | + | .await?; |
|
| 254 | + | Ok(row.map(|(run_number, receipt, receipt_hash, receipt_attest, receipt_signer, receipt_note)| Reused { |
|
| 255 | + | run_number, |
|
| 256 | + | receipt, |
|
| 257 | + | receipt_hash, |
|
| 258 | + | receipt_attest, |
|
| 259 | + | receipt_signer, |
|
| 260 | + | receipt_note, |
|
| 261 | + | })) |
|
| 202 | 262 | } |
|
| 203 | 263 | ||
| 204 | 264 | /// Runs of a job whose peaks set its next need. |
|
| … | 95 unchanged lines |
||
| 300 | 360 | .fetch_one(&mut *tx) |
|
| 301 | 361 | .await?; |
|
| 302 | 362 | for j in jobs { |
|
| 303 | - | sqlx::query( |
|
| 363 | + | let job_id: i64 = sqlx::query_scalar( |
|
| 304 | 364 | "INSERT INTO flow_jobs (run_id, name, depth, depends, spec, sandbox, runtime, status, need_disk_mib, approval, environment, priority, queued_ms) \ |
|
| 305 | - | VALUES ($1, $2, $3, $4, $5, $6, $7, 'queued', $8, $9, $10, $11, $12)", |
|
| 365 | + | VALUES ($1, $2, $3, $4, $5, $6, $7, 'queued', $8, $9, $10, $11, $12) RETURNING id", |
|
| 306 | 366 | ) |
|
| 307 | 367 | .bind(id) |
|
| 308 | 368 | .bind(&j.name) |
|
| … | 7 unchanged lines |
||
| 316 | 376 | .bind(environment_of(&j.spec)) |
|
| 317 | 377 | .bind(priority_of(&j.spec)) |
|
| 318 | 378 | .bind(now) |
|
| 319 | - | .execute(&mut *tx) |
|
| 379 | + | .fetch_one(&mut *tx) |
|
| 320 | 380 | .await?; |
|
| 381 | + | // A pass reused (#265): done before any runner can claim it. |
|
| 382 | + | if let Some(r) = &j.reused { |
|
| 383 | + | sqlx::query( |
|
| 384 | + | "UPDATE flow_jobs SET status = 'passed', exit_code = 0, error = $1, receipt = $2, receipt_hash = $3, receipt_attest = $4, \ |
|
| 385 | + | receipt_signer = $5, receipt_note = $6, started_ms = $7, finished_ms = $7 WHERE id = $8", |
|
| 386 | + | ) |
|
| 387 | + | .bind(r.note()) |
|
| 388 | + | .bind(&r.receipt) |
|
| 389 | + | .bind(&r.receipt_hash) |
|
| 390 | + | .bind(&r.receipt_attest) |
|
| 391 | + | .bind(&r.receipt_signer) |
|
| 392 | + | .bind(&r.receipt_note) |
|
| 393 | + | .bind(now) |
|
| 394 | + | .bind(job_id) |
|
| 395 | + | .execute(&mut *tx) |
|
| 396 | + | .await?; |
|
| 397 | + | } |
|
| 398 | + | } |
|
| 399 | + | // Every job reused: the run has passed already. |
|
| 400 | + | if !jobs.is_empty() && jobs.iter().all(|j| j.reused.is_some()) { |
|
| 401 | + | sqlx::query("UPDATE flow_runs SET status = 'passed', finished_ms = $1 WHERE id = $2").bind(now).bind(id).execute(&mut *tx).await?; |
|
| 321 | 402 | } |
|
| 322 | 403 | tx.commit().await?; |
|
| 323 | 404 | Ok(id) |
|
| … | 1010 unchanged lines |
modified crates/hub/src/db/repos.rs +9 −0
| … | 58 unchanged lines |
||
| 59 | 59 | /// 0088): `any` bookmark, `others` than the default one, or `never`. |
|
| 60 | 60 | #[sqlx(default)] |
|
| 61 | 61 | pub flow_skip: String, |
|
| 62 | + | /// A push run reuses a job's pass on the same state (#265, migration |
|
| 63 | + | /// 0090). On by default. |
|
| 64 | + | #[sqlx(default)] |
|
| 65 | + | pub flow_reuse: bool, |
|
| 62 | 66 | /// Pushes that add possible secrets are refused (#268, migration |
|
| 63 | 67 | /// 0085). On by default. |
|
| 64 | 68 | #[sqlx(default)] |
|
| … | 378 unchanged lines |
||
| 443 | 447 | ||
| 444 | 448 | /// Whether a new run leaves the older runs it supersedes that are already |
|
| 445 | 449 | /// running to finish (#267), cancelling only the queued ones. |
|
| 450 | + | pub async fn set_flow_reuse(pool: &Pool, id: i64, on: bool) -> sqlx::Result<()> { |
|
| 451 | + | sqlx::query("UPDATE repositories SET flow_reuse = $1 WHERE id = $2").bind(on).bind(id).execute(pool).await?; |
|
| 452 | + | Ok(()) |
|
| 453 | + | } |
|
| 454 | + | ||
| 446 | 455 | pub async fn set_flow_skip(pool: &Pool, id: i64, skip: &str) -> sqlx::Result<()> { |
|
| 447 | 456 | sqlx::query("UPDATE repositories SET flow_skip = $1 WHERE id = $2").bind(skip).bind(id).execute(pool).await?; |
|
| 448 | 457 | Ok(()) |
|
| … | 46 unchanged lines |
modified crates/hub/src/flow.rs +43 −2
| … | 59 unchanged lines |
||
| 60 | 60 | } |
|
| 61 | 61 | } |
|
| 62 | 62 | ||
| 63 | + | /// Whether a job's earlier pass may stand for it on the same state (#265): |
|
| 64 | + | /// not a job that waits for a person, deploys to an environment, holds |
|
| 65 | + | /// secrets or asks for identity tokens, whose running is the point. |
|
| 66 | + | fn reusable(job: &flow_core::Job) -> bool { |
|
| 67 | + | job.approval.is_none() && job.environment.is_none() && job.secrets.is_empty() && job.identities.is_empty() |
|
| 68 | + | } |
|
| 69 | + | ||
| 63 | 70 | /// The markers that ask a push not to start a run (#284), as the forges |
|
| 64 | 71 | /// spell them; matched in any case. |
|
| 65 | 72 | pub const SKIP_MARKERS: [&str; 3] = ["[skip flow]", "[skip ci]", "[ci skip]"]; |
|
| … | 730 unchanged lines |
||
| 796 | 803 | sandbox: serde_json::to_string(sb).unwrap_or_default(), |
|
| 797 | 804 | runtime: sb.runtime.as_str().into(), |
|
| 798 | 805 | need_disk_mib: None, |
|
| 806 | + | reused: None, |
|
| 799 | 807 | } |
|
| 800 | 808 | }) |
|
| 801 | 809 | .collect(); |
|
| … | 4 unchanged lines |
||
| 806 | 814 | if jobs.is_empty() { |
|
| 807 | 815 | // Nothing this event starts (or this push touched). |
|
| 808 | 816 | return Ok(None); |
|
| 817 | + | } |
|
| 818 | + | // A job that passed on this very state, with the same spec and |
|
| 819 | + | // sandbox, is not run again by a push (#265): main after a |
|
| 820 | + | // proposal landed it as tested. Never one that publishes, deploys |
|
| 821 | + | // or holds secrets, nor one whose artifacts a job of this run |
|
| 822 | + | // takes; and not where the repository turned reuse off. |
|
| 823 | + | if trigger == "push" && row.flow_reuse { |
|
| 824 | + | let consumed: std::collections::HashSet<&str> = p |
|
| 825 | + | .order() |
|
| 826 | + | .into_iter() |
|
| 827 | + | .filter(|j| keep.contains(&j.name)) |
|
| 828 | + | .flat_map(|j| j.inputs.iter().map(|i| i.from.as_str())) |
|
| 829 | + | .collect(); |
|
| 830 | + | for j in &mut jobs { |
|
| 831 | + | let Some(spec) = p.jobs.get(&j.name) else { continue }; |
|
| 832 | + | if !reusable(spec) || consumed.contains(j.name.as_str()) { |
|
| 833 | + | continue; |
|
| 834 | + | } |
|
| 835 | + | j.reused = |
|
| 836 | + | fdb::passed_on_state(&app.db, row.id, state, &j.name, &j.spec, &j.sandbox).await.map_err(|e| e.to_string())?; |
|
| 837 | + | } |
|
| 809 | 838 | } |
|
| 810 | 839 | let hash = hex::encode(p.hash()); |
|
| 811 | 840 | let id = |
|
| 812 | 841 | db::flow::create_run(&app.db, db::flow::NewRun { pipeline_hash: &hash, ..base }, &jobs).await.map_err(|e| e.to_string())?; |
|
| 813 | 842 | if let Ok(Some(run)) = fdb::run_by_id(&app.db, id).await { |
|
| 814 | - | for j in &jobs { |
|
| 843 | + | for j in jobs.iter().filter(|j| j.reused.is_none()) { |
|
| 815 | 844 | report_check(app, row, state, run.number, &j.name, "queued", "waiting for a runner").await; |
|
| 816 | 845 | } |
|
| 846 | + | // Reused passes (#265) report as any pass does: their checks, |
|
| 847 | + | // and a run that passed with them (Diffusion, landing a bump). |
|
| 848 | + | if jobs.iter().any(|j| j.reused.is_some()) { |
|
| 849 | + | let reused: Vec<fdb::Job> = |
|
| 850 | + | fdb::jobs(&app.db, run.id).await.unwrap_or_default().into_iter().filter(|j| j.reused_from().is_some()).collect(); |
|
| 851 | + | if let Err(e) = report_changed(app, run.id, reused).await { |
|
| 852 | + | tracing::warn!("flow: reporting reused passes of run #{}: {e}", run.number); |
|
| 853 | + | } |
|
| 854 | + | } |
|
| 817 | 855 | images_check(app, row, state, run.number, &jobs).await; |
|
| 818 | 856 | } |
|
| 819 | 857 | id |
|
| … | 1335 unchanged lines |
||
| 2155 | 2193 | } |
|
| 2156 | 2194 | for j in changed { |
|
| 2157 | 2195 | let summary = match j.status.as_str() { |
|
| 2158 | - | "passed" => format!("passed in {}", duration(&j)), |
|
| 2196 | + | "passed" => match j.reused_from() { |
|
| 2197 | + | Some(n) => format!("passed in run #{n} on this state, reused"), |
|
| 2198 | + | None => format!("passed in {}", duration(&j)), |
|
| 2199 | + | }, |
|
| 2159 | 2200 | "failed" => format!("exit {} after {}", j.exit_code.unwrap_or(-1), duration(&j)), |
|
| 2160 | 2201 | "skipped" => "skipped: a dependency did not pass".into(), |
|
| 2161 | 2202 | "cancelled" => j.error.clone(), |
|
| … | 562 unchanged lines |
modified crates/hub/src/web/mod.rs +1 −0
| … | 345 unchanged lines |
||
| 346 | 346 | .route("/{owner}/{name}/settings/secret-scan", post(repo_settings::secret_scan)) |
|
| 347 | 347 | .route("/{owner}/{name}/settings/flow-supersede", post(repo_settings::flow_supersede)) |
|
| 348 | 348 | .route("/{owner}/{name}/settings/flow-skip", post(repo_settings::flow_skip)) |
|
| 349 | + | .route("/{owner}/{name}/settings/flow-reuse", post(repo_settings::flow_reuse)) |
|
| 349 | 350 | .route("/{owner}/{name}/settings/diffusion", get(repo_settings::page).post(repo_settings::diffusion)) |
|
| 350 | 351 | .route("/{owner}/{name}/settings/backup", get(repo_settings::page).post(repo_settings::backup)) |
|
| 351 | 352 | .route("/{owner}/{name}/settings/storage", get(repo_settings::page).post(repo_settings::storage)) |
|
| … | 550 unchanged lines |
modified crates/hub/src/web/repo_settings.rs +32 −0
| … | 1331 unchanged lines |
||
| 1332 | 1332 | } |
|
| 1333 | 1333 | ||
| 1334 | 1334 | #[derive(Deserialize)] |
|
| 1335 | + | pub struct ReuseForm { |
|
| 1336 | + | #[serde(rename = "_csrf")] |
|
| 1337 | + | csrf_token: String, |
|
| 1338 | + | /// `on` when the box is ticked. |
|
| 1339 | + | #[serde(default)] |
|
| 1340 | + | reuse: String, |
|
| 1341 | + | } |
|
| 1342 | + | ||
| 1343 | + | /// `POST /{owner}/{repo}/settings/flow-reuse` (oxygenhub#265): whether a |
|
| 1344 | + | /// push run reuses a job's pass on the same state; for maintainers, |
|
| 1345 | + | /// audited. |
|
| 1346 | + | pub async fn flow_reuse( |
|
| 1347 | + | State(app): State<Shared>, |
|
| 1348 | + | viewer: Viewer, |
|
| 1349 | + | Path((owner, name)): Path<(String, String)>, |
|
| 1350 | + | Form(f): Form<ReuseForm>, |
|
| 1351 | + | ) -> Result<Response, Error> { |
|
| 1352 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); |
|
| 1353 | + | let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; |
|
| 1354 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 1355 | + | let on = f.reuse == "on"; |
|
| 1356 | + | db::repos::set_flow_reuse(&app.db, row.id, on).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 1357 | + | accounts::audit(&app.db, viewer.account(), "repo.flow_reuse", &format!("{owner}/{name}"), if on { "on" } else { "off" }).await.ok(); |
|
| 1358 | + | let notice = if on { |
|
| 1359 | + | "A push run now reuses a job's pass on the same state." |
|
| 1360 | + | } else { |
|
| 1361 | + | "Every job of every push now runs, passed before or not." |
|
| 1362 | + | }; |
|
| 1363 | + | back(&row, Section::Runs, Some(notice.into()), None) |
|
| 1364 | + | } |
|
| 1365 | + | ||
| 1366 | + | #[derive(Deserialize)] |
|
| 1335 | 1367 | pub struct SkipForm { |
|
| 1336 | 1368 | #[serde(rename = "_csrf")] |
|
| 1337 | 1369 | csrf_token: String, |
|
| … | 276 unchanged lines |
modified crates/hub/tests/flow.rs +63 −0
| … | 1891 unchanged lines |
||
| 1892 | 1892 | assert!(!oxygenhub::flow::skip_allowed(&row, "feature")); |
|
| 1893 | 1893 | } |
|
| 1894 | 1894 | ||
| 1895 | + | /// oxygenhub#265: a push run on a state whose jobs already passed reuses |
|
| 1896 | + | /// those passes (same spec and sandbox), except a job behind a gate; a |
|
| 1897 | + | /// repository can turn reuse off. |
|
| 1898 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 1899 | + | async fn a_push_reuses_a_pass_on_the_same_state() { |
|
| 1900 | + | let h = hub().await; |
|
| 1901 | + | db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", true).await.unwrap(); |
|
| 1902 | + | create(&h.app, "andy", "reuse").await; |
|
| 1903 | + | let url = format!("{}/andy/reuse", h.base); |
|
| 1904 | + | let work = tempfile::tempdir().unwrap(); |
|
| 1905 | + | let c = client(&url, work.path()); |
|
| 1906 | + | let gated = "job \"deploy\" {\n sandbox \"alpine\"\n depends-on \"test\"\n approval {\n by \"maintain\"\n }\n exec \"true\"\n}\n"; |
|
| 1907 | + | write(work.path(), ".flow/pipeline.kdl", &pipeline(gated)); |
|
| 1908 | + | write(work.path(), "test.sh", "echo ok\n"); |
|
| 1909 | + | c.commit("Start", &[]).unwrap(); |
|
| 1910 | + | assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 1911 | + | let first = runs_of(&h.app, "andy", "reuse").await.remove(0); |
|
| 1912 | + | // Its jobs pass, the gated one too, each with a receipt. |
|
| 1913 | + | sqlx::query("UPDATE flow_jobs SET status = 'passed', exit_code = 0, receipt_hash = 'r-' || name WHERE run_id = $1") |
|
| 1914 | + | .bind(first.id) |
|
| 1915 | + | .execute(&h.app.db) |
|
| 1916 | + | .await |
|
| 1917 | + | .unwrap(); |
|
| 1918 | + | sqlx::query("UPDATE flow_runs SET status = 'passed' WHERE id = $1").bind(first.id).execute(&h.app.db).await.unwrap(); |
|
| 1919 | + | let heads: Vec<oxy_core::hash::ChangeHash> = first.head_list().into_iter().filter_map(oxy_core::hash::ChangeHash::from_hex).collect(); |
|
| 1920 | + | let row = db::repos::get(&h.app.db, "andy", "reuse").await.unwrap().unwrap(); |
|
| 1921 | + | assert!(row.flow_reuse, "on by default"); |
|
| 1922 | + | let again = |row: db::repos::RepoRow| { |
|
| 1923 | + | let (app, state, heads) = (h.app.clone(), first.state.clone(), heads.clone()); |
|
| 1924 | + | async move { |
|
| 1925 | + | let n = |
|
| 1926 | + | oxygenhub::flow::start_run_scoped(&app, &row, "main", &state, &heads, "andy", "push", None).await.unwrap().expect("a run"); |
|
| 1927 | + | let run = db::flow::run(&app.db, row.id, n).await.unwrap().unwrap(); |
|
| 1928 | + | let jobs = db::flow::jobs(&app.db, run.id).await.unwrap(); |
|
| 1929 | + | (run, jobs) |
|
| 1930 | + | } |
|
| 1931 | + | }; |
|
| 1932 | + | let (run, jobs) = again(row.clone()).await; |
|
| 1933 | + | let by = |name: &str| jobs.iter().find(|j| j.name == name).unwrap().clone(); |
|
| 1934 | + | for name in ["build", "test", "lint"] { |
|
| 1935 | + | let j = by(name); |
|
| 1936 | + | assert_eq!((j.status.as_str(), j.reused_from()), ("passed", Some(first.number)), "{name}: {j:?}"); |
|
| 1937 | + | assert_eq!(j.receipt_hash, format!("r-{name}"), "the pass's receipt stands for it"); |
|
| 1938 | + | } |
|
| 1939 | + | let deploy = by("deploy"); |
|
| 1940 | + | assert!(deploy.status == "queued" && deploy.reused_from().is_none(), "a gated job runs again: {deploy:?}"); |
|
| 1941 | + | assert_eq!(run.status, "queued", "the run waits for the gated job"); |
|
| 1942 | + | let (_, page) = call(&h.app, "GET", &format!("/andy/reuse/flow/{}", run.number), None, None).await; |
|
| 1943 | + | assert!(page.contains("data-reused") && page.contains(&format!("/andy/reuse/flow/{}", first.number)), "{page}"); |
|
| 1944 | + | ||
| 1945 | + | let checks = db::hooks::checks_for_state(&h.app.db, row.id, &first.state).await.unwrap(); |
|
| 1946 | + | assert!( |
|
| 1947 | + | checks.iter().any(|c| c.name == "flow/build" && c.status == "passed" && c.summary.contains("reused")), |
|
| 1948 | + | "a reused pass reports its check: {checks:?}" |
|
| 1949 | + | ); |
|
| 1950 | + | ||
| 1951 | + | // Turned off: every job runs. |
|
| 1952 | + | db::repos::set_flow_reuse(&h.app.db, row.id, false).await.unwrap(); |
|
| 1953 | + | let row = db::repos::get(&h.app.db, "andy", "reuse").await.unwrap().unwrap(); |
|
| 1954 | + | let (_, jobs) = again(row).await; |
|
| 1955 | + | assert!(jobs.iter().all(|j| j.status == "queued" && j.reused_from().is_none()), "{jobs:?}"); |
|
| 1956 | + | } |
|
| 1957 | + | ||
| 1895 | 1958 | #[test] |
|
| 1896 | 1959 | fn skip_markers_are_the_forges_spellings_in_any_case() { |
|
| 1897 | 1960 | use oxygenhub::flow::skip_marker; |
|
| … | 4313 unchanged lines |
modified crates/hub/tests/proposals.rs +1 −0
| … | 805 unchanged lines |
||
| 806 | 806 | sandbox: "{}".into(), |
|
| 807 | 807 | runtime: "process".into(), |
|
| 808 | 808 | need_disk_mib: None, |
|
| 809 | + | reused: None, |
|
| 809 | 810 | }; |
|
| 810 | 811 | let new = |
|
| 811 | 812 | db::flow::NewRun { repo_id, bookmark, state: "s", heads: "h", pipeline_hash: "", actor: "andy", trigger, error: None }; |
|
| … | 295 unchanged lines |
modified docs/spec.md +1 −0
| … | 517 unchanged lines |
||
| 518 | 518 | Runs only ever take committed states already in the repository, never uncommitted work. A run writes no history: its receipts are signed statements about a state (Oxygen spec §14.6). Running a state again adds another statement. The environment hash shows whether it ran in the same environment, and a matching result is independent confirmation. 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). |
|
| 519 | 519 | - **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`. |
|
| 520 | 520 | - **Newest run wins** (oxygenhub#267). A new run cancels the older runs of the same thing still live, so runs do not pile up behind states that have moved on: a push run cancels the older push runs of its bookmark (Diffusion's bumps start push runs), and a proposal's run (bookmark `proposal/N`, started when its target moves) the older runs of that proposal. Each cancelled job says `superseded by run #N`, and the next push run's scope reaches back past what was cancelled. A proposal's run leaves its source bookmark's push runs alone: they test the source head, another state, whose checks the bookmark and other proposals may want. Tag, manual, scheduled and re-run runs were each asked for in their own right, so they supersede nothing and nothing supersedes them; they are cancelled by hand. A repository's maintainers choose under Settings › OxygenFlow › Newest run wins whether superseded runs already running are cancelled too (the default) or finish, with only the queued ones cancelled (`repositories.flow_keep_running`, audited as `repo.flow_keep_running`). |
|
| 521 | + | - **Reusing a pass** (oxygenhub#265). A push run does not run a job again when the same job, with the same compiled spec and sandbox (the image pin included), already passed on the very same state in this repository, as main does after a proposal lands its tested projection: the job is created passed, its `error` note `reused from run #N`, carrying that pass's receipt, hash and attestation, and its check reports `passed in run #N on this state, reused`. A run whose jobs are all reused has passed when created, and reports as any passing run (Diffusion's green bumps, a bump's landing). Never reused: a job with an approval gate, an environment, secrets or identity tokens, or one whose artifacts another job of the run takes as inputs; manual runs, re-runs, schedules and tags always run. Maintainers turn it off under Settings › Runs (`repositories.flow_reuse`, audited as `repo.flow_reuse`). |
|
| 521 | 522 | - **Pushing without a run** (oxygenhub#284). A push whose newest change (by its clock) has `[skip flow]`, `[skip ci]` or `[ci skip]` in its message, in any case and outside code (a marker between backticks or in a fenced block only mentions one), starts no jobs: the hub records a run with status `skipped`, finished at once, whose note names the marker, the change and who pushed it, so the run list shows the gap. The marker is in a signed message, so who skipped is verifiable. A skipped run is never a later run's baseline: the next push run's scope reaches back past it, so the changes it brought are still tested. A pushed tag always runs. A repository's maintainers choose under Settings › Runs where skipping is allowed: on any bookmark (the default), on any but the default bookmark, or never (`repositories.flow_skip`: `any`, `others`, `never`; audited as `repo.flow_skip`). |
|
| 522 | 523 | - **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. |
|
| 523 | 524 | - **Runners** (Admin → Runners, site administrators). |
|
| … | 364 unchanged lines |
modified templates/flow_run.html +1 −0
| … | 62 unchanged lines |
||
| 63 | 63 | <pre class="flow-log" data-log data-seq="{{ l.last_seq }}">{% for line in l.lines %}<span class="ln s-{{ line.stream }}"><span class="t">{{ line.t / 1000 }}.{{ "{:01}"|format(line.t % 1000 / 100) }}s</span>{{ line.text }}</span> |
|
| 64 | 64 | {% endfor %}</pre> |
|
| 65 | 65 | {% if !l.j.placed.is_empty() %}<p class="small mute pad">Ran on {{ l.j.runner }}: {{ l.j.placed }}.</p>{% endif %} |
|
| 66 | + | {% if l.j.status == "passed" %}{% if let Some(n) = l.j.reused_from() %}<p class="small mute pad" data-reused>Not run again: this job, with the same spec and sandbox, passed on this very state in <a href="{{ repo.url }}/flow/{{ n }}">run #{{ n }}</a>, whose log it would repeat; that pass's receipt stands for it here.</p>{% endif %}{% endif %} |
|
| 66 | 67 | {% if l.j.status == "queued" && !l.j.waiting.is_empty() %}<p class="small warn pad" data-waiting data-queued-note>Waiting: {{ l.j.waiting }}.</p>{% else if l.lines.is_empty() && l.j.status == "queued" %}<p class="small mute pad" data-queued-note>Waiting for a runner{% if !l.j.depends_on().is_empty() %} and for {{ l.j.depends_on().join(", ") }}{% endif %}.</p>{% endif %} |
|
| 67 | 68 | {% if l.lines.is_empty() && l.j.status == "skipped" %}<p class="small mute pad">Skipped: a job it depends on did not pass.</p>{% endif %} |
|
| 68 | 69 | </details> |
|
| … | 3 unchanged lines |
modified templates/repo_settings.html +7 −0
| … | 162 unchanged lines |
||
| 163 | 163 | <label class="check"><input type="checkbox" name="running"{% if !row.flow_keep_running %} checked{% endif %}> Cancel superseded runs already running too (untick to let them finish and cancel only those still queued)</label> |
|
| 164 | 164 | <button type="submit">SAVE</button> |
|
| 165 | 165 | </form> |
|
| 166 | + | <form class="card wide" id="flow-reuse" method="post" action="{{ repo.url }}/settings/flow-reuse"> |
|
| 167 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 168 | + | <h2>Reuse passes</h2> |
|
| 169 | + | <p>A push run doesn't run a job again when the same job, with the same spec and sandbox, already passed on the very same state, as main does after a proposal lands: the job shows as passed, reused from that run, with its receipt. A job that waits for an approval, deploys to an environment, holds secrets or asks for identity tokens always runs, as does one whose artifacts another job of the run takes.</p> |
|
| 170 | + | <label class="check"><input type="checkbox" name="reuse"{% if row.flow_reuse %} checked{% endif %}> Reuse a job's pass on the same state (untick to run every job of every push, for flaky or time-dependent tests)</label> |
|
| 171 | + | <button type="submit">SAVE</button> |
|
| 172 | + | </form> |
|
| 166 | 173 | <form class="card wide" id="flow-skip" method="post" action="{{ repo.url }}/settings/flow-skip"> |
|
| 167 | 174 | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 168 | 175 | <h2>Pushing without a run</h2> |
|
| … | 185 unchanged lines |