OXYGENHUB

The cheap checks run as WASM: layout, todos, size and summary are one WASI module (crates/flow-checks, .flow/checks.wasm), so any runner with the WASM tier takes them; the fmt job checks the module is what its source builds; the build image gains wasm32-wasip1 (re-pinned e52c41a1) (closes #96) 5544c8998aa7

Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key cfed2e3c1b56
When
· 2026-10-27 18:04 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
5544c8998aa73ea93413907732d3518c2704cab516a53b9e4b2698e87cf348b6
Depends on
411ef051b94d 59078d2b8d46 85ad9087a566 9296a15494d3 c3db750ed96e da5118d25db2 e0918b887850 eb5c4db673e5 f29b88af0d9b f849c7889471 fe128fa67a06
Depended on by
e3fb0e9b5e6b 0aac9efcbeea 412808f9f769 48ed8a1db369 0d663e91c598 e4260689c506
In bookmarks
main
In tags
v0.5.1 v0.5.2 v0.5.3
Edits
22 atoms, 10 tree operations, 14 files

Browse the tree at this change

added .flow/checks.wasm +0 −0

No line changes.

added crates/flow-checks/Cargo.toml +16 −0
1 +
[package]
2 +
name = "flow-checks"
3 +
version.workspace = true
4 +
edition.workspace = true
5 +
license.workspace = true
6 +
description = "OxygenFlow's cheap checks as one WASI module, so any runner with the WASM tier can take them (#96)"
7 +
8 +
# Built for wasm32-wasip1 by scripts/checks-wasm.sh and committed as
9 +
# .flow/checks.wasm; std only, so the build is the same on any machine with
10 +
# the same toolchain.
11 +
[[bin]]
12 +
name = "flow-checks"
13 +
path = "src/main.rs"
14 +
15 +
[dev-dependencies]
16 +
tempfile.workspace = true
added crates/flow-checks/src/main.rs +222 −0
1 +
//! OxygenFlow's cheap checks as one WASI module (#96): what
2 +
//! `.flow/jobs/{layout,todos,size,summary}.sh` did in an Alpine microVM, so
3 +
//! that any runner offering the WASM tier (a phone, plex, windows-1) can take
4 +
//! them. The job's files are preopened as `/src`, which is also the current
5 +
//! directory, so every path here is relative to the workspace.
6 +
//!
7 +
//! - `layout PATH...`: each path is a file; fails naming the first missing.
8 +
//! - `todos DIR...`: the lines holding a note marker, counted, the first 20
9 +
//!   listed; never fails.
10 +
//! - `size DIR`: lines of Rust in each crate directory under DIR.
11 +
//! - `summary`: one line naming `$PROJECT`.
12 +
13 +
use std::fs;
14 +
use std::io::{self, Write};
15 +
use std::path::{Path, PathBuf};
16 +
use std::process::ExitCode;
17 +
18 +
/// The note markers, spelt in pieces so this file does not count itself.
19 +
const MARKERS: [&str; 3] = [concat!("TO", "DO"), concat!("FIX", "ME"), concat!("X", "XX")];
20 +
21 +
/// How many noted lines `todos` lists.
22 +
const LISTED: usize = 20;
23 +
24 +
const USAGE: &str = "usage: flow-checks layout PATH... | todos DIR... | size DIR | summary";
25 +
26 +
fn main() -> ExitCode {
27 +
    let args: Vec<String> = std::env::args().skip(1).collect();
28 +
    let root = Path::new(".");
29 +
    let mut out = io::stdout().lock();
30 +
    let done = match args.split_first() {
31 +
        Some((cmd, rest)) => match cmd.as_str() {
32 +
            "layout" => layout(root, rest, &mut out),
33 +
            "todos" => todos(root, rest, &mut out),
34 +
            "size" => match rest {
35 +
                [dir] => size(root, dir, &mut out),
36 +
                _ => Err(USAGE.to_string()),
37 +
            },
38 +
            "summary" => summary(std::env::var("PROJECT").ok().as_deref(), &mut out),
39 +
            _ => Err(USAGE.to_string()),
40 +
        },
41 +
        None => Err(USAGE.to_string()),
42 +
    };
43 +
    match done {
44 +
        Ok(()) => ExitCode::SUCCESS,
45 +
        Err(e) => {
46 +
            eprintln!("{e}");
47 +
            ExitCode::FAILURE
48 +
        }
49 +
    }
50 +
}
51 +
52 +
type Done = Result<(), String>;
53 +
54 +
fn emit(out: &mut impl Write, line: &str) -> Done {
55 +
    writeln!(out, "{line}").map_err(|e| format!("write: {e}"))
56 +
}
57 +
58 +
/// The workspace has the files it should.
59 +
fn layout(root: &Path, paths: &[String], out: &mut impl Write) -> Done {
60 +
    if paths.is_empty() {
61 +
        return Err(USAGE.to_string());
62 +
    }
63 +
    for p in paths {
64 +
        if !root.join(p).is_file() {
65 +
            return Err(format!("missing: {p}"));
66 +
        }
67 +
        emit(out, &format!("found {p}"))?;
68 +
    }
69 +
    Ok(())
70 +
}
71 +
72 +
/// Notes left in the code, for the log.
73 +
fn todos(root: &Path, dirs: &[String], out: &mut impl Write) -> Done {
74 +
    if dirs.is_empty() {
75 +
        return Err(USAGE.to_string());
76 +
    }
77 +
    let mut noted = Vec::new();
78 +
    for d in dirs {
79 +
        for file in files(&root.join(d))? {
80 +
            // Text only, as grep reports a binary file rather than its lines.
81 +
            let Ok(text) = fs::read_to_string(&file) else { continue };
82 +
            let name = shown(root, &file);
83 +
            for (i, line) in text.lines().enumerate() {
84 +
                if MARKERS.iter().any(|m| line.contains(m)) {
85 +
                    noted.push(format!("{name}:{}:{line}", i + 1));
86 +
                }
87 +
            }
88 +
        }
89 +
    }
90 +
    emit(out, &format!("{} {} notes in {}", noted.len(), MARKERS.join("/"), shown_dirs(dirs)))?;
91 +
    for line in noted.iter().take(LISTED) {
92 +
        emit(out, line)?;
93 +
    }
94 +
    Ok(())
95 +
}
96 +
97 +
/// Lines of Rust per crate: each directory directly under `dir`.
98 +
fn size(root: &Path, dir: &str, out: &mut impl Write) -> Done {
99 +
    for krate in entries(&root.join(dir))?.into_iter().filter(|p| p.is_dir()) {
100 +
        let mut lines = 0;
101 +
        for file in files(&krate)?.into_iter().filter(|f| f.extension().is_some_and(|e| e == "rs")) {
102 +
            let bytes = fs::read(&file).map_err(|e| format!("{}: {e}", file.display()))?;
103 +
            lines += bytes.iter().filter(|&&b| b == b'\n').count();
104 +
        }
105 +
        emit(out, &format!("{:<24} {lines:>6} lines", format!("{}/", shown(root, &krate))))?;
106 +
    }
107 +
    Ok(())
108 +
}
109 +
110 +
fn summary(project: Option<&str>, out: &mut impl Write) -> Done {
111 +
    emit(out, &format!("{}: every check passed on this state.", project.unwrap_or("This project")))
112 +
}
113 +
114 +
/// A directory's entries, sorted by name so every runner prints the same.
115 +
fn entries(dir: &Path) -> Result<Vec<PathBuf>, String> {
116 +
    let read = fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
117 +
    let mut all = read.map(|e| e.map(|e| e.path())).collect::<Result<Vec<_>, _>>().map_err(|e| format!("{}: {e}", dir.display()))?;
118 +
    all.sort();
119 +
    Ok(all)
120 +
}
121 +
122 +
/// Every file under `dir`, depth first, in name order; links are not followed.
123 +
fn files(dir: &Path) -> Result<Vec<PathBuf>, String> {
124 +
    let mut found = Vec::new();
125 +
    for p in entries(dir)? {
126 +
        let kind = fs::symlink_metadata(&p).map_err(|e| format!("{}: {e}", p.display()))?.file_type();
127 +
        if kind.is_dir() {
128 +
            found.extend(files(&p)?);
129 +
        } else if kind.is_file() {
130 +
            found.push(p);
131 +
        }
132 +
    }
133 +
    Ok(found)
134 +
}
135 +
136 +
/// A path as the workspace names it, with `/` between its parts.
137 +
fn shown(root: &Path, p: &Path) -> String {
138 +
    let rel = p.strip_prefix(root).unwrap_or(p);
139 +
    rel.components().map(|c| c.as_os_str().to_string_lossy()).collect::<Vec<_>>().join("/")
140 +
}
141 +
142 +
fn shown_dirs(dirs: &[String]) -> String {
143 +
    dirs.iter().map(|d| format!("{}/", d.trim_end_matches('/'))).collect::<Vec<_>>().join(" ")
144 +
}
145 +
146 +
#[cfg(test)]
147 +
mod tests {
148 +
    use super::*;
149 +
150 +
    fn tree(files: &[(&str, &str)]) -> tempfile::TempDir {
151 +
        let dir = tempfile::tempdir().unwrap();
152 +
        for (path, text) in files {
153 +
            let p = dir.path().join(path);
154 +
            fs::create_dir_all(p.parent().unwrap()).unwrap();
155 +
            fs::write(p, text).unwrap();
156 +
        }
157 +
        dir
158 +
    }
159 +
160 +
    fn run(f: impl FnOnce(&mut Vec<u8>) -> Done) -> (Done, String) {
161 +
        let mut out = Vec::new();
162 +
        let done = f(&mut out);
163 +
        (done, String::from_utf8(out).unwrap())
164 +
    }
165 +
166 +
    fn args(a: &[&str]) -> Vec<String> {
167 +
        a.iter().map(|s| s.to_string()).collect()
168 +
    }
169 +
170 +
    #[test]
171 +
    fn layout_lists_what_it_found_and_fails_on_the_first_missing() {
172 +
        let t = tree(&[("Cargo.toml", ""), ("docs/m0.md", "")]);
173 +
        let (done, out) = run(|o| layout(t.path(), &args(&["Cargo.toml", "docs/m0.md"]), o));
174 +
        assert_eq!(done, Ok(()));
175 +
        assert_eq!(out, "found Cargo.toml\nfound docs/m0.md\n");
176 +
        let (done, out) = run(|o| layout(t.path(), &args(&["Cargo.toml", "nope.rs", "docs/m0.md"]), o));
177 +
        assert_eq!(done, Err("missing: nope.rs".to_string()));
178 +
        assert_eq!(out, "found Cargo.toml\n");
179 +
        // A directory is not the file asked for.
180 +
        assert!(layout(t.path(), &args(&["docs"]), &mut Vec::new()).is_err());
181 +
    }
182 +
183 +
    #[test]
184 +
    fn todos_counts_every_marked_line_and_lists_the_first_twenty() {
185 +
        let marker = MARKERS[0];
186 +
        let many: String = (0..25).map(|i| format!("// {marker} {i}\n")).collect();
187 +
        let t = tree(&[
188 +
            ("crates/a/src/lib.rs", &format!("fn a() {{}}\n// {} later\n", MARKERS[1])),
189 +
            ("crates/b/src/many.rs", &many),
190 +
            ("crates/b/blob.bin", "\u{0}"),
191 +
        ]);
192 +
        // Invalid UTF-8 is skipped, as grep skips a binary file's lines.
193 +
        fs::write(t.path().join("crates/b/blob.bin"), [0xff, 0xfe, b'T']).unwrap();
194 +
        let (done, out) = run(|o| todos(t.path(), &args(&["crates"]), o));
195 +
        assert_eq!(done, Ok(()));
196 +
        let lines: Vec<&str> = out.lines().collect();
197 +
        assert_eq!(lines[0], format!("26 {} notes in crates/", MARKERS.join("/")));
198 +
        assert_eq!(lines[1], format!("crates/a/src/lib.rs:2:// {} later", MARKERS[1]));
199 +
        assert_eq!(lines[2], format!("crates/b/src/many.rs:1:// {marker} 0"));
200 +
        assert_eq!(lines.len(), 1 + LISTED);
201 +
    }
202 +
203 +
    #[test]
204 +
    fn size_counts_rust_lines_per_crate_in_name_order() {
205 +
        let t = tree(&[
206 +
            ("crates/zeta/src/main.rs", "a\nb\nc\n"),
207 +
            ("crates/alpha/src/lib.rs", "a\n"),
208 +
            ("crates/alpha/src/deep/mod.rs", "a\nb\n"),
209 +
            ("crates/alpha/README.md", "not rust\n"),
210 +
            ("crates/loose.txt", "a file, not a crate\n"),
211 +
        ]);
212 +
        let (done, out) = run(|o| size(t.path(), "crates", o));
213 +
        assert_eq!(done, Ok(()));
214 +
        assert_eq!(out, format!("{:<24} {:>6} lines\n{:<24} {:>6} lines\n", "crates/alpha/", 3, "crates/zeta/", 3));
215 +
    }
216 +
217 +
    #[test]
218 +
    fn summary_names_the_project() {
219 +
        let (_, out) = run(|o| summary(Some("OxygenFlow"), o));
220 +
        assert_eq!(out, "OxygenFlow: every check passed on this state.\n");
221 +
    }
222 +
}
added scripts/checks-wasm.sh +31 −0
1 +
#!/bin/sh
2 +
# Build the cheap checks (crates/flow-checks, #96) as a WASI module and keep
3 +
# it at .flow/checks.wasm, where the pipeline's WASM jobs run it from.
4 +
#
5 +
#   scripts/checks-wasm.sh          rebuild .flow/checks.wasm
6 +
#   scripts/checks-wasm.sh --check  fail unless .flow/checks.wasm is what
7 +
#                                   the source builds (the fmt job runs this)
8 +
#
9 +
# The build is the same on any machine with the same toolchain (the build
10 +
# image's, images/rust): the crate is std only, the workspace path is
11 +
# remapped to /src, and the module carries no debug info.
12 +
# Needs: rustup target wasm32-wasip1.
13 +
set -eu
14 +
cd "$(dirname "$0")/.."
15 +
target=$(mktemp -d)
16 +
trap 'rm -rf "$target"' EXIT
17 +
RUSTFLAGS="--remap-path-prefix=$(pwd)=/src" \
18 +
CARGO_PROFILE_RELEASE_DEBUG=0 CARGO_PROFILE_RELEASE_STRIP=true CARGO_INCREMENTAL=0 \
19 +
    cargo build -q --release --locked --target wasm32-wasip1 -p flow-checks --target-dir "$target"
20 +
built="$target/wasm32-wasip1/release/flow-checks.wasm"
21 +
if [ "${1:-}" = --check ]; then
22 +
    if cmp -s "$built" .flow/checks.wasm; then
23 +
        echo ".flow/checks.wasm is what crates/flow-checks builds ($(rustc --version))"
24 +
    else
25 +
        echo ".flow/checks.wasm differs from what crates/flow-checks builds: run scripts/checks-wasm.sh and commit it" >&2
26 +
        exit 1
27 +
    fi
28 +
else
29 +
    cp "$built" .flow/checks.wasm
30 +
    echo "built .flow/checks.wasm ($(wc -c < .flow/checks.wasm) bytes, $(rustc --version))"
31 +
fi
removed .flow/jobs/layout.sh +0 −0
…
5 unchanged lines
removed .flow/jobs/size.sh +0 −0
…
5 unchanged lines
removed .flow/jobs/summary.sh +0 −0
…
1 unchanged lines
removed .flow/jobs/todos.sh +0 −0
…
4 unchanged lines
modified .flow/pipeline.kdl +24 −14
…
10 unchanged lines
11 11
    network "isolated"
12 12
}
13 13
14 +
// The cheap checks as one WASI module (#96): crates/flow-checks, built by
15 +
// scripts/checks-wasm.sh and committed as .flow/checks.wasm (the fmt job
16 +
// fails if it is not what the source builds). No image and no KVM, so any
17 +
// runner with the WASM tier takes them: the phone, plex, windows-1.
18 +
sandbox "wasm" {
19 +
    runtime "wasm"
20 +
    memory-limit "64MiB"
21 +
}
22 +
14 23
job "layout" {
15 -
    sandbox "alpine"
16 -
    exec "sh" "-eu" ".flow/jobs/layout.sh"
24 +
    sandbox "wasm"
25 +
    exec ".flow/checks.wasm" "layout" "Cargo.toml" "docs/m0.md" "crates/flow-core/src/pipeline.rs" "crates/flow-init/src/main.rs" "crates/flow/src/microvm.rs"
17 26
}
18 27
19 28
job "todos" {
20 -
    sandbox "alpine"
21 -
    scope "crates/**"
22 -
    exec "sh" "-eu" ".flow/jobs/todos.sh"
29 +
    sandbox "wasm"
30 +
    scope "crates/**" ".flow/checks.wasm"
31 +
    exec ".flow/checks.wasm" "todos" "crates"
23 32
}
24 33
25 34
job "size" {
26 -
    sandbox "alpine"
27 -
    scope "crates/**"
28 -
    exec "sh" "-eu" ".flow/jobs/size.sh"
35 +
    sandbox "wasm"
36 +
    scope "crates/**" ".flow/checks.wasm"
37 +
    exec ".flow/checks.wasm" "size" "crates"
29 38
}
30 39
31 40
job "isolation" {
…
5 unchanged lines
37 46
// vendored crates. oxygen comes from the pinned subrepo in deps/.
38 47
sandbox "rust" {
39 48
    runtime "microvm"
40 -
    rootfs "cas://blob/836461b613019f62399a960c61a25c482eb74e30054ff2cd44d5594c3305872b"
49 +
    rootfs "cas://blob/e52c41a173956b6bacc270eb81208a1b118b652412b84d15fa24708e6f8ec392"
41 50
    memory-limit "8GiB"
42 51
    cpu-limit 4
43 52
    network "loopback"
…
1 unchanged lines
45 54
46 55
sandbox "rust-small" {
47 56
    runtime "microvm"
48 -
    rootfs "cas://blob/836461b613019f62399a960c61a25c482eb74e30054ff2cd44d5594c3305872b"
57 +
    rootfs "cas://blob/e52c41a173956b6bacc270eb81208a1b118b652412b84d15fa24708e6f8ec392"
49 58
    memory-limit "1GiB"
50 59
    cpu-limit 1
51 60
    network "isolated"
…
1 unchanged lines
53 62
54 63
job "fmt" {
55 64
    sandbox "rust-small"
56 -
    // This workspace only: --all would reach the pinned oxygen in deps/.
57 -
    exec "cargo" "fmt" "--check" "-p" "flow" "-p" "flow-core" "-p" "flow-init" "-p" "flow-casfs"
65 +
    // This workspace only (--all would reach the pinned oxygen in deps/),
66 +
    // and the checks' committed WASI module is what its source builds (#96).
67 +
    exec "sh" "-euc" "cargo fmt --check -p flow -p flow-core -p flow-init -p flow-casfs -p flow-checks && sh scripts/checks-wasm.sh --check"
58 68
}
59 69
60 70
job "clippy" {
…
74 unchanged lines
135 145
}
136 146
137 147
job "summary" {
138 -
    sandbox "alpine"
148 +
    sandbox "wasm"
139 149
    depends-on "layout" "todos" "size" "isolation" "clippy" "test"
140 150
    env {
141 151
        PROJECT "OxygenFlow"
142 152
    }
143 -
    exec "sh" "-eu" ".flow/jobs/summary.sh"
153 +
    exec ".flow/checks.wasm" "summary"
144 154
}
modified CHANGELOG.md +2 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **The cheap checks run as WASM (#96):** `layout`, `todos`, `size` and `summary` are one WASI module, `crates/flow-checks`, built by `scripts/checks-wasm.sh` and committed as `.flow/checks.wasm`, so they need no image or microVM and any runner with the WASM tier takes them (the phone, plex, windows-1); each runs in under 100 ms. The fmt job fails unless the committed module is what the source builds, and the build image has the wasm32-wasip1 target for that.
8 +
7 9
## 0.5.0 (2026-10-27)
8 10
9 11
- **A job's report says where its time went (#35).** The finish report carries `phases` in milliseconds: `image` (fetching its image, when the runner lacked it), `prepare` (its workspace and disks), `run` (the sandbox from start to stop) and `finish` (outputs, caches and receipt), so a slow job shows whether it waited on an image, a fetch or itself.
…
116 unchanged lines
modified Cargo.lock +7 −0
…
853 unchanged lines
854 854
]
855 855
856 856
[[package]]
857 +
name = "flow-checks"
858 +
version = "0.5.0"
859 +
dependencies = [
860 +
 "tempfile",
861 +
]
862 +
863 +
[[package]]
857 864
name = "flow-core"
858 865
version = "0.5.0"
859 866
dependencies = [
…
2389 unchanged lines
modified Cargo.toml +1 −1
1 1
[workspace]
2 -
members = ["crates/flow-core", "crates/flow-init", "crates/flow", "crates/flow-casfs", "crates/jet"]
2 +
members = ["crates/flow-core", "crates/flow-init", "crates/flow", "crates/flow-casfs", "crates/jet", "crates/flow-checks"]
3 3
# oxygen as a pinned subrepo (oxy submodule): the state flow builds against.
4 4
exclude = ["deps"]
5 5
resolver = "2"
…
21 unchanged lines
modified images/rust/Dockerfile +3 −2
…
16 unchanged lines
17 17
# Release builds (oxygen#59, oxygenhub#85): each project's scripts/dist.sh
18 18
# cross-builds Linux x86_64, arm64 and riscv64, macOS and Windows, with
19 19
# cargo-zigbuild and zig (no Apple SDK needed) and MinGW, and packs .deb,
20 -
# .tar.gz and .zip. The rustup targets' standard libraries, zig checked
20 +
# .tar.gz and .zip; wasm32-wasip1 rebuilds the cheap checks' WASI module
21 +
# (scripts/checks-wasm.sh --check, #96). The rustup targets' standard libraries, zig checked
21 22
# against its published SHA-256, and cargo-zigbuild, all linked onto the
22 23
# guest's fixed PATH.
23 24
ARG ZIG_VERSION
24 25
ARG ZIG_SHA256
25 -
RUN rustup target add x86_64-pc-windows-gnu aarch64-unknown-linux-gnu riscv64gc-unknown-linux-gnu x86_64-apple-darwin aarch64-apple-darwin \
26 +
RUN rustup target add x86_64-pc-windows-gnu aarch64-unknown-linux-gnu riscv64gc-unknown-linux-gnu x86_64-apple-darwin aarch64-apple-darwin wasm32-wasip1 \
26 27
 && apt-get update \
27 28
 && apt-get install -y --no-install-recommends gcc-mingw-w64-x86-64 zip \
28 29
 && rm -rf /var/lib/apt/lists/*
…
58 unchanged lines
modified images/rust/README.md +1 −0
…
7 unchanged lines
8 8
- Coverage: llvm-tools and cargo-llvm-cov 0.9.1, so a job can write an LCOV report for the hub's Coverage tab.
9 9
- Test reports: cargo-nextest 0.9.148, so a job can write JUnit XML for the hub (with `cargo llvm-cov nextest`, both from one run).
10 10
- Release cross-building: the Windows, Linux arm64 and riscv64, and macOS targets, zig 0.16.0 (checked against its SHA-256), cargo-zigbuild and MinGW, with zip, so `scripts/dist.sh` runs in a job.
11 +
- WASI: the wasm32-wasip1 target, so the fmt job can check that `.flow/checks.wasm` is what `crates/flow-checks` builds (#96).
11 12
- Every crate that oxygen, oxygenhub and oxygenflow use, vendored in `/opt/vendor`.
12 13
13 14
OxygenFlow jobs have no network (`network "isolated"`). So cargo is pointed at the vendored crates by `/.cargo/config.toml`, which cargo reads for any directory under `/`, including `/src`. `CARGO_HOME` stays at its default, `$HOME/.cargo`, which is under `/tmp` and writable. The toolchain's binaries are linked into `/usr/local/bin`, because the guest's `PATH` is fixed and nothing at run time needs rustup.
…
41 unchanged lines