feat(cli): aura campaign runs — read-back for stored realizations (fieldtest 0108 F11)

The recorded bootstrap/generalization evidence is no longer write-only:
'aura campaign runs' lists every stored record (campaign id, run
counter, cell count, first cell's stage signature); 'aura campaign
runs <id> [run]' dumps the BARE stored line(s) — the store form,
byte-identical, never the {"campaign_run": ...} run-emit wrapper (the
F10 parsing trap). A lookup, not an action: empty/unmatched notes and
exits 0 (the runs-family convention); outside a project the campaign
family's project refusal.

RED-first (tdd-author handoff): hermetic seed of one hand-written
store line into the fixture project, list + byte-identical dump pinned,
observed failing on clap's unknown-subcommand exit 2.

Gates: workspace 1027/0, clippy -D warnings clean.

closes #206
This commit is contained in:
2026-07-04 02:22:36 +02:00
parent 891c40d64b
commit 0bb203a38f
2 changed files with 116 additions and 0 deletions
+61
View File
@@ -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<String>, run: Option<usize> },
}
/// 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<usize>, 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}");