← Commit history
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)