diff --git a/crates/aura-cli/src/research_docs.rs b/crates/aura-cli/src/research_docs.rs index a546062..4056abb 100644 --- a/crates/aura-cli/src/research_docs.rs +++ b/crates/aura-cli/src/research_docs.rs @@ -287,6 +287,9 @@ pub enum CampaignSub { /// Execute a stored campaign into a realized run-set (a .json file is /// register-then-run sugar; the canonical address is the content id). Run { target: String }, + /// List stored campaign realizations, or dump one campaign's records + /// (the bare store lines, not the run-emit wrapper). + Runs { campaign: Option, run: Option }, } /// The one authoring trap the referential tier can name outright: a ref that @@ -325,6 +328,63 @@ pub(crate) fn ref_fault_prose(f: &RefFault) -> String { } /// `aura campaign …` — the dispatch entry main.rs calls. +/// `aura campaign runs [CAMPAIGN-ID] [RUN]` (#206): the read-back for stored +/// realizations. Without an id: one scannable line per record. With an id +/// (optionally narrowed to one run counter): the BARE stored record line(s) +/// — the store form, never the `{"campaign_run": …}` run-emit wrapper (the +/// 0108 fieldtest F10 parsing trap). A lookup, not an action: an empty store +/// or unmatched id notes it and exits 0 (the `runs family` convention). +fn campaign_runs(campaign: Option<&str>, run: Option, env: &Env) -> Result<(), String> { + if env.provenance().is_none() { + let cwd = std::env::current_dir() + .map(|d| d.display().to_string()) + .unwrap_or_default(); + return Err(format!( + "campaign runs needs a project: the realization store lives under the \ + project runs root (no Aura.toml found up from {cwd})" + )); + } + let records = env.registry().load_campaign_runs().map_err(|e| e.to_string())?; + match campaign { + None => { + if records.is_empty() { + println!("no campaign runs recorded"); + return Ok(()); + } + for r in &records { + let signature: Vec<&str> = r + .cells + .first() + .map(|c| c.stages.iter().map(|s| s.block.as_str()).collect()) + .unwrap_or_default(); + println!( + "{} run {} — {} cell(s), {}", + r.campaign, + r.run, + r.cells.len(), + if signature.is_empty() { "no stages".to_string() } else { signature.join(" -> ") }, + ); + } + } + Some(id) => { + let matched: Vec<_> = records + .iter() + .filter(|r| r.campaign == id && run.is_none_or(|n| r.run == n)) + .collect(); + if matched.is_empty() { + println!("no campaign runs recorded for {id}"); + return Ok(()); + } + for r in matched { + // serde round-trip == the stored bytes (compact, declaration + // order, sparse fields skipped) — the bare store form. + println!("{}", serde_json::to_string(r).map_err(|e| e.to_string())?); + } + } + } + Ok(()) +} + pub fn campaign_cmd(cmd: CampaignCmd, env: &Env) { let result = match &cmd.sub { CampaignSub::Validate { file } => validate_campaign_file(file, env), @@ -334,6 +394,7 @@ pub fn campaign_cmd(cmd: CampaignCmd, env: &Env) { } CampaignSub::Register { file } => register_campaign(file, env), CampaignSub::Run { target } => crate::campaign_run::run_campaign(target, env), + CampaignSub::Runs { campaign, run } => campaign_runs(campaign.as_deref(), *run, env), }; if let Err(m) = result { eprintln!("aura: {m}"); diff --git a/crates/aura-cli/tests/research_docs.rs b/crates/aura-cli/tests/research_docs.rs index 8801a2c..84d991c 100644 --- a/crates/aura-cli/tests/research_docs.rs +++ b/crates/aura-cli/tests/research_docs.rs @@ -845,6 +845,61 @@ fn campaign_run_unknown_id_refuses() { ); } +/// Property: the recorded campaign realizations are READABLE BACK. A read-back +/// verb (`aura campaign runs`) both lists every stored campaign-run record on a +/// scannable line carrying its campaign id, and — addressed by that id — dumps +/// the BARE persisted store record (the `campaign_runs.jsonl` line form), NOT +/// the `{"campaign_run": …}` stdout wrapper the `campaign run` emit uses (F10, +/// fieldtest 0108). Today `runs` is an unrecognized campaign subcommand (clap +/// exit 2) — the feature is absent. +/// +/// Hermetic seed: one hand-written record line in the exact bare store form +/// serde emits (mirroring the aura-registry pre-widening parse line — +/// canonical field order, sparse-skip fields omitted — so a re-serialized dump +/// is byte-identical). Appending a real record needs a full campaign run over +/// data; hand-writing the store line is the only data-free path and pins the +/// bare-store-line contract exactly. +#[test] +fn campaign_runs_lists_and_dumps_the_bare_stored_record() { + let _fixture = project_lock(); + let dir = built_project(); + let runs_dir = dir.join("runs"); + std::fs::remove_dir_all(&runs_dir).ok(); + std::fs::create_dir_all(&runs_dir).expect("create runs dir"); + let _cleanup = ScratchGuard(vec![ScratchPath::Dir(runs_dir.clone())]); + + let campaign_id = "deadbeef".repeat(8); // a 64-hex content id + let record_line = format!( + r#"{{"campaign":"{campaign_id}","process":"beef","run":0,"seed":42,"cells":[{{"strategy":"3f9c","instrument":"GER40","window_ms":[1725148800000,1727740799999],"stages":[{{"block":"std::sweep","family_id":"sweep-0"}},{{"block":"std::gate","survivor_ordinals":[0,1,2,3]}}]}}]}}"# + ); + std::fs::write(runs_dir.join("campaign_runs.jsonl"), format!("{record_line}\n")) + .expect("seed the campaign-run store"); + + // LIST mode: one scannable line per stored record, carrying the campaign + // id (bare 64-hex or a prefix), exit 0. + let (list_out, list_code) = run_code_in(dir, &["campaign", "runs"]); + assert_eq!(list_code, Some(0), "list exits 0: {list_out}"); + assert!( + list_out.contains(&campaign_id[..8]), + "the list line carries the campaign id (bare or prefix): {list_out}" + ); + + // DUMP mode: addressed by the campaign content id, prints the BARE stored + // record line — the store form, NOT the `{{"campaign_run": …}}` run-emit + // wrapper (F10) — byte-identical to the seeded line, exit 0. + let (dump_out, dump_code) = run_code_in(dir, &["campaign", "runs", &campaign_id]); + assert_eq!(dump_code, Some(0), "dump exits 0: {dump_out}"); + assert!( + !dump_out.contains("\"campaign_run\""), + "dump prints the bare store record, not the run-emit wrapper (F10): {dump_out}" + ); + assert_eq!( + dump_out.trim(), + record_line, + "dump is byte-identical to the bare stored line (F10): {dump_out}" + ); +} + /// The v2 boundary, order side: `[sweep, monte_carlo, walk_forward]` is /// intrinsically valid — `process register` ACCEPTS it (asserted inside /// `register_process_doc`; the intrinsic-tier ground is the shipped