From 792120163da05fe9ab8b7e33118cf97ef6eae11a Mon Sep 17 00:00:00 2001 From: noctem-o Date: Sun, 5 Jul 2026 15:39:57 +0000 Subject: [PATCH] Anchor status: read-only view of anchoring debt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cog anchor status --scan reports the anchoring state of every sealed bundle without acting: anchored, anchored with a stale marker, pending (recorded debt), or unanchored with no marker at all — the silent-debt class left by emission while anchoring was unconfigured. It also sweeps for stale and orphaned pending-anchor markers. Reconcile's read-only sibling by construction: same candidate scan, same per-kind request builders, same root shape gate, same chain-verified AnchorSet fold (a tampered log refuses to report). The status path opens no Anchorer, never creates a missing log (missing log is a loud error pointing at `cog anchor init`), and never writes or deletes markers — enforced by a full-tree byte-snapshot test. Debt is information, not failure: exit is zero once the scan completes; nonzero is reserved for operational errors. Gating on debt stays --require-anchor's future job. Ticket 0010; implemented by Codex (gpt-5.5), reviewed by Claude. Validation: cog-cli anchor tests 32/32, cargo fmt --check clean, cargo clippy --workspace --all-targets -D warnings clean, cargo tree -i magpie-log = cog-anchor sole direct dependent. Co-Authored-By: Claude Fable 5 --- crates/cog-cli/src/cli.rs | 10 + crates/cog-cli/src/commands/anchor.rs | 591 ++++++++++++++++++++++++++ crates/cog-cli/src/main.rs | 6 + 3 files changed, 607 insertions(+) diff --git a/crates/cog-cli/src/cli.rs b/crates/cog-cli/src/cli.rs index a970258..7a18045 100644 --- a/crates/cog-cli/src/cli.rs +++ b/crates/cog-cli/src/cli.rs @@ -110,6 +110,7 @@ pub(crate) enum Command { pub(crate) enum AnchorCommand { Init(AnchorInitArgs), Reconcile(AnchorReconcileArgs), + Status(AnchorStatusArgs), } #[derive(Parser, Debug)] pub(crate) struct AnchorInitArgs { @@ -127,6 +128,15 @@ pub(crate) struct AnchorReconcileArgs { #[arg(long)] pub scan: PathBuf, } +#[derive(Parser, Debug)] +pub(crate) struct AnchorStatusArgs { + #[arg(long = "anchor-key")] + pub anchor_key: Option, + #[arg(long = "anchor-log")] + pub anchor_log: Option, + #[arg(long)] + pub scan: PathBuf, +} #[derive(Subcommand, Debug)] pub(crate) enum AdapterCommand { diff --git a/crates/cog-cli/src/commands/anchor.rs b/crates/cog-cli/src/commands/anchor.rs index fecb592..8ed6c8a 100644 --- a/crates/cog-cli/src/commands/anchor.rs +++ b/crates/cog-cli/src/commands/anchor.rs @@ -96,6 +96,14 @@ struct RawAnchorReconcileArgs { unexpected_args: Vec, } +#[derive(Debug, Clone, Default, Eq, PartialEq)] +struct RawAnchorStatusArgs { + anchor_key: RawFlagScan, + anchor_log: RawFlagScan, + scan: RawFlagScan, + unexpected_args: Vec, +} + #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct AnchorConfigFile { @@ -356,6 +364,59 @@ pub(crate) fn anchor_reconcile_cmd(args: &[String]) -> Result<(), String> { } } +/// Report anchoring debt without mutating anything. +/// +/// Status is reconcile's read-only sibling: it reuses the same candidate scan, +/// manifest-to-request builders, root shape gate, and verified `AnchorSet` +/// projection, then reports observed state only. It never opens an `Anchorer`, +/// never creates a missing log, and never writes or removes pending markers. +/// +/// Debt is information, not failure: unanchored, stale-marker, orphan-marker, +/// and unparseable candidate reports still exit zero once the scan completes. +/// Operational errors remain nonzero: unconfigured paths, bad flags, missing +/// key, missing/unreadable/tampered log, or an unreadable scan tree. A missing +/// log is refused instead of treated as an empty estate because this command is +/// read-only and a wrong path should be loud. +pub(crate) fn anchor_status_cmd(args: &[String]) -> Result<(), String> { + let raw = raw_anchor_status_args(args); + if let Some(arg) = raw.unexpected_args.first() { + return Err(format!( + "anchor_status_unknown_arg: unsupported argument {arg}" + )); + } + let anchor_key = optional_anchor_status_arg(&raw.anchor_key, ANCHOR_KEY_FLAG)?; + let anchor_log = optional_anchor_status_arg(&raw.anchor_log, ANCHOR_LOG_FLAG)?; + let scan = required_anchor_status_arg(&raw.scan, ANCHOR_SCAN_FLAG)?; + let paths = match resolve_anchor_paths(anchor_key.as_deref(), anchor_log.as_deref()) { + AnchorPathResolution::Configured(paths) => paths, + AnchorPathResolution::Unconfigured => { + return Err(format!( + "anchor_status_not_configured: provide {ANCHOR_KEY_FLAG} and {ANCHOR_LOG_FLAG}, or {ANCHOR_CONFIG_FILE} with [anchor] signing_key_path and log_path" + )); + } + AnchorPathResolution::Failed(err) => { + return Err(format!("anchor_status_config_failed: {err}")) + } + }; + + let report = anchor_status_report(&paths, &scan)?; + for line in report.lines { + println!("{line}"); + } + println!( + "anchor_status_summary scanned={} anchored={} unanchored={} recorded_debt={} silent_debt={} stale_markers={} orphaned_markers={} unparseable={}", + report.summary.scanned, + report.summary.anchored, + report.summary.unanchored, + report.summary.recorded_debt, + report.summary.silent_debt, + report.summary.stale_markers, + report.summary.orphaned_markers, + report.summary.unparseable + ); + Ok(()) +} + pub(crate) fn resolve_anchor_paths( anchor_key: Option<&Path>, anchor_log: Option<&Path>, @@ -490,6 +551,10 @@ fn optional_anchor_reconcile_arg( } } +fn optional_anchor_status_arg(scan: &RawFlagScan, flag: &str) -> Result, String> { + optional_anchor_command_arg(scan, flag, "anchor_status") +} + fn required_anchor_reconcile_arg(scan: &RawFlagScan, flag: &str) -> Result { match scan.unique_value() { Ok(Some(value)) => Ok(PathBuf::from(value)), @@ -503,6 +568,19 @@ fn required_anchor_reconcile_arg(scan: &RawFlagScan, flag: &str) -> Result Result { + match scan.unique_value() { + Ok(Some(value)) => Ok(PathBuf::from(value)), + Ok(None) => Err(format!("anchor_status_arg_missing: {flag} is required")), + Err(RawFlagProblem::MissingValue) => Err(format!( + "anchor_status_arg_missing: {flag} requires a value" + )), + Err(RawFlagProblem::Duplicate) => Err(format!( + "anchor_status_arg_duplicate: {flag} must be supplied once" + )), + } +} + fn optional_anchor_command_arg( scan: &RawFlagScan, flag: &str, @@ -580,6 +658,19 @@ fn raw_anchor_reconcile_args(args: &[String]) -> RawAnchorReconcileArgs { } } +fn raw_anchor_status_args(args: &[String]) -> RawAnchorStatusArgs { + RawAnchorStatusArgs { + anchor_key: raw_anchor_flag(args, 3, ANCHOR_KEY_FLAG), + anchor_log: raw_anchor_flag(args, 3, ANCHOR_LOG_FLAG), + scan: raw_anchor_flag(args, 3, ANCHOR_SCAN_FLAG), + unexpected_args: raw_anchor_unexpected_args( + args, + 3, + &[ANCHOR_KEY_FLAG, ANCHOR_LOG_FLAG, ANCHOR_SCAN_FLAG], + ), + } +} + fn raw_anchor_flag(args: &[String], start: usize, name: &str) -> RawFlagScan { let mut scan = RawFlagScan::default(); let equals_prefix = format!("{name}="); @@ -647,6 +738,24 @@ struct AnchorReconcileSummary { skipped_unparseable: usize, } +#[derive(Debug, Clone, Default, Eq, PartialEq)] +struct AnchorStatusSummary { + scanned: usize, + anchored: usize, + unanchored: usize, + recorded_debt: usize, + silent_debt: usize, + stale_markers: usize, + orphaned_markers: usize, + unparseable: usize, +} + +#[derive(Debug, Clone, Eq, PartialEq)] +struct AnchorStatusReport { + lines: Vec, + summary: AnchorStatusSummary, +} + const RECONCILE_MANIFESTS: &[AnchorReconcileManifest] = &[ AnchorReconcileManifest { file_name: KERNEL_WITNESS_MANIFEST_FILE, @@ -706,6 +815,179 @@ fn present_reconcile_manifests(dir: &Path) -> Vec { .collect() } +fn anchor_status_report(paths: &AnchorPaths, scan: &Path) -> Result { + let verifying_key = read_verifying_key(&paths.key_path) + .map_err(|err| format!("anchor_status_key_failed: {err}"))?; + if !paths.log_path.exists() { + return Err(format!( + "anchor_status_log_missing: {} missing; run `cog anchor init`", + paths.log_path.display() + )); + } + let anchored = AnchorSet::from_log(&paths.log_path, verifying_key) + .map_err(|err| format!("anchor_status_log_replay_failed: {err}"))?; + let candidates = collect_reconcile_candidates(scan).map_err(anchor_status_scan_error)?; + let marker_paths = collect_anchor_status_markers(scan)?; + let candidate_dirs = candidates.iter().cloned().collect::>(); + let mut stale_marker_paths = BTreeSet::new(); + let mut orphan_marker_paths = BTreeSet::new(); + let mut lines = Vec::new(); + let mut summary = AnchorStatusSummary { + scanned: candidates.len(), + ..AnchorStatusSummary::default() + }; + + for dir in candidates { + let manifests = present_reconcile_manifests(&dir); + let [manifest] = manifests.as_slice() else { + summary.unparseable += 1; + lines.push(format!( + "anchor_status_skip path={} reason=ambiguous error=manifests={}", + dir.display(), + manifests + .iter() + .map(|manifest| manifest.file_name) + .collect::>() + .join(",") + )); + continue; + }; + + let request = match reconcile_anchor_request(&dir, manifest.kind) { + Ok(request) => request, + Err(err) => { + summary.unparseable += 1; + lines.push(format!( + "anchor_status_skip path={} kind={} reason=unparseable error={}", + dir.display(), + manifest.kind.label(), + err + )); + continue; + } + }; + + if !is_lower_hex_64(&request.witness_root) { + summary.unparseable += 1; + lines.push(format!( + "anchor_status_skip path={} kind={} reason=unparseable error=invalid_witness_root root={}", + dir.display(), + manifest.kind.label(), + request.witness_root + )); + continue; + } + + let marker_path = pending_anchor_marker_path(&dir); + let marker_present = marker_path.is_file(); + let state = if anchored.contains(&request.witness_root) { + summary.anchored += 1; + if marker_present { + summary.stale_markers += 1; + stale_marker_paths.insert(marker_path); + "anchored_stale_marker" + } else { + "anchored" + } + } else { + summary.unanchored += 1; + if marker_present { + summary.recorded_debt += 1; + "pending" + } else { + summary.silent_debt += 1; + "unanchored_silent" + } + }; + + lines.push(format!( + "anchor_status_bundle path={} kind={} root={} state={}", + dir.display(), + manifest.kind.label(), + request.witness_root, + state + )); + } + + for marker_path in marker_paths { + let Some(run_dir) = pending_anchor_marker_implied_run(&marker_path) else { + continue; + }; + if !candidate_dirs.contains(&run_dir) { + summary.orphaned_markers += 1; + orphan_marker_paths.insert(marker_path); + } + } + + let mut marker_reports = Vec::new(); + marker_reports.extend(stale_marker_paths.into_iter().map(|path| (path, "stale"))); + marker_reports.extend( + orphan_marker_paths + .into_iter() + .map(|path| (path, "orphaned")), + ); + marker_reports.sort_by(|left, right| left.0.cmp(&right.0).then(left.1.cmp(right.1))); + lines.extend(marker_reports.into_iter().map(|(path, reason)| { + format!( + "anchor_status_marker path={} reason={reason}", + path.display() + ) + })); + + Ok(AnchorStatusReport { lines, summary }) +} + +fn anchor_status_scan_error(err: String) -> String { + err.replacen( + "anchor_reconcile_scan_failed", + "anchor_status_scan_failed", + 1, + ) +} + +fn collect_anchor_status_markers(scan: &Path) -> Result, String> { + let mut markers = Vec::new(); + collect_anchor_status_markers_inner(scan, &mut markers)?; + markers.sort(); + Ok(markers) +} + +fn collect_anchor_status_markers_inner( + dir: &Path, + markers: &mut Vec, +) -> Result<(), String> { + let entries = std::fs::read_dir(dir) + .map_err(|err| format!("anchor_status_scan_failed: {}: {err}", dir.display()))? + .collect::, _>>() + .map_err(|err| format!("anchor_status_scan_failed: {}: {err}", dir.display()))?; + + for entry in entries { + let path = entry.path(); + let file_type = entry + .file_type() + .map_err(|err| format!("anchor_status_scan_failed: {}: {err}", path.display()))?; + if file_type.is_dir() { + collect_anchor_status_markers_inner(&path, markers)?; + } else if file_type.is_file() && pending_anchor_marker_implied_run(&path).is_some() { + markers.push(path); + } + } + Ok(()) +} + +fn pending_anchor_marker_implied_run(marker: &Path) -> Option { + let suffix = format!(".{PENDING_ANCHOR_FILE}"); + let file_name = marker.file_name()?.to_str()?; + let run_name = file_name.strip_suffix(&suffix)?; + if run_name.is_empty() { + return None; + } + // The degenerate `pending-anchor.json` fallback from + // `pending_anchor_marker_path` has no sibling run name and is intentionally + // ignored by this sweep. + Some(marker.parent()?.join(run_name)) +} + fn reconcile_anchor_request( run: &Path, kind: AnchorReconcileBundleKind, @@ -886,6 +1168,20 @@ mod tests { ] } + fn status_command(key: &Path, log: &Path, scan: &Path) -> Vec { + vec![ + "cog".into(), + "anchor".into(), + "status".into(), + ANCHOR_KEY_FLAG.into(), + key.display().to_string(), + ANCHOR_LOG_FLAG.into(), + log.display().to_string(), + ANCHOR_SCAN_FLAG.into(), + scan.display().to_string(), + ] + } + fn root(pair: &str) -> String { pair.repeat(32) } @@ -948,6 +1244,111 @@ mod tests { std::fs::write(run.join("witness_root.txt"), format!("{witness_root}\n")).unwrap(); } + struct StatusMixedEstate { + anchored_run: PathBuf, + anchored_root: String, + pending_run: PathBuf, + pending_root: String, + silent_run: PathBuf, + silent_root: String, + stale_run: PathBuf, + stale_root: String, + stale_marker: PathBuf, + orphan_marker: PathBuf, + } + + fn status_paths(key: &Path, log: &Path) -> AnchorPaths { + AnchorPaths { + key_path: key.to_path_buf(), + log_path: log.to_path_buf(), + } + } + + fn status_report(key: &Path, log: &Path, scan: &Path) -> AnchorStatusReport { + anchor_status_report(&status_paths(key, log), scan).unwrap() + } + + fn write_status_mixed_estate(key: &Path, log: &Path, scan: &Path) -> StatusMixedEstate { + generate_key_file(key).unwrap(); + let anchored_run = scan.join("a-anchored-observation"); + let pending_run = scan.join("b-pending-transaction"); + let silent_run = scan.join("c-silent-observation"); + let stale_run = scan.join("d-stale-transaction"); + let anchored_root = root("31"); + let pending_root = root("32"); + let silent_root = root("33"); + let stale_root = root("34"); + write_observation_bundle(&anchored_run, &anchored_root, "obs-status-anchored"); + write_transaction_bundle(&pending_run, &pending_root, "tx-status-pending"); + write_observation_bundle(&silent_run, &silent_root, "obs-status-silent"); + write_transaction_bundle(&stale_run, &stale_root, "tx-status-stale"); + + { + let mut anchorer = Anchorer::open(log, key).unwrap(); + anchorer + .anchor( + "test preanchor", + observation_anchor_request(&anchored_run).unwrap(), + ) + .unwrap(); + anchorer + .anchor( + "test preanchor", + transaction_anchor_request(&stale_run).unwrap(), + ) + .unwrap(); + } + + std::fs::write(pending_anchor_marker_path(&pending_run), "{}\n").unwrap(); + let stale_marker = pending_anchor_marker_path(&stale_run); + std::fs::write(&stale_marker, "{}\n").unwrap(); + let orphan_marker = scan.join("x.pending-anchor.json"); + std::fs::write(&orphan_marker, "{}\n").unwrap(); + + StatusMixedEstate { + anchored_run, + anchored_root, + pending_run, + pending_root, + silent_run, + silent_root, + stale_run, + stale_root, + stale_marker, + orphan_marker, + } + } + + fn recursive_file_snapshot( + root: &Path, + ) -> std::collections::BTreeMap>> { + let mut snapshot = std::collections::BTreeMap::new(); + recursive_file_snapshot_inner(root, root, &mut snapshot); + snapshot + } + + fn recursive_file_snapshot_inner( + root: &Path, + path: &Path, + snapshot: &mut std::collections::BTreeMap>>, + ) { + let relative = path.strip_prefix(root).unwrap().to_path_buf(); + let metadata = std::fs::metadata(path).unwrap(); + if metadata.is_dir() { + snapshot.insert(relative, None); + let mut entries = std::fs::read_dir(path) + .unwrap() + .collect::, _>>() + .unwrap(); + entries.sort_by_key(|entry| entry.path()); + for entry in entries { + recursive_file_snapshot_inner(root, &entry.path(), snapshot); + } + } else if metadata.is_file() { + snapshot.insert(relative, Some(std::fs::read(path).unwrap())); + } + } + /// Reconcile verifies candidates with the live verifiers, so tests that /// expect an anchor must feed it GENUINE sealed bundles, not hand-built /// manifests. Returns the bundle's witness root. @@ -1170,6 +1571,196 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } + #[test] + fn anchor_status_reports_mixed_estate_counts_without_acting() { + let _lock = crate::commands::support::CURRENT_DIR_TEST_LOCK + .lock() + .unwrap(); + let dir = temp_dir("status-mixed"); + let key = dir.join("anchor.key"); + let log = dir.join("anchor.jsonl"); + let scan = dir.join("scan"); + let estate = write_status_mixed_estate(&key, &log, &scan); + + let report = status_report(&key, &log, &scan); + + assert_eq!( + report.summary, + AnchorStatusSummary { + scanned: 4, + anchored: 2, + unanchored: 2, + recorded_debt: 1, + silent_debt: 1, + stale_markers: 1, + orphaned_markers: 1, + unparseable: 0, + } + ); + assert!(report.lines.contains(&format!( + "anchor_status_bundle path={} kind=observation root={} state=anchored", + estate.anchored_run.display(), + estate.anchored_root + ))); + assert!(report.lines.contains(&format!( + "anchor_status_bundle path={} kind=transaction root={} state=pending", + estate.pending_run.display(), + estate.pending_root + ))); + assert!(report.lines.contains(&format!( + "anchor_status_bundle path={} kind=observation root={} state=unanchored_silent", + estate.silent_run.display(), + estate.silent_root + ))); + assert!(report.lines.contains(&format!( + "anchor_status_bundle path={} kind=transaction root={} state=anchored_stale_marker", + estate.stale_run.display(), + estate.stale_root + ))); + assert!(report.lines.contains(&format!( + "anchor_status_marker path={} reason=stale", + estate.stale_marker.display() + ))); + assert!(report.lines.contains(&format!( + "anchor_status_marker path={} reason=orphaned", + estate.orphan_marker.display() + ))); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn anchor_status_is_read_only_over_log_bundles_and_markers() { + let _lock = crate::commands::support::CURRENT_DIR_TEST_LOCK + .lock() + .unwrap(); + let dir = temp_dir("status-read-only"); + let key = dir.join("anchor.key"); + let log = dir.join("anchor.jsonl"); + let scan = dir.join("scan"); + let estate = write_status_mixed_estate(&key, &log, &scan); + let before_log = std::fs::read(&log).unwrap(); + let before_scan = recursive_file_snapshot(&scan); + + let report = status_report(&key, &log, &scan); + + assert_eq!(report.summary.stale_markers, 1); + assert_eq!(std::fs::read(&log).unwrap(), before_log); + assert_eq!(recursive_file_snapshot(&scan), before_scan); + assert!(estate.stale_marker.exists()); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn anchor_status_refuses_tampered_log_without_reporting_or_writing() { + let _lock = crate::commands::support::CURRENT_DIR_TEST_LOCK + .lock() + .unwrap(); + let dir = temp_dir("status-tamper"); + let key = dir.join("anchor.key"); + let log = dir.join("anchor.jsonl"); + let scan = dir.join("scan"); + let estate = write_status_mixed_estate(&key, &log, &scan); + let mut log_text = std::fs::read_to_string(&log).unwrap(); + let offset = log_text.find(&estate.anchored_root).unwrap(); + log_text.replace_range(offset..offset + 1, "6"); + std::fs::write(&log, &log_text).unwrap(); + let before_scan = recursive_file_snapshot(&scan); + + let err = anchor_status_report(&status_paths(&key, &log), &scan).unwrap_err(); + + assert!(err.contains("anchor_status_log_replay_failed")); + assert_eq!(std::fs::read_to_string(&log).unwrap(), log_text); + assert_eq!(recursive_file_snapshot(&scan), before_scan); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn anchor_status_missing_log_is_error_and_does_not_create_it() { + let _lock = crate::commands::support::CURRENT_DIR_TEST_LOCK + .lock() + .unwrap(); + let dir = temp_dir("status-missing-log"); + let key = dir.join("anchor.key"); + let log = dir.join("missing-anchor.jsonl"); + let scan = dir.join("scan"); + generate_key_file(&key).unwrap(); + + let err = anchor_status_cmd(&status_command(&key, &log, &scan)).unwrap_err(); + + assert!(err.contains("anchor_status_log_missing")); + assert!(err.contains("cog anchor init")); + assert!(!log.exists()); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn anchor_status_output_order_is_deterministic() { + let _lock = crate::commands::support::CURRENT_DIR_TEST_LOCK + .lock() + .unwrap(); + let dir = temp_dir("status-order"); + let key = dir.join("anchor.key"); + let log = dir.join("anchor.jsonl"); + let scan = dir.join("scan"); + write_status_mixed_estate(&key, &log, &scan); + + let first = status_report(&key, &log, &scan); + let second = status_report(&key, &log, &scan); + + assert_eq!(first, second); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn anchor_status_reports_unparseable_and_ambiguous_dirs_and_exits_ok() { + let _lock = crate::commands::support::CURRENT_DIR_TEST_LOCK + .lock() + .unwrap(); + let dir = temp_dir("status-unparseable"); + let key = dir.join("anchor.key"); + let log = dir.join("anchor.jsonl"); + let scan = dir.join("scan"); + generate_key_file(&key).unwrap(); + let anchorer = Anchorer::open(&log, &key).unwrap(); + drop(anchorer); + let ambiguous_run = scan.join("a-ambiguous"); + write_observation_bundle(&ambiguous_run, &root("41"), "obs-ambiguous"); + write_transaction_bundle(&ambiguous_run, &root("42"), "tx-ambiguous"); + let invalid_root_run = scan.join("b-invalid-root"); + write_observation_bundle(&invalid_root_run, &"AB".repeat(32), "obs-invalid-root"); + let parse_error_run = scan.join("c-parse-error"); + std::fs::create_dir_all(&parse_error_run).unwrap(); + std::fs::write( + parse_error_run.join(OBSERVATION_WITNESS_MANIFEST_FILE), + b"{not-json", + ) + .unwrap(); + + let report = status_report(&key, &log, &scan); + + assert_eq!( + report.summary, + AnchorStatusSummary { + scanned: 3, + unparseable: 3, + ..AnchorStatusSummary::default() + } + ); + assert!(report.lines.iter().any(|line| { + line.contains("anchor_status_skip") + && line.contains("reason=ambiguous") + && line.contains("error=manifests=") + })); + assert!(report.lines.iter().any(|line| { + line.contains("reason=unparseable") && line.contains("error=invalid_witness_root") + })); + assert!(report + .lines + .iter() + .any(|line| line.contains("reason=unparseable") && line.contains("parse_failed"))); + let _ = std::fs::remove_dir_all(dir); + } + #[test] fn anchor_reconcile_refuses_tampered_log_before_anchoring() { let _lock = crate::commands::support::CURRENT_DIR_TEST_LOCK diff --git a/crates/cog-cli/src/main.rs b/crates/cog-cli/src/main.rs index 425d6d4..12e5686 100644 --- a/crates/cog-cli/src/main.rs +++ b/crates/cog-cli/src/main.rs @@ -126,6 +126,9 @@ fn dispatch_typed_priority(args: &[String]) -> Result> Command::Anchor { command: AnchorCommand::Reconcile(_), } => anchor_reconcile_cmd(args), + Command::Anchor { + command: AnchorCommand::Status(_), + } => anchor_status_cmd(args), Command::Desktop { command: DesktopCommand::FakeLiveRead(_), } => desktop_fake_live_read_cmd(args), @@ -492,6 +495,9 @@ fn main() -> Result<(), String> { Some("anchor") if args.get(2).map(String::as_str) == Some("reconcile") => { anchor_reconcile_cmd(&args)? } + Some("anchor") if args.get(2).map(String::as_str) == Some("status") => { + anchor_status_cmd(&args)? + } Some("lease") if args.get(2).map(String::as_str) == Some("issue") => { lease_issue_cmd(&args)? }