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
0.1.70: automatically open and recover each AIFlow run dashboard in Hydrogen
12 files changed
+398−30
Cargo.lock+10−10@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-aiflow"-version = "0.1.69"+version = "0.1.70" dependencies = [ "aiflow-analyze", "aiflow-board",@@ -24,7 +24,7 @@ dependencies = [ [[package]] name = "aiflow-analyze"-version = "0.1.69"+version = "0.1.70" dependencies = [ "serde", "serde_json",@@ -33,7 +33,7 @@ dependencies = [ [[package]] name = "aiflow-board"-version = "0.1.69"+version = "0.1.70" dependencies = [ "roxmltree", "serde",@@ -42,7 +42,7 @@ dependencies = [ [[package]] name = "aiflow-bridge"-version = "0.1.69"+version = "0.1.70" dependencies = [ "aiflow-board", "serde",@@ -51,7 +51,7 @@ dependencies = [ [[package]] name = "aiflow-copper"-version = "0.1.69"+version = "0.1.70" dependencies = [ "aiflow-board", "aiflow-grid",@@ -61,7 +61,7 @@ dependencies = [ [[package]] name = "aiflow-grid"-version = "0.1.69"+version = "0.1.70" dependencies = [ "aiflow-board", "serde",@@ -70,7 +70,7 @@ dependencies = [ [[package]] name = "aiflow-place"-version = "0.1.69"+version = "0.1.70" dependencies = [ "aiflow-board", "serde",@@ -79,7 +79,7 @@ dependencies = [ [[package]] name = "aiflow-pours"-version = "0.1.69"+version = "0.1.70" dependencies = [ "aiflow-board", "aiflow-copper",@@ -89,7 +89,7 @@ dependencies = [ [[package]] name = "aiflow-router"-version = "0.1.69"+version = "0.1.70" dependencies = [ "aiflow-board", "aiflow-grid",@@ -99,7 +99,7 @@ dependencies = [ [[package]] name = "aiflow-run"-version = "0.1.69"+version = "0.1.70" dependencies = [ "serde", "serde_json",
Cargo.toml+1−1@@ -14,7 +14,7 @@ members = [ ] [workspace.package]-version = "0.1.69"+version = "0.1.70" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
README.md+4@@ -195,3 +195,7 @@ Native pour integration: [owned-zone transactions and asynchronous refill/readba `adom-aiflow browser-check --lane pup --input page-observation.json` distinguishes access denial from missing or loading pages and recommends an owned native-browser session when appropriate. It checks saved page evidence and does not navigate or claim the product was verified. See [the sourcing skill](skills/aiflow-sourcing/SKILL.md#detect-browser-access-failures-before-abandoning-a-source). [0.1.69: sourcing recovery, state-aware component assets and routing fidelity](docs/release-0.1.69.md).++## Automatic progress dashboard++AIFlow opens its Hydrogen timeline automatically when a run starts or resumes, for every AI engine. Existing tabs are reused; each project gets its own server identity. Closed tabs and stopped servers recover on the next command. UI failures are reported separately from design checks. Use `timeline open` to bring it forward explicitly, or `AIFLOW_DASHBOARD=off` for headless automation.
SKILL.md+7@@ -46,6 +46,13 @@ jump past it. ## The timeline (show it, keep it up) +### Automatic dashboard (all AI engines)++The CLI opens or reuses the run's Hydrogen webview automatically on `intake`, `start`, and subsequent run commands. `prompt` surfaces it when resuming work. No separate `timeline open` instruction is needed. Keep the dashboard in a work pane beside the chat; do not move the editor or change pane sizes. Each run has its own verified server identity and stable tab name, so concurrent projects cannot silently share the wrong timeline. A closed tab or stopped server is recovered on the next run command. Every completed command refreshes the timeline; long-running work should use real stage/step events, never fabricated progress or idle heartbeats.++`timeline open` remains the explicit show/recovery command. Dashboard failures print `Dashboard unavailable` with recovery guidance and do not change board-gate outcomes. In genuinely headless automation, `AIFLOW_DASHBOARD=off` explicitly suppresses automatic display; this is not the default for Adom users. `--help`, commands without an existing run, and `timeline serve/write` do not create a dashboard recursively. Server lifetime is not charged as an engineering turn. Displayed steps are recorded workflow state, not a substitute for the finish checks.++ `adom-aiflow timeline open` serves the run's timeline and opens it as a Hydrogen webview in the right pane of the workspace the run is driving (for a demo, the filmed Hydrogen, never your own): every required step grouped by phase, a teal check on each done step, the current one lit, "Step 9 of 21, 12 to
bin/adom-aiflow⋯ 1 unchanged line ⋯
crates/adom-aiflow/src/dashboard.rsadded+266@@ -0,0 +1,266 @@+//! Engine-independent Hydrogen dashboard lifecycle. Never touches board acceptance.+use serde_json::{json, Value};+use std::{+ fs,+ io::{Read, Write},+ net::{SocketAddr, TcpStream},+ path::Path,+ process::{Command, Stdio},+ time::{Duration, Instant},+};++pub fn run_id(dir: &Path) -> String {+ let path = fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());+ let mut hash = 0xcbf29ce484222325u64;+ for b in path.to_string_lossy().bytes() {+ hash = (hash ^ b as u64).wrapping_mul(0x100000001b3);+ }+ format!("{hash:016x}")+}++fn command(args: &[&str], seconds: u64) -> Result<Value, String> {+ // Files avoid a full pipe deadlocking a bounded child. No shell interpolation.+ static N: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);+ let path = std::env::temp_dir().join(format!(+ "aiflow-dashboard-{}-{}.json",+ std::process::id(),+ N.fetch_add(1, std::sync::atomic::Ordering::Relaxed)+ ));+ let f = fs::File::create(&path).map_err(|e| e.to_string())?;+ let result = (|| {+ let mut child = Command::new("adom-cli")+ .args(args)+ .stdin(Stdio::null())+ .stdout(f)+ .stderr(Stdio::null())+ .spawn()+ .map_err(|e| e.to_string())?;+ let start = Instant::now();+ loop {+ if let Some(status) = child.try_wait().map_err(|e| e.to_string())? {+ let body = fs::read(&path).map_err(|e| e.to_string())?;+ let value: Value = serde_json::from_slice(&body)+ .map_err(|_| "Hydrogen returned invalid JSON".to_string())?;+ if !status.success()+ || value["ok"] == false+ || value["success"] == false+ || value["status"] == "error"+ {+ return Err("Hydrogen command failed".into());+ }+ return Ok(value);+ }+ if start.elapsed() >= Duration::from_secs(seconds) {+ let _ = child.kill();+ let _ = child.wait();+ return Err("Hydrogen dashboard request timed out".into());+ }+ std::thread::sleep(Duration::from_millis(40));+ }+ })();+ let _ = fs::remove_file(path);+ result+}++fn identity(port: u16) -> Option<String> {+ let addr: SocketAddr = ([127, 0, 0, 1], port).into();+ let mut stream = TcpStream::connect_timeout(&addr, Duration::from_millis(100)).ok()?;+ stream+ .set_read_timeout(Some(Duration::from_millis(200)))+ .ok()?;+ stream+ .set_write_timeout(Some(Duration::from_millis(200)))+ .ok()?;+ stream+ .write_all(b"GET /dashboard.json HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")+ .ok()?;+ let mut bytes = Vec::new();+ stream.take(8192).read_to_end(&mut bytes).ok()?;+ let body = String::from_utf8(bytes).ok()?;+ let (_, json) = body.split_once("\r\n\r\n")?;+ serde_json::from_str::<Value>(json).ok()?["runId"]+ .as_str()+ .map(str::to_owned)+}++pub fn has_tab(value: &Value, name: &str, url: &str) -> bool {+ match value {+ Value::Object(o) => {+ if o.get("panelState")+ .map(|s| s["displayName"] == name && s["url"] == url)+ .unwrap_or(false)+ {+ return true;+ }+ o.values().any(|v| has_tab(v, name, url))+ }+ Value::Array(a) => a.iter().any(|v| has_tab(v, name, url)),+ _ => false,+ }+}++pub fn proxy_url(template: Option<&str>, port: u16) -> String {+ template+ .filter(|s| s.contains("{{port}}"))+ .map(|s| s.replace("{{port}}", &port.to_string()))+ .unwrap_or_else(|| format!("http://127.0.0.1:{port}/"))+}++struct Lock(std::path::PathBuf);+impl Drop for Lock {+ fn drop(&mut self) {+ let _ = fs::remove_file(&self.0);+ }+}++pub fn ensure(+ dir: &Path,+ ai_thread: &str,+ requested_port: Option<u16>,+ force_show: bool,+) -> Result<String, String> {+ let dir = fs::canonicalize(dir).map_err(|e| e.to_string())?;+ let tdir = dir.join("timeline");+ fs::create_dir_all(&tdir).map_err(|e| e.to_string())?;+ let lock = dir.join("dashboard.lock");+ if fs::metadata(&lock)+ .ok()+ .and_then(|m| m.modified().ok())+ .and_then(|t| t.elapsed().ok())+ .map(|d| d.as_secs() > 60)+ .unwrap_or(false)+ {+ let _ = fs::remove_file(&lock);+ }+ fs::OpenOptions::new()+ .write(true)+ .create_new(true)+ .open(&lock)+ .map_err(|_| {+ "dashboard recovery is already running; next command will retry".to_string()+ })?;+ let _lock = Lock(lock);+ let id = run_id(&dir);+ fs::write(+ tdir.join("dashboard.json"),+ json!({"runId":id,"schemaVersion":1}).to_string(),+ )+ .map_err(|e| e.to_string())?;+ let config = dir.join("dashboard.json");+ let saved: Value = fs::read(&config)+ .ok()+ .and_then(|s| serde_json::from_slice(&s).ok())+ .unwrap_or(Value::Null);+ let saved_port = saved["port"].as_u64().and_then(|p| u16::try_from(p).ok());+ let mut port = None;+ for candidate in requested_port+ .into_iter()+ .chain(saved_port)+ .chain(8893..8993)+ {+ if identity(candidate).as_deref() == Some(&id) {+ port = Some(candidate);+ break;+ }+ if std::net::TcpListener::bind(("127.0.0.1", candidate)).is_err() {+ continue;+ }+ let exe = std::env::current_exe().map_err(|e| e.to_string())?;+ let log = fs::File::create(tdir.join("serve.log")).map_err(|e| e.to_string())?;+ let mut cmd = Command::new("setsid");+ cmd.arg(exe)+ .args([+ "--run",+ &dir.to_string_lossy(),+ "timeline",+ "serve",+ "--port",+ &candidate.to_string(),+ ])+ .stdin(Stdio::null())+ .stdout(log.try_clone().map_err(|e| e.to_string())?)+ .stderr(log);+ let mut child = cmd.spawn().map_err(|e| format!("dashboard server: {e}"))?;+ for _ in 0..30 {+ if identity(candidate).as_deref() == Some(&id) {+ port = Some(candidate);+ break;+ }+ if child.try_wait().ok().flatten().is_some() {+ break;+ }+ std::thread::sleep(Duration::from_millis(100));+ }+ if port.is_some() {+ break;+ }+ // Do not kill a process by port: it might belong to another run.+ return Err(format!(+ "dashboard server did not become ready on {candidate}; inspect {}",+ tdir.join("serve.log").display()+ ));+ }+ let port = port.ok_or("no free dashboard port in 8893..8993")?;+ let template = std::env::var("VSCODE_PROXY_URI").ok();+ let url = proxy_url(template.as_deref(), port);+ let name = format!("AI Flow ยท {}", &id[..8]);+ let workspace = command(&["hydrogen", "workspace", "get"], 8)?;+ if force_show || !has_tab(&workspace, &name, &url) {+ // Restart is a shell command by the Hydrogen API contract: quote run path safely.+ let quoted = format!("'{}'", dir.to_string_lossy().replace('\'', "'\\''"));+ let restart = format!("adom-aiflow timeline serve --run {quoted} --port {port}");+ command(+ &[+ "hydrogen",+ "webview",+ "open-or-refresh",+ "--name",+ &name,+ "--url",+ &url,+ "--ai-thread",+ ai_thread,+ "--reason",+ "AIFlow design progress",+ "--restart",+ &restart,+ ],+ 12,+ )?;+ }+ fs::write(+ config,+ serde_json::to_vec_pretty(&json!({"runId":id,"port":port,"url":url,"name":name})).unwrap(),+ )+ .map_err(|e| e.to_string())?;+ Ok(url)+}++#[cfg(test)]+mod tests {+ use super::*;+ #[test]+ fn tab_requires_run_name_and_url() {+ let ws = json!({"root":{"first":{"tabs":[{"panelState":{"displayName":"one","url":"http://one/"}}]}}});+ assert!(has_tab(&ws, "one", "http://one/"));+ assert!(!has_tab(&ws, "two", "http://one/"));+ assert!(!has_tab(&ws, "one", "http://other/"));+ assert!(!has_tab(&Value::Null, "one", "http://one/"));+ }+ #[test]+ fn proxy_is_port_specific() {+ assert_eq!(+ proxy_url(Some("http://{{port}}.localhost:1/"), 8894),+ "http://8894.localhost:1/"+ );+ assert_eq!(+ proxy_url(Some("http://unrelated/"), 8894),+ "http://127.0.0.1:8894/"+ );+ }+ #[test]+ fn different_runs_have_different_ids() {+ assert_ne!(run_id(Path::new("run-a")), run_id(Path::new("run-b")));+ assert_eq!(run_id(Path::new("run-a")), run_id(Path::new("run-a")));+ }+}
crates/adom-aiflow/src/main.rs+36−16@@ -1,5 +1,6 @@ //! 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 dashboard; mod silkscreen_sections; mod sequence; mod testplan;@@ -185,6 +186,26 @@ enum Cmd { } static TURN: std::sync::OnceLock<(PathBuf, usize)> = std::sync::OnceLock::new();+static DASHBOARD_ATTEMPTED: std::sync::OnceLock<()> = std::sync::OnceLock::new();+static DASHBOARD: std::sync::OnceLock<(PathBuf, String)> = std::sync::OnceLock::new();++fn refresh_dashboard(dir: &Path) {+ if let Some(r) = Run::open(dir) {+ let flow: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap();+ let seq = r.data["sequence"].as_array().map(|a| a.iter().filter_map(|v|v.as_str().map(str::to_owned)).collect()).unwrap_or_else(|| sequence::sequence_of(&flow, sequence::lane(dir, &r.data)));+ sequence::write_timeline(dir, &sequence::timeline(dir, &r.data, &flow, &seq, &r.step_report()), TIMELINE_PAGE);+ }+}++fn ensure_dashboard(dir: &Path, ai_thread: &str, force_show: bool) {+ if Run::open(dir).is_none() || std::env::var("AIFLOW_DASHBOARD").as_deref() == Ok("off") { return; }+ let _ = DASHBOARD_ATTEMPTED.set(());+ refresh_dashboard(dir);+ if let Err(e) = dashboard::ensure(dir, ai_thread, None, force_show) {+ eprintln!("Dashboard unavailable: {e}. Board results are unchanged. Retry `adom-aiflow timeline open --run {}` when Hydrogen is available.", dir.display());+ }+}+ /// Close the ledger entry for this command (see Run::turn_begin). fn close_turn(ok_: bool, first_line: &str) {@@ -194,6 +215,11 @@ fn close_turn(ok_: bool, first_line: &str) { let _ = r.save(); } }+ if let Some((dir, ai_thread)) = DASHBOARD.get() {+ // Intake/start create the run during dispatch; existing commands refresh after completion.+ if DASHBOARD_ATTEMPTED.get().is_none() { ensure_dashboard(dir, ai_thread, false); }+ else { refresh_dashboard(dir); }+ } } fn ok(msg: &str, hints: &[String]) {@@ -1943,8 +1969,14 @@ fn project_dir_of(r: &Run) -> PathBuf { fn main() { let cli = Cli::parse(); let dir = run_dir(&cli);+ // All engines share the same default. Server/write children must never open tabs recursively.+ if !matches!(cli.cmd, Cmd::Timeline { .. }) {+ let ai_thread = cli.ai_thread.clone().or_else(|| Run::open(&dir).and_then(|r|r.data["aiThread"].as_str().map(str::to_owned))).unwrap_or_else(|| "adom-aiflow".into());+ let _ = DASHBOARD.set((dir.clone(), ai_thread.clone()));+ ensure_dashboard(&dir, &ai_thread, matches!(cli.cmd, Cmd::Prompt { .. }));+ } // every command is a turn in the run's ledger; the gap before it is the AI's thinking time- if !matches!(cli.cmd, Cmd::Start { .. } | Cmd::Intake { .. }) {+ if !matches!(cli.cmd, Cmd::Start { .. } | Cmd::Intake { .. }) && !matches!(&cli.cmd, Cmd::Timeline { action, .. } if action == "serve") { if let Some(mut r) = Run::open(&dir) { let idx = r.turn_begin(&command_line()); let _ = r.save();@@ -3487,7 +3519,7 @@ fn main() { let tdir = tdir.clone(); std::thread::spawn(move || { use std::io::{Read, Write};- let mut st = st; let mut buf = [0u8; 4096];+ let mut st = st; let _ = st.set_read_timeout(Some(std::time::Duration::from_secs(5))); let mut buf = [0u8; 4096]; let n = st.read(&mut buf).unwrap_or(0); let req = String::from_utf8_lossy(&buf[..n]).to_string(); let path = req.split_whitespace().nth(1).unwrap_or("/").split('?').next().unwrap_or("/").trim_start_matches('/').to_string();@@ -3503,20 +3535,8 @@ fn main() { } } _ => {- // the server: reuse a running one, else start one that outlives this command- if std::net::TcpStream::connect(("127.0.0.1", *port)).is_err() {- let exe = std::env::current_exe().unwrap();- let log = std::fs::File::create(tdir.join("serve.log")).ok();- let mut c = std::process::Command::new("setsid");- c.arg(exe).args(["--run", &dir.display().to_string(), "timeline", "serve", "--port", &port.to_string()]).stdin(std::process::Stdio::null());- if let Some(f) = log { c.stdout(f.try_clone().unwrap()).stderr(f); }- c.spawn().unwrap_or_else(|e| err(&format!("could not start the timeline server: {e}"), &[]));- std::thread::sleep(std::time::Duration::from_millis(600));- }- let url = std::env::var("VSCODE_PROXY_URI").ok().map(|u| u.replace("{{port}}", &port.to_string())).unwrap_or_else(|| format!("http://127.0.0.1:{port}/"));- let o = std::process::Command::new("adom-cli").args(["hydrogen", "webview", "open-or-refresh", "--name", "AI Flow timeline", "--url", &url]).output();- let opened = o.as_ref().map(|o| o.status.success()).unwrap_or(false);- if !opened { err(&format!("the timeline is served at {url}, but Hydrogen did not open it"), &["Open it yourself: `adom-cli hydrogen webview open-or-refresh --name \"AI Flow timeline\" --url <that url>`.".into()]); }+ let ai_thread=cli.ai_thread.clone().or_else(||r.data["aiThread"].as_str().map(str::to_owned)).unwrap_or_else(||"adom-aiflow".into());+ let url=dashboard::ensure(&dir,&ai_thread,Some(*port),true).unwrap_or_else(|e|err(&e,&["Dashboard failure does not change any board gate. Retry when Hydrogen is connected.".into()])); ok(&format!("timeline open in Hydrogen: {url} (step {} of {})", tl["position"], tl["total"]), &["Keep it in the right pane beside the chat (the VS Code tab stays on the left; never widen panes). Every `step` updates it within a second.".into()]); } }
docs/automatic-dashboard.mdadded+18@@ -0,0 +1,18 @@+# Automatic Hydrogen dashboard++AIFlow 0.1.70 opens the timeline by default for every AI engine when a run is created or used. No engine-specific prompt or manual `timeline open` step is required.++- `intake` and `start` open a webview after creating the run.+- Subsequent run commands ensure the server and tab exist, then update the timeline after the command finishes.+- `prompt` brings the existing run tab forward when resuming work. Ordinary commands leave the user's selected tab alone when the dashboard already exists.+- Each canonical run directory has a stable identity. A server is reused only if its HTTP identity matches that run; busy ports belonging to other projects are preserved.+- Closing a tab or stopping its server is repaired on the next run command. Hydrogen also receives a restart command and AI thread attribution.+- No panes are resized, and the editor pane is not replaced. Hydrogen chooses the work pane.+- A missing/disconnected Hydrogen session yields an explicit warning; it cannot change a board gate or claim the dashboard was displayed.+- `AIFLOW_DASHBOARD=off` is an explicit headless-automation override. Help without a run and the server/write subcommands do not recursively open tabs.++The timeline reflects recorded stages and evidence. It does not manufacture engineering progress, mark a board finished, or reinterpret a failed check. Long-lived dashboard server processes are excluded from the run's engineering-turn clock.++## Validation++Production compilation and workspace Rust tests; real CLI/server lifecycle tests with a fake Hydrogen API cover initial opening, reuse, closed-tab recovery, concurrent-run isolation, resume, stopped-server recovery, explicit headless mode and visible UI failure. Native Hydrogen rendering and window recording are checked separately before release. The component regression test now matches the already-released MPN-marking-on default; this release does not change that preference.
package.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-aiflow", "type": "app",- "version": "0.1.69",+ "version": "0.1.70", "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.",
skills/adom-aiflow/SKILL.md+7@@ -46,6 +46,13 @@ jump past it. ## The timeline (show it, keep it up) +### Automatic dashboard (all AI engines)++The CLI opens or reuses the run's Hydrogen webview automatically on `intake`, `start`, and subsequent run commands. `prompt` surfaces it when resuming work. No separate `timeline open` instruction is needed. Keep the dashboard in a work pane beside the chat; do not move the editor or change pane sizes. Each run has its own verified server identity and stable tab name, so concurrent projects cannot silently share the wrong timeline. A closed tab or stopped server is recovered on the next run command. Every completed command refreshes the timeline; long-running work should use real stage/step events, never fabricated progress or idle heartbeats.++`timeline open` remains the explicit show/recovery command. Dashboard failures print `Dashboard unavailable` with recovery guidance and do not change board-gate outcomes. In genuinely headless automation, `AIFLOW_DASHBOARD=off` explicitly suppresses automatic display; this is not the default for Adom users. `--help`, commands without an existing run, and `timeline serve/write` do not create a dashboard recursively. Server lifetime is not charged as an engineering turn. Displayed steps are recorded workflow state, not a substitute for the finish checks.++ `adom-aiflow timeline open` serves the run's timeline and opens it as a Hydrogen webview in the right pane of the workspace the run is driving (for a demo, the filmed Hydrogen, never your own): every required step grouped by phase, a teal check on each done step, the current one lit, "Step 9 of 21, 12 to
tests/component-workflow.py+2−2@@ -7,10 +7,10 @@ with tempfile.TemporaryDirectory(prefix='aiflow-components-') as d: text=subprocess.run(base+['plan'],check=True,capture_output=True,text=True).stdout assert text.index('step components') < text.index('step libraries') < text.index('step models') < text.index('step library-tour') < text.index('step placement') assert text.index('Search the wiki FIRST')<text.index('use adom-parts-search next')- for phrase in ['ALL ADOM USERS','private reference-only','optional MPN marking','Cache keys','per-file provenance mandatory','datasheet page/figure/table','Unknown provenance stays unknown']:+ for phrase in ['ALL ADOM USERS','private reference-only','MPN marking is ON by default','Cache keys','per-file provenance mandatory','datasheet page/figure/table','Unknown provenance stays unknown']: assert phrase in text,phrase result=subprocess.run(base+['components'],check=True,capture_output=True,text=True)- p=pathlib.Path(d)/'components.json';v=json.load(open(p));assert len(v['components'])==149;assert v['mpnMarking']=='ask'+ p=pathlib.Path(d)/'components.json';v=json.load(open(p));assert len(v['components'])==149;assert v['mpnMarking']=='on' assert all('assetProvenance' in r['review'] for r in v['components']) refs=[r['reference'] for r in v['components']];assert len(set(refs))==149 v['components'][0]['review']['notes']='Preserve this independent review';p.write_text(json.dumps(v))
tests/dashboard-workflow.pyadded+46@@ -0,0 +1,46 @@+"""Real CLI/server lifecycle with a fake Hydrogen API; no CAD or real tabs touched."""+import json,os,pathlib,subprocess,sys,tempfile,time,urllib.request,signal+binary=str(pathlib.Path(sys.argv[1]).resolve())+with tempfile.TemporaryDirectory(prefix='aiflow-dashboard-test-') as root:+ p=pathlib.Path(root);bindir=p/'bin';bindir.mkdir();state=p/'hydrogen.json';state.write_text('{}');calls=p/'calls.jsonl'+ mock=bindir/'adom-cli';mock.write_text('''#!/usr/bin/env python3+import sys,json,os,pathlib+args=sys.argv[1:];p=pathlib.Path(os.environ['TEST_HYDROGEN']);d=json.loads(p.read_text())+with open(os.environ['TEST_CALLS'],'a')as f:f.write(json.dumps(args)+'\\n')+if args[:3]==['hydrogen','workspace','get']:print(json.dumps(d))+elif args[:3]==['hydrogen','webview','open-or-refresh']:+ name=args[args.index('--name')+1];url=args[args.index('--url')+1]+ d.setdefault('tabs',[]).append({'panelState':{'displayName':name,'url':url}});p.write_text(json.dumps(d));print(json.dumps({'ok':True,'action':'created'}))+else:print('{}')+''');mock.chmod(0o755)+ env=dict(os.environ,PATH=str(bindir)+os.pathsep+os.environ['PATH'],TEST_HYDROGEN=str(state),TEST_CALLS=str(calls));env.pop('AIFLOW_DASHBOARD',None);env.pop('VSCODE_PROXY_URI',None)+ runs=[p/'one',p/'two'];children=[]+ def cmd(run,*args,extra=None):+ r=subprocess.run([binary,'--run',str(run),'--ai-thread','dashboard-test',*args],env=extra or env,capture_output=True,text=True,timeout=35)+ assert r.returncode==0,(r.stdout,r.stderr);return r+ def opens():return sum('open-or-refresh'in json.loads(l)for l in calls.read_text().splitlines())+ try:+ cmd(runs[0],'intake','--prompt-time','2026-10-09T10:00:00Z','--engine','codex');assert opens()==1+ a=json.loads((runs[0]/'dashboard.json').read_text());assert json.load(urllib.request.urlopen(a['url']+'dashboard.json'))['runId']==a['runId']+ cmd(runs[0],'status');assert opens()==1,'repeated command must reuse tab'+ state.write_text('{}');cmd(runs[0],'status');assert opens()==2,'closed tab must reopen'+ cmd(runs[1],'intake','--prompt-time','2026-10-09T10:00:00Z','--engine','claude');b=json.loads((runs[1]/'dashboard.json').read_text());assert a['port']!=b['port'] and a['name']!=b['name'];assert opens()==3+ assert json.load(urllib.request.urlopen(a['url']+'dashboard.json'))['runId']==a['runId'],'second run stole first server'+ cmd(runs[0],'prompt','--text','resume');assert opens()==4,'resume must surface tab'+ for line in subprocess.check_output(['ps','-eo','pid,args'],text=True).splitlines():+ if binary in line and str(runs[0]) in line and 'timeline serve' in line:os.kill(int(line.split()[0]),signal.SIGTERM)+ time.sleep(.2);cmd(runs[0],'status');assert opens()==4,'server restart should reuse the tab'+ assert json.load(urllib.request.urlopen(a['url']+'dashboard.json'))['runId']==a['runId']++ # Persistent server activity must not leave a never-ending ledger turn.+ d=json.loads((runs[0]/'run.json').read_text());assert 'timeline serve'not in (runs[0]/'run.jsonl').read_text()+ off=dict(env,AIFLOW_DASHBOARD='off');cmd(p/'headless','intake','--prompt-time','2026-10-09T10:00:00Z',extra=off);assert not (p/'headless'/'dashboard.json').exists()+ # Failed Hydrogen read is a visible warning, not a fake successful display or failed board command.+ mock.write_text('#!/bin/sh\nexit 1\n');cmd(runs[0],'status')+ r=cmd(runs[0],'status');assert 'Dashboard unavailable'in r.stderr+ print('PASS: default intake, reuse, closed-tab recovery, per-run server isolation, resume, stopped-server recovery, headless override, visible UI failure, server excluded from ledger')+ finally:+ # Only terminate test-owned timeline processes identified by exact temporary run path.+ for line in subprocess.check_output(['ps','-eo','pid,args'],text=True).splitlines():+ if binary in line and root in line and 'timeline serve'in line:+ os.kill(int(line.split()[0]),signal.SIGTERM)