OXYGENHUB

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