Artifacts: when "always" keeps a failing job's report 9a6fac2b40a9
An artifact declared with when "always" is read and uploaded when the job fails or times out too, so a test report survives the failure it describes. From a job that did not pass it never goes in the receipt, the log says so, and one that cannot be read is only noted. The default, when "passed", serialises and hashes as before. closes #37
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-05 18:44 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
9a6fac2b40a9b624498746ae2edd9656a975a862ff7aff8e51404b9a87e6ee3c- Depends on
- a083cc802892 a67b97e9a717 e0918b887850 e2c125610f43 ea56c56eaf90
- Depended on by
- 28cc603f737d 6d53fb7d00ec
- In bookmarks
- main newer-pipeline push-uses-login
- In tags
- v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.5.0 v0.5.1 v0.5.2 v0.5.3
- Edits
- 12 atoms, 0 tree operations, 4 files
Browse the tree at this change
modified crates/flow-core/src/pipeline.rs +12 −1
| … | 249 unchanged lines |
||
| 250 | 250 | pub name: String, |
|
| 251 | 251 | /// A clean absolute path under `/src`, not on a tmpfs mount. |
|
| 252 | 252 | pub path: String, |
|
| 253 | + | /// `when "always"`: kept from a failing job too, such as a test report |
|
| 254 | + | /// (oxygenflow#37). From a failing job it is never in the receipt. |
|
| 255 | + | #[serde(default, skip_serializing_if = "std::ops::Not::not")] |
|
| 256 | + | pub always: bool, |
|
| 253 | 257 | } |
|
| 254 | 258 | ||
| 255 | 259 | /// A secret the job reads as a file (spec §9): never in the environment. |
|
| … | 810 unchanged lines |
||
| 1066 | 1070 | continue; |
|
| 1067 | 1071 | } |
|
| 1068 | 1072 | let mut path = None; |
|
| 1073 | + | let mut always = false; |
|
| 1069 | 1074 | for c in block(ctx, a) { |
|
| 1070 | 1075 | ctx.no_children(c); |
|
| 1071 | 1076 | match c.name().value() { |
|
| … | 10 unchanged lines |
||
| 1082 | 1087 | ctx.err(c.span(), "artifact format is \"blob\""); |
|
| 1083 | 1088 | } |
|
| 1084 | 1089 | } |
|
| 1090 | + | "when" => match ctx.one_string(c).as_deref() { |
|
| 1091 | + | Some("passed") => always = false, |
|
| 1092 | + | Some("always") => always = true, |
|
| 1093 | + | Some(_) => ctx.err(c.span(), "artifact `when` is \"passed\" (the default) or \"always\""), |
|
| 1094 | + | None => {} |
|
| 1095 | + | }, |
|
| 1085 | 1096 | other => ctx.err(c.span(), format!("unknown artifact setting `{other}`")), |
|
| 1086 | 1097 | } |
|
| 1087 | 1098 | } |
|
| … | 6 unchanged lines |
||
| 1094 | 1105 | } else if job.outputs.len() == MAX_OUTPUTS { |
|
| 1095 | 1106 | ctx.err(a.span(), format!("a job has at most {MAX_OUTPUTS} artifacts")); |
|
| 1096 | 1107 | } else { |
|
| 1097 | - | job.outputs.push(Output { name, path }); |
|
| 1108 | + | job.outputs.push(Output { name, path, always }); |
|
| 1098 | 1109 | } |
|
| 1099 | 1110 | } |
|
| 1100 | 1111 | } |
|
| … | 126 unchanged lines |
modified crates/flow-core/src/tests.rs +15 −0
| … | 459 unchanged lines |
||
| 460 | 460 | .contains("given twice")); |
|
| 461 | 461 | assert!(bad(" output {\n artifact \"x\" {\n path \"/src/a\"\n format \"tar\"\n }\n }\n") |
|
| 462 | 462 | .contains("format")); |
|
| 463 | + | ||
| 464 | + | // oxygenflow#37: `when "always"` keeps a failing job's report; the |
|
| 465 | + | // default hashes and serialises as before. |
|
| 466 | + | let p = Pipeline::parse(&src( |
|
| 467 | + | " output {\n artifact \"junit\" {\n path \"/src/target/junit.xml\"\n when \"always\"\n }\n artifact \"tool\" {\n path \"/src/target/tool\"\n when \"passed\"\n }\n }\n", |
|
| 468 | + | )) |
|
| 469 | + | .unwrap(); |
|
| 470 | + | let outs = &p.jobs["build"].outputs; |
|
| 471 | + | assert!(outs[0].always && outs[0].name == "junit"); |
|
| 472 | + | assert!(!outs[1].always); |
|
| 473 | + | let json = serde_json::to_string(&outs[1]).unwrap(); |
|
| 474 | + | assert!(!json.contains("always"), "{json}"); |
|
| 475 | + | assert!(serde_json::to_string(&outs[0]).unwrap().contains("\"always\":true")); |
|
| 476 | + | assert!(bad(" output {\n artifact \"x\" {\n path \"/src/a\"\n when \"failed\"\n }\n }\n") |
|
| 477 | + | .contains("\"passed\" (the default) or \"always\"")); |
|
| 463 | 478 | } |
|
| 464 | 479 | ||
| 465 | 480 | /// metalgear feedback 19 (oxygenflow#17): `\033` in a string is not KDL; |
|
| … | 85 unchanged lines |
modified crates/flow/src/runner.rs +30 −25
| … | 559 unchanged lines |
||
| 560 | 560 | return Ok(cancelled()); |
|
| 561 | 561 | } |
|
| 562 | 562 | // A passing job's artifacts, read from its disks and uploaded; a |
|
| 563 | - | // declared artifact that cannot be kept fails the job. |
|
| 563 | + | // declared artifact that cannot be kept fails the job. A failing |
|
| 564 | + | // job keeps only its `when "always"` ones (oxygenflow#37), outside |
|
| 565 | + | // its receipt, and one it cannot keep is only noted. |
|
| 564 | 566 | let mut outputs = vec![]; |
|
| 565 | 567 | let mut output_error = None; |
|
| 566 | - | if outcome.exit_code == Some(0) { |
|
| 567 | - | for o in &claim.job.outputs { |
|
| 568 | - | let read = if syscall_tier { |
|
| 569 | - | let (dir, inner) = crate::outputs::locate_dir(&claim.job, &scratch.join("tree"), &scratch, &o.path); |
|
| 570 | - | crate::outputs::read_dir_file(&dir, &inner) |
|
| 571 | - | } else { |
|
| 572 | - | let (image, inner) = crate::outputs::locate(&claim.job, &scratch.join("workspace.ext4"), &scratch, &o.path); |
|
| 573 | - | crate::outputs::read(&image, &inner, &scratch) |
|
| 574 | - | }; |
|
| 575 | - | let kept = read.and_then(|bytes| { |
|
| 576 | - | let blob = Repo::blob_ref(&bytes); |
|
| 577 | - | let named = hub.artifact(claim.job_id, &o.name, &bytes)?; |
|
| 578 | - | if named != blob.to_hex() { |
|
| 579 | - | bail!("the hub kept it as {named}, not {}", blob.to_hex()); |
|
| 580 | - | } |
|
| 581 | - | note(&mut sink, format!("artifact {}: {} bytes, blob {}", o.name, bytes.len(), &named[..12])); |
|
| 582 | - | Ok(blob.0) |
|
| 583 | - | }); |
|
| 584 | - | match kept { |
|
| 585 | - | Ok(b) => outputs.push(b), |
|
| 586 | - | Err(e) => { |
|
| 587 | - | output_error = Some(format!("artifact {}: {e:#}", o.name)); |
|
| 588 | - | break; |
|
| 589 | - | } |
|
| 568 | + | let passed = outcome.exit_code == Some(0); |
|
| 569 | + | let wanted = claim.job.outputs.iter().filter(|o| passed || o.always); |
|
| 570 | + | for o in wanted { |
|
| 571 | + | let read = if syscall_tier { |
|
| 572 | + | let (dir, inner) = crate::outputs::locate_dir(&claim.job, &scratch.join("tree"), &scratch, &o.path); |
|
| 573 | + | crate::outputs::read_dir_file(&dir, &inner) |
|
| 574 | + | } else { |
|
| 575 | + | let (image, inner) = crate::outputs::locate(&claim.job, &scratch.join("workspace.ext4"), &scratch, &o.path); |
|
| 576 | + | crate::outputs::read(&image, &inner, &scratch) |
|
| 577 | + | }; |
|
| 578 | + | let kept = read.and_then(|bytes| { |
|
| 579 | + | let blob = Repo::blob_ref(&bytes); |
|
| 580 | + | let named = hub.artifact(claim.job_id, &o.name, &bytes)?; |
|
| 581 | + | if named != blob.to_hex() { |
|
| 582 | + | bail!("the hub kept it as {named}, not {}", blob.to_hex()); |
|
| 583 | + | } |
|
| 584 | + | let unattested = if passed { "" } else { " (the job failed: not in its receipt)" }; |
|
| 585 | + | note(&mut sink, format!("artifact {}: {} bytes, blob {}{unattested}", o.name, bytes.len(), &named[..12])); |
|
| 586 | + | Ok(blob.0) |
|
| 587 | + | }); |
|
| 588 | + | match kept { |
|
| 589 | + | Ok(b) if passed => outputs.push(b), |
|
| 590 | + | Ok(_) => {} |
|
| 591 | + | Err(e) if passed => { |
|
| 592 | + | output_error = Some(format!("artifact {}: {e:#}", o.name)); |
|
| 593 | + | break; |
|
| 590 | 594 | } |
|
| 595 | + | Err(e) => note(&mut sink, format!("artifact {}: not kept: {e:#}", o.name)), |
|
| 591 | 596 | } |
|
| 592 | 597 | } |
|
| 593 | 598 | if let Some(e) = &output_error { |
|
| … | 336 unchanged lines |
modified docs/spec.md +14 −0
| … | 233 unchanged lines |
||
| 234 | 234 | 2. Modified outputs are chunked with FastCDC, hashed via BLAKE3-256, and |
|
| 235 | 235 | written as canonical Blob and Chunk objects. |
|
| 236 | 236 | 3. Output ObjectRefs are stored directly in the Oxygen repository. |
|
| 237 | + | 4. Outputs are read when the job passes. An artifact with `when "always"` |
|
| 238 | + | (default `when "passed"`) is also read when the job fails or is stopped |
|
| 239 | + | at its time limit, so a test report survives the failure it describes. |
|
| 240 | + | From a job that did not pass it is kept and shown, but its receipt never |
|
| 241 | + | names it, and one that cannot be read is only noted in the log. |
|
| 237 | 242 | ||
| 243 | + | ```kdl |
|
| 244 | + | output { |
|
| 245 | + | artifact "junit" { |
|
| 246 | + | path "/src/target/junit.xml" |
|
| 247 | + | when "always" |
|
| 248 | + | } |
|
| 249 | + | } |
|
| 250 | + | ``` |
|
| 251 | + | ||
| 238 | 252 | ## 6. Commutative Execution & Change Caching |
|
| 239 | 253 | ||
| 240 | 254 | In Oxygen, changes that do not share scopes (referenced atoms, placements, or |
|
| … | 282 unchanged lines |