OXYGENHUB

Report outputs: a directory with report.toml, uploaded file by file with a manifest 6d53fb7d00ec

A job's output block takes report "NAME" { path; when } beside artifact.
The runner reads the directory without following links (debugfs rdump on
the MicroVM tier, checked components on the syscall tier), within 2000
files and 512 MiB, uploads each file to the hub's report files endpoint
and then a manifest under the report's name; the receipt names the
manifest. The kind is part of the job's hash.

refs andymccall/oxygenhub#127
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-10 23:41 UTC
Signature
✔ signed by Andy McCall
Hash
6d53fb7d00eca0eeaf62fe6094a88849c03bd2e57846f9e747224566cf5b1001
Depends on
1abadd4a2fa6 3007d8f011fa 47c1d64dec8a 9a6fac2b40a9 a083cc802892 a67b97e9a717 e0918b887850 e2c125610f43 ea56c56eaf90 fc10d9e80383
Depended on by
28cc603f737d e20897216221 46fe2c815cc3 ea81796bb757 89171fca079c d60aac6c3f8a
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
27 atoms, 0 tree operations, 7 files

Browse the tree at this change

modified crates/flow-core/src/canonical.rs +10 −2
…
72 unchanged lines
73 73
            ),
74 74
        ));
75 75
    }
76 -
    // Artifacts as [name, path] sorted by name; absent when there are none.
76 +
    // Outputs as [name, path] sorted by name, a report's with "report"
77 +
    // after them; absent when there are none.
77 78
    if !j.outputs.is_empty() {
78 -
        fields.push((7, Value::Array(j.outputs.iter().map(|o| Value::Array(vec![text(&o.name), text(&o.path)])).collect())));
79 +
        let output = |o: &crate::pipeline::Output| {
80 +
            let mut v = vec![text(&o.name), text(&o.path)];
81 +
            if !o.kind.is_artifact() {
82 +
                v.push(text(o.kind.word()));
83 +
            }
84 +
            Value::Array(v)
85 +
        };
86 +
        fields.push((7, Value::Array(j.outputs.iter().map(output).collect())));
79 87
    }
80 88
    // Secrets as [name, mount path] sorted by name; absent when there are none.
81 89
    if !j.secrets.is_empty() {
…
50 unchanged lines
modified crates/flow-core/src/lib.rs +2 −1
…
11 unchanged lines
12 12
13 13
pub use canonical::{object_hash, scope_tree_hash, Hash};
14 14
pub use pipeline::{
15 -
    arch_ok, Identity, Job, Mount, MountSource, Network, Pipeline, Problem, Problems, Runtime, Sandbox, Secret, PIPELINE_PATH,
15 +
    arch_ok, Identity, Job, Mount, MountSource, Network, Output, OutputKind, Pipeline, Problem, Problems, Runtime, Sandbox, Secret,
16 +
    PIPELINE_PATH,
16 17
};
17 18
pub use receipt::{Environment, Receipt};
18 19
…
2 unchanged lines
modified crates/flow-core/src/pipeline.rs +49 −17
…
253 unchanged lines
254 254
    /// (oxygenflow#37). From a failing job it is never in the receipt.
255 255
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
256 256
    pub always: bool,
257 +
    /// An artifact is one file; a report is a directory with a
258 +
    /// `report.toml`, kept file by file with a manifest (oxygenhub#127).
259 +
    #[serde(default, skip_serializing_if = "OutputKind::is_artifact")]
260 +
    pub kind: OutputKind,
257 261
}
258 262
263 +
/// What an [`Output`] is.
264 +
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
265 +
#[serde(rename_all = "lowercase")]
266 +
pub enum OutputKind {
267 +
    #[default]
268 +
    Artifact,
269 +
    Report,
270 +
}
271 +
272 +
impl OutputKind {
273 +
    pub fn is_artifact(&self) -> bool {
274 +
        *self == OutputKind::Artifact
275 +
    }
276 +
277 +
    pub fn word(&self) -> &'static str {
278 +
        match self {
279 +
            OutputKind::Artifact => "artifact",
280 +
            OutputKind::Report => "report",
281 +
        }
282 +
    }
283 +
}
284 +
259 285
/// A secret the job reads as a file (spec §9): never in the environment.
260 286
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
261 287
pub struct Secret {
…
796 unchanged lines
1058 1084
1059 1085
fn parse_output(ctx: &mut Ctx, node: &KdlNode, job: &mut Job) {
1060 1086
    if !node.entries().is_empty() {
1061 -
        ctx.err(node.span(), "`output` takes a block of artifacts");
1087 +
        ctx.err(node.span(), "`output` takes a block of artifacts and reports");
1062 1088
    }
1063 1089
    for a in block(ctx, node) {
1064 -
        if a.name().value() != "artifact" {
1065 -
            ctx.err(a.span(), format!("unknown output `{}`; outputs are `artifact \"name\" {{ path \"/src/…\" }}`", a.name().value()));
1066 -
            continue;
1067 -
        }
1090 +
        let kind = match a.name().value() {
1091 +
            "artifact" => OutputKind::Artifact,
1092 +
            "report" => OutputKind::Report,
1093 +
            other => {
1094 +
                ctx.err(
1095 +
                    a.span(),
1096 +
                    format!("unknown output `{other}`; outputs are `artifact \"name\" {{ path \"/src/file\" }}` and `report \"name\" {{ path \"/src/dir\" }}`"),
1097 +
                );
1098 +
                continue;
1099 +
            }
1100 +
        };
1101 +
        let word = kind.word();
1068 1102
        let Some(name) = ctx.one_string(a) else { continue };
1069 -
        if !ctx.name_ok(a.span(), "artifact", &name) {
1103 +
        if !ctx.name_ok(a.span(), word, &name) {
1070 1104
            continue;
1071 1105
        }
1072 1106
        let mut path = None;
…
3 unchanged lines
1076 1110
            match c.name().value() {
1077 1111
                "path" => match ctx.one_string(c) {
1078 1112
                    Some(p) if output_path_ok(&p) => path = Some(p),
1079 -
                    Some(p) => ctx.err(
1080 -
                        c.span(),
1081 -
                        format!("artifact path {p:?} must be an absolute path under /src, without . or .., quotes or spaces"),
1082 -
                    ),
1113 +
                    Some(p) => ctx
1114 +
                        .err(c.span(), format!("{word} path {p:?} must be an absolute path under /src, without . or .., quotes or spaces")),
1083 1115
                    None => {}
1084 1116
                },
1085 -
                "format" => {
1117 +
                "format" if kind == OutputKind::Artifact => {
1086 1118
                    if ctx.one_string(c).as_deref() != Some("blob") {
1087 1119
                        ctx.err(c.span(), "artifact format is \"blob\"");
1088 1120
                    }
…
1 unchanged lines
1090 1122
                "when" => match ctx.one_string(c).as_deref() {
1091 1123
                    Some("passed") => always = false,
1092 1124
                    Some("always") => always = true,
1093 -
                    Some(_) => ctx.err(c.span(), "artifact `when` is \"passed\" (the default) or \"always\""),
1125 +
                    Some(_) => ctx.err(c.span(), format!("{word} `when` is \"passed\" (the default) or \"always\"")),
1094 1126
                    None => {}
1095 1127
                },
1096 -
                other => ctx.err(c.span(), format!("unknown artifact setting `{other}`")),
1128 +
                other => ctx.err(c.span(), format!("unknown {word} setting `{other}`")),
1097 1129
            }
1098 1130
        }
1099 1131
        let Some(path) = path else {
1100 -
            ctx.err(a.span(), format!("artifact {name:?} needs a path"));
1132 +
            ctx.err(a.span(), format!("{word} {name:?} needs a path"));
1101 1133
            continue;
1102 1134
        };
1103 1135
        if job.outputs.iter().any(|o| o.name == name) {
1104 -
            ctx.err(a.span(), format!("artifact {name:?} is given twice"));
1136 +
            ctx.err(a.span(), format!("output {name:?} is given twice (artifacts and reports share names)"));
1105 1137
        } else if job.outputs.len() == MAX_OUTPUTS {
1106 -
            ctx.err(a.span(), format!("a job has at most {MAX_OUTPUTS} artifacts"));
1138 +
            ctx.err(a.span(), format!("a job has at most {MAX_OUTPUTS} artifacts and reports"));
1107 1139
        } else {
1108 -
            job.outputs.push(Output { name, path, always });
1140 +
            job.outputs.push(Output { name, path, always, kind });
1109 1141
        }
1110 1142
    }
1111 1143
}
…
126 unchanged lines
modified crates/flow-core/src/tests.rs +15 −0
…
474 unchanged lines
475 475
    assert!(serde_json::to_string(&outs[0]).unwrap().contains("\"always\":true"));
476 476
    assert!(bad("    output {\n        artifact \"x\" {\n            path \"/src/a\"\n            when \"failed\"\n        }\n    }\n")
477 477
        .contains("\"passed\" (the default) or \"always\""));
478 +
479 +
    // oxygenhub#127: a report is a directory output; it shares names with
480 +
    // artifacts, and its kind is part of the job's hash.
481 +
    let as_kind = |kind: &str| {
482 +
        Pipeline::parse(&src(&format!("    output {{\n        {kind} \"perf\" {{\n            path \"/src/target/report\"\n            when \"always\"\n        }}\n    }}\n"))).unwrap()
483 +
    };
484 +
    let (report, artifact) = (as_kind("report"), as_kind("artifact"));
485 +
    let o = &report.jobs["build"].outputs[0];
486 +
    assert!(o.kind == OutputKind::Report && o.always && o.path == "/src/target/report");
487 +
    assert!(serde_json::to_string(o).unwrap().contains("\"kind\":\"report\""));
488 +
    assert!(!serde_json::to_string(&artifact.jobs["build"].outputs[0]).unwrap().contains("kind"));
489 +
    assert_ne!(report.jobs["build"].hash(sb), artifact.jobs["build"].hash(sb));
490 +
    assert!(bad("    output {\n        artifact \"x\" {\n            path \"/src/a\"\n        }\n        report \"x\" {\n            path \"/src/b\"\n        }\n    }\n")
491 +
        .contains("given twice"));
492 +
    assert!(bad("    output {\n        report \"x\" {\n        }\n    }\n").contains("needs a path"));
478 493
}
479 494
480 495
/// metalgear feedback 19 (oxygenflow#17): `\033` in a string is not KDL;
…
85 unchanged lines
modified crates/flow/src/outputs.rs +126 −0
…
79 unchanged lines
80 80
    Ok(bytes)
81 81
}
82 82
83 +
/// Most files a report keeps (oxygenhub#127).
84 +
pub const MAX_REPORT_FILES: usize = 2000;
85 +
/// Most bytes a report's files hold together.
86 +
pub const MAX_REPORT_BYTES: u64 = 512 << 20;
87 +
88 +
/// Report directory `inner` of ext4 image `image`: dumped whole with
89 +
/// debugfs into `scratch`, then read as [`read_tree`] does.
90 +
pub fn read_report(image: &Path, inner: &str, scratch: &Path) -> Result<Vec<(String, Vec<u8>)>> {
91 +
    let stat = debugfs(image, &format!("stat \"{inner}\""))?;
92 +
    if !stat.contains("Type: directory") {
93 +
        bail!("{inner} is not a directory in the job's disk: a report is a directory holding report.toml");
94 +
    }
95 +
    let dest = scratch.join("report.out");
96 +
    let _ = std::fs::remove_dir_all(&dest);
97 +
    std::fs::create_dir_all(&dest)?;
98 +
    debugfs(image, &format!("rdump \"{inner}\" \"{}\"", dest.display()))?;
99 +
    let base = inner.trim_end_matches('/').rsplit('/').next().unwrap_or_default();
100 +
    let files = read_tree(&dest.join(base));
101 +
    let _ = std::fs::remove_dir_all(&dest);
102 +
    files
103 +
}
104 +
105 +
/// Report directory `inner` under `base` for the syscall tier: every
106 +
/// component of the path must be a directory, never a link, so a job
107 +
/// cannot point the runner outside its own tree.
108 +
pub fn read_report_dir(base: &Path, inner: &str) -> Result<Vec<(String, Vec<u8>)>> {
109 +
    let mut dir = base.to_path_buf();
110 +
    for part in inner.split('/').filter(|p| !p.is_empty()) {
111 +
        if part == "." || part == ".." {
112 +
            bail!("{inner} is not a clean path");
113 +
        }
114 +
        dir.push(part);
115 +
        let meta = std::fs::symlink_metadata(&dir).with_context(|| format!("{inner}: no such directory in the job's tree"))?;
116 +
        if !meta.is_dir() {
117 +
            bail!("{inner} is not a directory in the job's tree (a link is not followed): a report is a directory holding report.toml");
118 +
        }
119 +
    }
120 +
    read_tree(&dir)
121 +
}
122 +
123 +
/// A report file name: letters, digits, `.`, `_`, `-` and `+`, so a path
124 +
/// travels in a header and as a URL as it is.
125 +
fn report_name_ok(n: &str) -> bool {
126 +
    !n.is_empty() && n != "." && n != ".." && n.len() <= 255 && n.bytes().all(|b| b.is_ascii_alphanumeric() || b"._-+".contains(&b))
127 +
}
128 +
129 +
/// The regular files under host directory `root`, by `/`-separated path
130 +
/// relative to it, sorted. Links and anything that is not a file or a
131 +
/// directory are left out, never followed. Within the report limits, and
132 +
/// `report.toml` must be at the top.
133 +
pub fn read_tree(root: &Path) -> Result<Vec<(String, Vec<u8>)>> {
134 +
    let mut out: Vec<(String, Vec<u8>)> = Vec::new();
135 +
    let mut total = 0u64;
136 +
    let mut stack = vec![(root.to_path_buf(), String::new())];
137 +
    while let Some((dir, rel)) = stack.pop() {
138 +
        for entry in std::fs::read_dir(&dir).with_context(|| format!("read {}", dir.display()))? {
139 +
            let entry = entry?;
140 +
            let name = entry.file_name().into_string().map_err(|_| anyhow::anyhow!("a report file name is not UTF-8"))?;
141 +
            let path = if rel.is_empty() { name.clone() } else { format!("{rel}/{name}") };
142 +
            if !report_name_ok(&name) {
143 +
                bail!("report file {path:?}: names may hold letters, digits, '.', '_', '-' and '+'");
144 +
            }
145 +
            let meta = std::fs::symlink_metadata(entry.path())?;
146 +
            if meta.is_dir() {
147 +
                stack.push((entry.path(), path));
148 +
            } else if meta.is_file() {
149 +
                if meta.len() > MAX_ARTIFACT_BYTES {
150 +
                    bail!("report file {path} is {} MiB; a file is at most {} MiB", meta.len() >> 20, MAX_ARTIFACT_BYTES >> 20);
151 +
                }
152 +
                total += meta.len();
153 +
                if total > MAX_REPORT_BYTES {
154 +
                    bail!("the report holds more than {} MiB", MAX_REPORT_BYTES >> 20);
155 +
                }
156 +
                if out.len() == MAX_REPORT_FILES {
157 +
                    bail!("the report holds more than {MAX_REPORT_FILES} files");
158 +
                }
159 +
                out.push((path, std::fs::read(entry.path())?));
160 +
            }
161 +
        }
162 +
    }
163 +
    if !out.iter().any(|(p, _)| p == "report.toml") {
164 +
        bail!("the report directory has no report.toml at its top");
165 +
    }
166 +
    out.sort();
167 +
    Ok(out)
168 +
}
169 +
83 170
fn debugfs(image: &Path, request: &str) -> Result<String> {
84 171
    let out = std::process::Command::new("debugfs").arg("-R").arg(request).arg(image).output().context("run debugfs (e2fsprogs)")?;
85 172
    if !out.status.success() {
…
85 unchanged lines
171 258
    }
172 259
173 260
    #[test]
261 +
    fn reports_are_read_as_trees_without_following_links() {
262 +
        let dir = tempfile::tempdir().unwrap();
263 +
        let tree = dir.path().join("tree");
264 +
        let report = tree.join("out/report");
265 +
        std::fs::create_dir_all(report.join("data")).unwrap();
266 +
        std::fs::write(report.join("report.toml"), b"kind = \"custom\"\n").unwrap();
267 +
        std::fs::write(report.join("index.html"), b"<p>hi</p>").unwrap();
268 +
        std::fs::write(report.join("data/frames.csv"), b"1,2\n").unwrap();
269 +
        std::fs::write(dir.path().join("secret"), b"runner key").unwrap();
270 +
        std::os::unix::fs::symlink(dir.path().join("secret"), report.join("data/leak")).unwrap();
271 +
        std::os::unix::fs::symlink(dir.path(), report.join("up")).unwrap();
272 +
        let files = read_report_dir(&tree, "/out/report").unwrap();
273 +
        let paths: Vec<&str> = files.iter().map(|(p, _)| p.as_str()).collect();
274 +
        assert_eq!(paths, ["data/frames.csv", "index.html", "report.toml"], "sorted, links left out");
275 +
        assert_eq!(files[0].1, b"1,2\n");
276 +
277 +
        std::os::unix::fs::symlink(&report, tree.join("linked")).unwrap();
278 +
        assert!(read_report_dir(&tree, "/linked").is_err(), "a linked report directory");
279 +
        assert!(read_report_dir(&tree, "/out/../out/report").is_err());
280 +
        assert!(read_report_dir(&tree, "/out/report/index.html").is_err(), "a file");
281 +
282 +
        std::fs::remove_file(report.join("report.toml")).unwrap();
283 +
        assert!(format!("{:#}", read_tree(&report).unwrap_err()).contains("report.toml"));
284 +
        std::fs::write(report.join("report.toml"), b"").unwrap();
285 +
        std::fs::write(report.join("bad name"), b"").unwrap();
286 +
        assert!(read_tree(&report).is_err(), "a name that cannot travel as is");
287 +
    }
288 +
289 +
    #[test]
174 290
    fn files_are_read_back_from_an_ext4_image() {
175 291
        let dir = tempfile::tempdir().unwrap();
176 292
        let tree = dir.path().join("tree");
…
6 unchanged lines
183 299
        assert_eq!(read(&image, "/release/tool", dir.path()).unwrap(), body);
184 300
        assert!(read(&image, "/release/missing", dir.path()).is_err());
185 301
        assert!(read(&image, "/release/link", dir.path()).unwrap_err().to_string().contains("not a regular file"));
302 +
303 +
        std::fs::create_dir_all(tree.join("report/data")).unwrap();
304 +
        std::fs::write(tree.join("report/report.toml"), b"kind = \"custom\"\n").unwrap();
305 +
        std::fs::write(tree.join("report/data/a.csv"), &body).unwrap();
306 +
        std::os::unix::fs::symlink("/etc/passwd", tree.join("report/data/leak")).unwrap();
307 +
        crate::workspace::image(&tree, &image).unwrap();
308 +
        let files = read_report(&image, "/report", dir.path()).unwrap();
309 +
        assert_eq!(files.iter().map(|(p, _)| p.as_str()).collect::<Vec<_>>(), ["data/a.csv", "report.toml"]);
310 +
        assert_eq!(files[0].1, body);
311 +
        assert!(read_report(&image, "/release/tool", dir.path()).is_err(), "a file is not a report");
186 312
    }
187 313
}
modified crates/flow/src/runner.rs +81 −0
…
122 unchanged lines
123 123
        }
124 124
    }
125 125
126 +
    /// Upload file `path` of report `name` of job `job` (oxygenhub#127);
127 +
    /// the hub keeps it as a blob and answers with its ObjectRef. The
128 +
    /// report's manifest follows through [`Hub::artifact`].
129 +
    pub fn report_file(&self, job: i64, name: &str, path: &str, bytes: &[u8]) -> Result<String> {
130 +
        let url = format!("{}/api/v1/flow/jobs/{job}/reports/{name}/files", self.base);
131 +
        let mut delays = RETRY_DELAYS.iter();
132 +
        loop {
133 +
            let req = self
134 +
                .agent
135 +
                .post(&url)
136 +
                .timeout(Duration::from_secs(600))
137 +
                .set("Authorization", &format!("Bearer {}", self.token))
138 +
                .set("Content-Type", "application/octet-stream")
139 +
                .set("X-Flow-Report-Path", path);
140 +
            match req.send_bytes(bytes) {
141 +
                Ok(r) => {
142 +
                    let v: serde_json::Value = r.into_json()?;
143 +
                    return v["blob"].as_str().map(String::from).context("the hub named no blob");
144 +
                }
145 +
                Err(ureq::Error::Status(code, r)) => {
146 +
                    let text = r.into_string().unwrap_or_default();
147 +
                    match delays.next() {
148 +
                        Some(d) if code >= 500 => std::thread::sleep(*d),
149 +
                        _ => bail!("{url}: HTTP {code}: {text}"),
150 +
                    }
151 +
                }
152 +
                Err(e) => bail!("{url}: {e}"),
153 +
            }
154 +
        }
155 +
    }
156 +
126 157
    /// Report a job's end until the hub has it (oxygenhub#71): while the hub
127 158
    /// cannot be reached or fails (5xx), try again after `first_wait`,
128 159
    /// doubling to a minute, for up to `patience`. A refusal (4xx: the job was ended
…
439 unchanged lines
568 599
        let passed = outcome.exit_code == Some(0);
569 600
        let wanted = claim.job.outputs.iter().filter(|o| passed || o.always);
570 601
        for o in wanted {
602 +
            if o.kind == flow_core::OutputKind::Report {
603 +
                let read = if syscall_tier {
604 +
                    let (dir, inner) = crate::outputs::locate_dir(&claim.job, &scratch.join("tree"), &scratch, &o.path);
605 +
                    crate::outputs::read_report_dir(&dir, &inner)
606 +
                } else {
607 +
                    let (image, inner) = crate::outputs::locate(&claim.job, &scratch.join("workspace.ext4"), &scratch, &o.path);
608 +
                    crate::outputs::read_report(&image, &inner, &scratch)
609 +
                };
610 +
                let kept = read.and_then(|files| upload_report(hub, claim.job_id, &o.name, &files));
611 +
                match kept {
612 +
                    Ok((blob, files, bytes)) => {
613 +
                        let unattested = if passed { "" } else { " (the job failed: not in its receipt)" };
614 +
                        let hex = blob.to_hex();
615 +
                        note(&mut sink, format!("report {}: {files} files, {bytes} bytes, manifest {}{unattested}", o.name, &hex[..12]));
616 +
                        if passed {
617 +
                            outputs.push(blob.0);
618 +
                        }
619 +
                    }
620 +
                    Err(e) if passed => {
621 +
                        output_error = Some(format!("report {}: {e:#}", o.name));
622 +
                        break;
623 +
                    }
624 +
                    Err(e) => note(&mut sink, format!("report {}: not kept: {e:#}", o.name)),
625 +
                }
626 +
                continue;
627 +
            }
571 628
            let read = if syscall_tier {
572 629
                let (dir, inner) = crate::outputs::locate_dir(&claim.job, &scratch.join("tree"), &scratch, &o.path);
573 630
                crate::outputs::read_dir_file(&dir, &inner)
…
54 unchanged lines
628 685
/// Bind `receipt` to the state of `heads` in `repo` and sign it as the
629 686
/// runner (Oxygen spec §14.6). The state is computed here, not taken from
630 687
/// the claim, so the runner vouches only for what it fetched.
688 +
/// Upload a report's files, then its manifest under the report's name, and
689 +
/// check the hub kept each as the blob it should (oxygenhub#127). Answers
690 +
/// the manifest's blob, the file count and their total size.
691 +
fn upload_report(hub: &Hub, job: i64, name: &str, files: &[(String, Vec<u8>)]) -> Result<(oxy_core::hash::ObjectRef, usize, u64)> {
692 +
    let mut listed = Vec::with_capacity(files.len());
693 +
    let mut total = 0u64;
694 +
    for (path, bytes) in files {
695 +
        let blob = Repo::blob_ref(bytes).to_hex();
696 +
        let named = hub.report_file(job, name, path, bytes).with_context(|| format!("uploading {path}"))?;
697 +
        if named != blob {
698 +
            bail!("the hub kept {path} as {named}, not {blob}");
699 +
        }
700 +
        total += bytes.len() as u64;
701 +
        listed.push(serde_json::json!({ "path": path, "blob": blob, "size": bytes.len() }));
702 +
    }
703 +
    let manifest = serde_json::to_vec(&serde_json::json!({ "version": 1, "files": listed }))?;
704 +
    let blob = Repo::blob_ref(&manifest);
705 +
    let named = hub.artifact(job, name, &manifest)?;
706 +
    if named != blob.to_hex() {
707 +
        bail!("the hub kept the manifest as {named}, not {}", blob.to_hex());
708 +
    }
709 +
    Ok((blob, files.len(), total))
710 +
}
711 +
631 712
fn sign_receipt(
632 713
    repo: &Repo,
633 714
    heads: &[ChangeHash],
…
301 unchanged lines
modified docs/spec.md +20 −0
…
248 unchanged lines
249 249
}
250 250
```
251 251
252 +
5. A `report` output (OxygenHub `docs/reports.md`) names a directory
253 +
   holding `report.toml` at its top. The runner reads its regular files
254 +
   (links and anything else are left out, never followed; file names hold
255 +
   letters, digits, `.`, `_`, `-` and `+`; at most 2000 files and 512 MiB),
256 +
   uploads each to `POST /api/v1/flow/jobs/{job}/reports/{name}/files` with
257 +
   its path in `X-Flow-Report-Path`, then uploads a manifest,
258 +
   `{"version":1,"files":[{"path","blob","size"}]}`, as the output under
259 +
   the report's name, checking every blob the hub answers. The receipt
260 +
   names the manifest. Reports and artifacts share names; a report's kind
261 +
   is part of the job's hash (`[name, path, "report"]`).
262 +
263 +
```kdl
264 +
output {
265 +
    report "frame-timing" {
266 +
        path "/src/out/report"
267 +
        when "always"
268 +
    }
269 +
}
270 +
```
271 +
252 272
## 6. Commutative Execution & Change Caching
253 273
254 274
In Oxygen, changes that do not share scopes (referenced atoms, placements, or
…
282 unchanged lines