feat(aura-cli): surviving-surface prose — scaffold template, concepts help, sweep-vehicle tests

Slice 5 of the #319 retirement. The scaffolder's project CLAUDE.md and the
CLI concepts help now teach only the surviving surface (exec over both
document classes, --override, graph introspect --params for discovery;
traces via exec --tap / presentation.persist_taps). Sweep vehicles moved
onto campaign documents driven by exec (project_new's starter quickstarts,
cli_broken_pipe — whose bare-sweep EPIPE probe was latently vacuous and now
streams a real 4-member campaign, project_sweep_campaign's real-data leg,
graph_construct's gang-axis parity pair as paired one-cell campaigns);
pure discovery sites became graph introspect --params, and the line-identity
test kept its literal --params expectations minus the sweep half. Help pins
rewritten; the scaffold template's in-src pin follows the new bytes.

refs #319
This commit is contained in:
2026-07-25 19:34:07 +02:00
parent 5dc8e03249
commit 24782caaec
8 changed files with 573 additions and 221 deletions
+123 -26
View File
@@ -5,47 +5,146 @@
//! handled cleanly, never a panic.
use std::io::Read;
use std::path::Path;
use std::process::{Command, Stdio};
mod common;
use common::{fresh_project_with_data, ScratchGuard, ScratchPath};
/// Path to the freshly-built `aura` binary (Cargo sets this env var for the test
/// crate; the binary is named `aura` in `Cargo.toml`).
const BIN: &str = env!("CARGO_BIN_EXE_aura");
/// A fresh, unique working directory so the spawned `aura sweep` writes its
/// `./runs/runs.jsonl` into a throwaway dir, never dirtying the repo. Unique
/// per test + per process; no external tempfile dependency (mirrors `cli_run.rs`).
fn temp_cwd(name: &str) -> std::path::PathBuf {
let dir = std::path::Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("aura-cli-{name}"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create temp cwd");
dir
fn run_code_in(dir: &Path, args: &[&str]) -> (String, Option<i32>) {
let out = Command::new(BIN).args(args).current_dir(dir).output().expect("binary runs");
let text = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
(text, out.status.code())
}
fn write_doc(dir: &Path, name: &str, text: &str) -> std::path::PathBuf {
let p = dir.join(name);
std::fs::write(&p, text).expect("write doc");
p
}
/// The minimal executable pipeline (one sweep stage) — copied verbatim from
/// `research_docs.rs`'s constant of the same name.
const SWEEP_ONLY_PROCESS_DOC: &str = r#"{
"format_version": 1,
"kind": "process",
"name": "sweep-only",
"pipeline": [ { "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" } ]
}"#;
/// Seed one blueprint (its params bound, reopened by the sweep axes per
/// #246) into the built demo project's store via a real sweep and return
/// its content id — copied verbatim from `research_docs.rs`'s recipe of the
/// same name.
fn seed_blueprint(dir: &Path, name: &str) -> String {
let closed_bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
let (out, code) = run_code_in(
dir,
&[
"sweep",
&closed_bp,
"--axis",
"fast.length=2,4",
"--axis",
"slow.length=8,16",
"--name",
name,
],
);
assert_eq!(code, Some(0), "seed sweep failed: {out}");
std::fs::read_dir(dir.join("runs").join("blueprints"))
.expect("blueprints dir")
.next()
.expect("one stored blueprint")
.expect("dir entry")
.path()
.file_stem()
.expect("stem")
.to_string_lossy()
.into_owned()
}
/// Register `doc` as a process document in the project store; returns its
/// id — copied verbatim from `research_docs.rs`'s recipe of the same name.
fn register_process_doc(dir: &Path, file: &str, doc: &str) -> String {
write_doc(dir, file, doc);
let (out, code) = run_code_in(dir, &["process", "register", file]);
assert_eq!(code, Some(0), "process register failed: {out}");
out.lines()
.find(|l| l.starts_with("registered process "))
.expect("register line")
.trim_start_matches("registered process ")
.split(' ')
.next()
.expect("id")
.trim_start_matches("content:")
.to_string()
}
/// A one-strategy, two-axis (2×2) campaign document over `bp_id`/`proc_id` —
/// copied verbatim from `exec.rs`'s `campaign_doc_json_for` of the same
/// shape, which yields exactly four family members (the four-line stream
/// this test's property needs).
fn campaign_doc_json_for(instrument: &str, bp_id: &str, proc_id: &str, window: (i64, i64)) -> String {
format!(
r#"{{
"format_version": 1,
"kind": "campaign",
"name": "broken-pipe",
"data": {{ "instruments": ["{instrument}"], "windows": [ {{ "from_ms": {from}, "to_ms": {to} }} ] }},
"strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }},
"axes": {{ "fast.length": {{ "kind": "I64", "values": [2, 4] }},
"slow.length": {{ "kind": "I64", "values": [8, 16] }} }} }} ],
"process": {{ "ref": {{ "content_id": "{proc_id}" }} }},
"seed": 7,
"presentation": {{ "persist_taps": [], "emit": ["family_table"] }}
}}"#,
from = window.0,
to = window.1,
)
}
/// Property: a family-emitting command whose stdout reader closes early exits on
/// the EPIPE cleanly — it does NOT panic (`failed printing to stdout: Broken
/// pipe`) and does NOT exit 101. `aura sweep` streams four family-member JSON
/// lines via `println!`; when the reader closes stdout after the first line, the
/// next write hits a closed pipe. Rust's runtime sets SIGPIPE to SIG_IGN at
/// startup, so that write returns EPIPE and `println!` panics rather than the
/// process terminating quietly — exactly the behaviour a CLI must not have. This
/// is the contract: any aura subcommand can be piped into `| head` / `| less` /
/// pipe`) and does NOT exit 101. `aura exec <campaign.json>` streams four
/// family-member JSON lines via `println!` (a 2×2 axis grid — #319: the
/// surviving surface for what a bare `aura sweep` demo grid used to stream);
/// when the reader closes stdout after the first line, the next write hits a
/// closed pipe. Rust's runtime sets SIGPIPE to SIG_IGN at startup, so that
/// write returns EPIPE and `println!` panics rather than the process
/// terminating quietly — exactly the behaviour a CLI must not have. This is
/// the contract: any aura subcommand can be piped into `| head` / `| less` /
/// a UI pane that goes away, and it must finish quietly, not panic.
///
/// Autonomous: constructs its own early-closing reader inline (no `head`, no
/// shell, no fixture files) by spawning the binary with a piped stdout and
/// dropping the read handle after a single short read, which closes the pipe's
/// read end and makes the writer's next write hit EPIPE.
#[test]
fn family_emitting_command_survives_early_reader_close() {
let cwd = temp_cwd("broken-pipe-sweep");
let (dir, _fixture) = fresh_project_with_data();
let runs_dir = dir.join("runs");
std::fs::remove_dir_all(&runs_dir).ok();
let _cleanup = ScratchGuard(vec![
ScratchPath::Dir(runs_dir),
ScratchPath::File(dir.join("broken-pipe.process.json")),
ScratchPath::File(dir.join("broken-pipe.campaign.json")),
]);
let bp_id = seed_blueprint(&dir, "broken-pipe-seed");
let proc_id = register_process_doc(&dir, "broken-pipe.process.json", SWEEP_ONLY_PROCESS_DOC);
let doc = campaign_doc_json_for("SYMA", &bp_id, &proc_id, (1709251200000, 1719791999999));
write_doc(&dir, "broken-pipe.campaign.json", &doc);
let mut child = Command::new(BIN)
.arg("sweep")
.current_dir(&cwd)
.args(["exec", "broken-pipe.campaign.json"])
.current_dir(&dir)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn aura sweep");
.expect("spawn aura exec");
// Read just enough to guarantee the writer is mid-stream, then drop the read
// handle. Dropping it closes the read end of the pipe; the next `println!`
@@ -63,9 +162,7 @@ fn family_emitting_command_survives_early_reader_close() {
if let Some(mut err) = child.stderr.take() {
let _ = err.read_to_string(&mut stderr);
}
let status = child.wait().expect("reap aura sweep");
let _ = std::fs::remove_dir_all(&cwd);
let status = child.wait().expect("reap aura exec");
// The defect: stderr carries the broken-pipe panic.
assert!(