app
AI Flow
Public Made by Adomby adom
Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board.
← Commit history
Preserve artifact evidence in verified snapshots instead of overwriting by basename
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(©, 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.')