← Commit history

Preserve artifact evidence in verified snapshots instead of overwriting by basename

John Lauer ·40cd80ecb1 · ·parent 6f1e2b6
9 files changed +147−17
Cargo.lock+10−10
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-aiflow"-version = "0.1.86"+version = "0.1.87" dependencies = [  "aiflow-analyze",  "aiflow-board",@@ -24,7 +24,7 @@ dependencies = [  [[package]] name = "aiflow-analyze"-version = "0.1.86"+version = "0.1.87" dependencies = [  "aiflow-board",  "serde",@@ -34,7 +34,7 @@ dependencies = [  [[package]] name = "aiflow-board"-version = "0.1.86"+version = "0.1.87" dependencies = [  "roxmltree",  "serde",@@ -43,7 +43,7 @@ dependencies = [  [[package]] name = "aiflow-bridge"-version = "0.1.86"+version = "0.1.87" dependencies = [  "aiflow-board",  "serde",@@ -52,7 +52,7 @@ dependencies = [  [[package]] name = "aiflow-copper"-version = "0.1.86"+version = "0.1.87" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -62,7 +62,7 @@ dependencies = [  [[package]] name = "aiflow-grid"-version = "0.1.86"+version = "0.1.87" dependencies = [  "aiflow-board",  "serde",@@ -71,7 +71,7 @@ dependencies = [  [[package]] name = "aiflow-place"-version = "0.1.86"+version = "0.1.87" dependencies = [  "aiflow-board",  "serde",@@ -80,7 +80,7 @@ dependencies = [  [[package]] name = "aiflow-pours"-version = "0.1.86"+version = "0.1.87" dependencies = [  "aiflow-board",  "aiflow-copper",@@ -90,7 +90,7 @@ dependencies = [  [[package]] name = "aiflow-router"-version = "0.1.86"+version = "0.1.87" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -100,7 +100,7 @@ dependencies = [  [[package]] name = "aiflow-run"-version = "0.1.86"+version = "0.1.87" dependencies = [  "serde",  "serde_json",
Cargo.toml+1−1
@@ -14,7 +14,7 @@ members = [ ]  [workspace.package]-version = "0.1.86"+version = "0.1.87" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
SKILL.md+4
@@ -397,3 +397,7 @@ When multiple physical lands share one logical pin number, use the reviewed Fiel ### Existing pours after board adoption  Use `verify-pours --plan <reviewed-plan.json>` for read-only validation of already present saved pours against a reconciled explicit plan. Read `docs/existing-pours.md`. It records input-bound verification, not creation ownership; never replay a native mutation simply to repair dashboard evidence. A stale plan must fail. Native DRC, measurement and electrical/thermal gates remain required.++### Artifact evidence is copied without overwriting++`artifact --file` stores a content-addressed snapshot, including files already inside the run. Two different files named `review.json` remain separate; later source edits do not rewrite prior evidence. Copy/readback failures refuse registration. The ledger records the SHA-256 and original source path. Use the registered path printed by the command with `--replaces`; no historical event is rewritten. A hash establishes identity, not engineering validity. Existing ledgers remain unchanged.
bin/adom-aiflow
⋯ 1 unchanged line ⋯
crates/adom-aiflow/src/artifact_store.rsadded+90
@@ -0,0 +1,90 @@+//! Content-addressed copies keep artifact registration from overwriting run evidence.+use sha2::{Digest, Sha256};+use std::io::Write;+use std::path::{Path, PathBuf};++pub fn snapshot(source: &Path, run_dir: &Path) -> Result<(PathBuf, String), String> {+    let bytes = std::fs::read(source)+        .map_err(|e| format!("cannot read artifact {}: {e}", source.display()))?;+    let hash = format!("{:x}", Sha256::digest(&bytes));+    let suffix = source.extension().and_then(|s| s.to_str())+        .filter(|s| !s.is_empty() && s.len() <= 16 && s.bytes().all(|b| b.is_ascii_alphanumeric()))+        .map(|s| format!(".{}", s.to_ascii_lowercase())).unwrap_or_default();+    let dir = std::fs::canonicalize(run_dir)+        .map_err(|e| format!("cannot open artifact run directory: {e}"))?;+    let dest = dir.join(format!("artifact-{hash}{suffix}"));+    match std::fs::OpenOptions::new().write(true).create_new(true).open(&dest) {+        Ok(mut file) => {+            if let Err(e) = file.write_all(&bytes).and_then(|_| file.sync_all()) {+                drop(file);+                let _ = std::fs::remove_file(&dest);+                return Err(format!("cannot store artifact {}: {e}", dest.display()));+            }+        }+        Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}+        Err(e) => return Err(format!("cannot create artifact {}: {e}", dest.display())),+    }+    let metadata = std::fs::symlink_metadata(&dest).map_err(|e| e.to_string())?;+    if !metadata.file_type().is_file() {+        return Err(format!("artifact destination is not a regular file: {}", dest.display()));+    }+    let saved = std::fs::read(&dest).map_err(|e| format!("cannot verify artifact: {e}"))?;+    if saved != bytes {+        return Err(format!("artifact snapshot differs from source; existing file preserved: {}", dest.display()));+    }+    Ok((dest, hash))+}++#[cfg(test)]+mod tests {+    use super::*;+    fn directory(name: &str) -> PathBuf {+        let p = std::env::temp_dir().join(format!("aiflow-artifact-{name}-{}", std::process::id()));+        std::fs::create_dir_all(&p).unwrap();+        p+    }+    #[test]+    fn same_basename_keeps_both_reviews_and_source_changes_keep_history() {+        let d = directory("reviews");+        for name in ["a", "b", "run"] { std::fs::create_dir_all(d.join(name)).unwrap(); }+        let a = d.join("a/review.json"); let b = d.join("b/review.json");+        std::fs::write(&a, b"first review").unwrap();+        std::fs::write(&b, b"second review").unwrap();+        let first = snapshot(&a, &d.join("run")).unwrap();+        let second = snapshot(&b, &d.join("run")).unwrap();+        assert_ne!(first.0, second.0);+        assert_eq!(snapshot(&a, &d.join("run")).unwrap(), first);+        std::fs::write(&a, b"revised review").unwrap();+        assert_ne!(snapshot(&a, &d.join("run")).unwrap().0, first.0);+        assert_eq!(std::fs::read(first.0).unwrap(), b"first review");+        assert_eq!(std::fs::read(second.0).unwrap(), b"second review");+        std::fs::remove_dir_all(d).unwrap();+    }+    #[test]+    fn run_manifest_name_and_in_run_sources_are_safe() {+        let d = directory("manifest"); let run = d.join("run");+        std::fs::create_dir_all(&run).unwrap();+        std::fs::write(run.join("run.json"), b"keep manifest").unwrap();+        let src = d.join("run.json"); std::fs::write(&src, b"external review").unwrap();+        snapshot(&src, &run).unwrap();+        assert_eq!(std::fs::read(run.join("run.json")).unwrap(), b"keep manifest");+        let inside = run.join("plot.PNG"); std::fs::write(&inside, b"original image").unwrap();+        let (copy, _) = snapshot(&inside, &run).unwrap();+        assert_eq!(copy.extension().unwrap(), "png");+        std::fs::write(inside, b"new image").unwrap();+        assert_eq!(std::fs::read(copy).unwrap(), b"original image");+        std::fs::remove_dir_all(d).unwrap();+    }+    #[test]+    fn corrupted_existing_snapshot_is_refused_without_overwrite() {+        let d = directory("corrupt"); let source = d.join("source.json");+        std::fs::write(&source, b"original").unwrap();+        let (copy, _) = snapshot(&source, &d).unwrap();+        std::fs::write(&copy, b"changed externally").unwrap();+        assert!(snapshot(&source, &d).unwrap_err().contains("existing file preserved"));+        assert_eq!(std::fs::read(copy).unwrap(), b"changed externally");+        assert!(snapshot(&d.join("absent"), &d).is_err());+        assert!(snapshot(&source, &d.join("absent")).is_err());+        std::fs::remove_dir_all(d).unwrap();+    }+}
crates/adom-aiflow/src/main.rs+5−5
@@ -1,6 +1,7 @@ //! adom-aiflow: the board loop as one command, with the clock, the negotiation and the finish line. //! Every reply starts with OK: or ERROR: and carries Hint: lines that depend on this board and run. mod continuation;+mod artifact_store; mod pourverify; mod dashboard; mod silkscreen_sections;@@ -3324,10 +3325,9 @@ fn main() {             if !src.is_file() { err(&format!("no such file: {file}"), &["Make the drawing first (a PNG at 1920x1080 reads best fullscreen), then register it.".into()]); }             let step = step.clone().or_else(|| r.data["currentStep"].as_str().map(str::to_string)).unwrap_or_else(|| err("no current step", &["`step <name>` first: an artifact belongs to a step visit.".into()]));             let visit = r.data["steps"][&step]["visits"].as_array().map(|a| a.len()).unwrap_or(1);-            // the file lives in the run directory so the page and the video can find it later-            let canon = std::fs::canonicalize(&src).unwrap_or(src.clone());-            let rdir = std::fs::canonicalize(&dir).unwrap_or(dir.clone());-            let dest = if canon.starts_with(&rdir) { canon } else { let d = dir.join(src.file_name().unwrap_or_default()); let _ = std::fs::copy(&src, &d); std::fs::canonicalize(&d).unwrap_or(d) };+            // Snapshot even in-run sources: later edits must not rewrite earlier evidence.+            let (dest, artifact_hash) = artifact_store::snapshot(&src, &dir)+                .unwrap_or_else(|e| err(&e, &["No artifact was registered. Resolve the storage error and retry; existing evidence is preserved.".into()]));             let is_image = matches!(dest.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()).as_deref(), Some("png") | Some("jpg") | Some("jpeg") | Some("webp"));             let review_path=if kind=="library-overview" {                 let rp=review.as_ref().unwrap_or_else(||err("library-overview needs --review <review.json>", &["Review exact selected STEP/GLB variants in the native EDA and the overview. See docs/library-overview-review.md.".into()]));@@ -3337,7 +3337,7 @@ fn main() {                 Some(std::fs::canonicalize(rp).unwrap_or(rp.clone()).display().to_string())             }else{None};             let replaced = replaces.as_ref().map(|p| std::fs::canonicalize(p).unwrap_or(PathBuf::from(p)).display().to_string());-            r.log("artifact", json!({"step": step, "visit": visit, "kind": kind, "file": dest.display().to_string(), "caption": caption, "byAi": true, "replaces": replaced, "review":review_path}));+            r.log("artifact", json!({"step": step, "visit": visit, "kind": kind, "file": dest.display().to_string(), "caption": caption, "byAi": true, "replaces": replaced, "review":review_path, "sha256": artifact_hash, "sourceFile": src.display().to_string()}));             r.mark(&format!("artifact {kind} for {step}-{visit}: {caption}"));             r.save().unwrap();             ok(&format!("{kind} registered for {step} (visit {visit}): {}", dest.display()), &[if is_image { "It goes on the run page under this step with your caption above it, and into the final video as a fullscreen shot at this point of the step (the one place the video leaves the EDA window).".to_string() } else if kind=="library-overview" { "The reviewed moving overview goes into compose; the full tour remains separate.".into() } else { "It goes on the run page under this step with your caption; this artifact kind does not automatically enter the final video.".to_string() }, "Next: if this was an analysis, walk the copper it judged on camera under this same step: `tour nets --nets <the pour nets>`.".into()]);
docs/release-0.1.87.mdadded+5
@@ -0,0 +1,5 @@+# 0.1.87: preserve artifact evidence++Registering two external files with the same basename previously overwrote the first run copy; a source named `run.json` could also collide with the manifest. `artifact` now creates a content-addressed, verified snapshot and reports storage failures before recording success. In-run source files are copied too, so subsequent source edits preserve earlier evidence. Existing snapshots are never overwritten; conflicting contents refuse.++The ledger additionally records `sha256` and `sourceFile`. Reports and video composition keep using the registered `file` path. Pass that printed path to `--replaces`. Old ledgers and files are not migrated or rewritten. Identical contents with the same extension reuse a verified snapshot. This protects evidence identity, not the correctness of its engineering conclusions.
package.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-aiflow",   "type": "app",-  "version": "0.1.86",+  "version": "0.1.87",   "title": "AI Flow",   "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad today; Altium, Fusion and Adom's own web apps next.",   "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",
tests/artifact-workflow.pyadded+31
@@ -0,0 +1,31 @@+"""Actual artifact CLI regression: same basename, manifest collision and immutable history."""+import hashlib,json,os,pathlib,subprocess,sys,tempfile+binary=str(pathlib.Path(sys.argv[1]).resolve())+with tempfile.TemporaryDirectory(prefix='aiflow-artifact-cli-') as td:+    p=pathlib.Path(td);run=p/'run';env=dict(os.environ,AIFLOW_DASHBOARD='off')+    def call(*args):+        return subprocess.run([binary,'--run',str(run),'--ai-thread','artifact-regression',*args],env=env,capture_output=True,text=True,timeout=30)+    assert call('intake','--prompt-time','2026-10-10T00:00:00Z','--engine','test').returncode==0+    def artifacts():+        return [r for r in map(json.loads,(run/'run.jsonl').read_text().splitlines()) if r.get('event')=='artifact']+    for name,body in [('a','first'),('b','second')]:+        (p/name).mkdir();src=p/name/'review.json';src.write_text(body)+        result=call('artifact','--file',str(src),'--step','intake','--kind','engineering-review','--caption',name)+        assert result.returncode==0,result.stderr+    first,second=artifacts()[-2:]+    assert first['file']!=second['file']+    assert pathlib.Path(first['file']).read_text()=='first'+    assert pathlib.Path(second['file']).read_text()=='second'+    assert first['sha256']==hashlib.sha256(b'first').hexdigest()+    (p/'a/review.json').write_text('new first')+    assert pathlib.Path(first['file']).read_text()=='first'+    src=p/'run.json';src.write_text('not a run manifest')+    assert call('artifact','--file',str(src),'--step','intake','--caption','manifest-name').returncode==0+    assert json.loads((run/'run.json').read_text())['engine']=='test'+    assert call('artifact','--file',str(p/'a/review.json'),'--step','intake','--caption','updated','--replaces',first['file']).returncode==0+    assert artifacts()[-1]['replaces']==first['file']+    corrupted=pathlib.Path(second['file']);corrupted.write_text('externally changed')+    before=len(artifacts());result=call('artifact','--file',str(p/'b/review.json'),'--step','intake','--caption','must refuse')+    assert result.returncode!=0 and 'existing file preserved' in result.stdout+result.stderr+    assert len(artifacts())==before and corrupted.read_text()=='externally changed'+    print('PASS: CLI preserves distinct reviews and run manifest, binds hashes, supports replacement, refuses corrupted copies.')