← Commit history
AGENTS.md+2
@@ -13,3 +13,5 @@ Source handoff: issue #28 supplied the v0.1.25 base at f31cc251. Preserve its re ## Concurrent edits and PR hygiene  Before any source push or PR, read `dev-skills/review-safe-contributions/SKILL.md` and run `tools/pr_preflight.py`. Start from an immutable current wiki HEAD, recheck before submission, review every deletion, and prefer contextual branch PRs over file-set replacements. Never overwrite a newer module/verb registry with an older complete file. Rust changes require production compilation as well as tests so a function hidden under `cfg(test)` cannot pass unnoticed. Record native acceptance separately.++UI isolation: integration tests must set `AIFLOW_DASHBOARD=off` unless they replace `adom-cli` with a test-owned mock. Never open fixture runs in the user workspace. Dashboard changes require CLI-to-SSE, reconnect replay, repeated-open/tab-identity and real existing-tab checks. Read app-creator live-channel, lifecycle, identity and surfaces skills.
Cargo.lock+10−10
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-aiflow"-version = "0.1.88"+version = "0.1.89" dependencies = [  "aiflow-analyze",  "aiflow-board",@@ -24,7 +24,7 @@ dependencies = [  [[package]] name = "aiflow-analyze"-version = "0.1.88"+version = "0.1.89" dependencies = [  "aiflow-board",  "serde",@@ -34,7 +34,7 @@ dependencies = [  [[package]] name = "aiflow-board"-version = "0.1.88"+version = "0.1.89" dependencies = [  "roxmltree",  "serde",@@ -43,7 +43,7 @@ dependencies = [  [[package]] name = "aiflow-bridge"-version = "0.1.88"+version = "0.1.89" dependencies = [  "aiflow-board",  "serde",@@ -52,7 +52,7 @@ dependencies = [  [[package]] name = "aiflow-copper"-version = "0.1.88"+version = "0.1.89" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -62,7 +62,7 @@ dependencies = [  [[package]] name = "aiflow-grid"-version = "0.1.88"+version = "0.1.89" dependencies = [  "aiflow-board",  "serde",@@ -71,7 +71,7 @@ dependencies = [  [[package]] name = "aiflow-place"-version = "0.1.88"+version = "0.1.89" dependencies = [  "aiflow-board",  "serde",@@ -80,7 +80,7 @@ dependencies = [  [[package]] name = "aiflow-pours"-version = "0.1.88"+version = "0.1.89" dependencies = [  "aiflow-board",  "aiflow-copper",@@ -90,7 +90,7 @@ dependencies = [  [[package]] name = "aiflow-router"-version = "0.1.88"+version = "0.1.89" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -100,7 +100,7 @@ dependencies = [  [[package]] name = "aiflow-run"-version = "0.1.88"+version = "0.1.89" dependencies = [  "serde",  "serde_json",
Cargo.toml+1−1
@@ -14,7 +14,7 @@ members = [ ]  [workspace.package]-version = "0.1.88"+version = "0.1.89" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
SKILL.md+8
@@ -401,3 +401,11 @@ Use `verify-pours --plan <reviewed-plan.json>` for read-only validation of alrea ### 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.++## One live dashboard per run++The main timeline uses the app-creator SSE channel: CLI workflow commands update durable run files and the server pushes state to the existing page. `timeline present --json '{"caption":"Current passed; thermal open","image":"review.png"}' --run <run> --ai-thread <name>` sends presentation changes through the server's HTTP control endpoint; it never changes engineering gates. Images must already exist in `<run>/timeline/`. Reconnect replays current state. Static exports and older servers retain polling fallback.++Do not reopen the webview to show progress. `timeline open` activates the existing named tab; a changed proxy URL navigates that tab. Repeated status/step/artifact calls reuse it. Keep one tab for this run. Tests must set `AIFLOW_DASHBOARD=off` unless Hydrogen is explicitly mocked; never let a temporary fixture open the user's workspace.++After a live-channel upgrade, restart only the verified server for this run, preserving the tab and its URL. Inspect `/health` for the active version and `liveChannel: sse`; an installed new binary does not update a running old server.
crates/adom-aiflow/src/dashboard.rs+17−1
@@ -99,6 +99,15 @@ pub fn has_tab(value: &Value, name: &str, url: &str) -> bool {     } } +fn has_named_tab(value: &Value, name: &str) -> bool {+    match value {+        Value::Object(o) => o.get("panelState").map(|s| s["displayName"] == name).unwrap_or(false)+            || o.values().any(|v| has_named_tab(v, name)),+        Value::Array(a) => a.iter().any(|v| has_named_tab(v, name)),+        _ => false,+    }+}+ pub fn proxy_url(template: Option<&str>, port: u16) -> String {     template         .filter(|s| s.contains("{{port}}"))@@ -205,7 +214,14 @@ pub fn ensure(     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) {+    if has_tab(&workspace, &name, &url) {+        if force_show {+            command(&["hydrogen", "workspace", "active-tab", "--name", &name], 8)?;+        }+    } else if has_named_tab(&workspace, &name) {+        // A changed proxy/port is navigation of this run's existing tab, never creation.+        command(&["hydrogen", "webview", "navigate", &url, "--name", &name], 8)?;+    } else {         // 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}");
crates/adom-aiflow/src/main.rs+13−23
@@ -4,6 +4,7 @@ mod continuation; mod artifact_store; mod pourverify; mod dashboard;+mod timeline_server; mod silkscreen_sections; mod sequence; mod testplan;@@ -12,7 +13,7 @@ mod thermalvias; mod outline; mod browser_access; -/// The timeline dashboard (tools/timeline/index.html): it polls timeline.json beside it.+/// The timeline dashboard (tools/timeline/index.html): it receives state over SSE from its run server. const TIMELINE_PAGE: &str = include_str!("../../../tools/timeline/index.html"); mod library_review; mod checks;@@ -87,7 +88,7 @@ enum Cmd {     /// `outline round`: the rounded-corners step. Every sharp outside corner of the board outline becomes an arc of spec.outline.cornerRadius (default 1.0 mm): live in Fusion (MITER at each corner of the dimension layer), checked on the board after; `outline check` only reports. finish refuses a sharp corner.     Outline { #[arg(value_parser = ["round", "check"])] action: String, #[arg(long)] radius: Option<f64> },     /// `timeline open`: the flow's timeline dashboard in a Hydrogen webview (the right pane): every required step in order, the current one, how many to go, a check on each done step and an arc for every return. `timeline serve` serves <run>/timeline/ (open starts it when needed); `timeline write` refreshes timeline.json (every `step` does).-    Timeline { #[arg(value_parser = ["open", "serve", "write"])] action: String, #[arg(long, default_value_t = 8893)] port: u16 },+    Timeline { #[arg(value_parser = ["open", "serve", "write", "present"])] action: String, #[arg(long, default_value_t = 8893)] port: u16, #[arg(long)] json: Option<String> },     Testplan { #[arg(value_parser = ["check"])] action: String, #[arg(long)] tests: Option<String>, #[arg(long)] netlist: Option<String>, #[arg(long)] add: bool },     Rules { #[arg(value_parser = ["use", "install", "check", "list"])] action: String, #[arg(long)] profile: Option<String>, #[arg(long)] board: Option<String>, /// replace an existing .kicad_dru     #[arg(long)] force: bool, /// copper layers (default 2)@@ -3553,7 +3554,7 @@ fn main() {             if !after.is_empty() { err(&format!("{} corner(s) still sharp after MITER: {}", after.len(), after.iter().map(|p| format!("({:.2}, {:.2})", p.0, p.1)).collect::<Vec<_>>().join(", ")), &["Look at the corner in Fusion: an outline drawn as one polyline may need the corner selected; run the MITER by hand there, then `outline check`.".into()]); }             ok(&format!("{done} board corner(s) rounded to {rad} mm; the outline has no sharp corner"), &["Copper keeps its edge clearance from the new arcs: refill the pours (`land pours` again) if a pour followed the old corner.".into()]);         }-        Cmd::Timeline { action, port } => {+        Cmd::Timeline { action, port, json: presentation } => {             let mut r = load_run(&cli);             let flow_full: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap();             if r.data["steps"].as_object().map(|o| o.keys().all(|k| k == "intake")).unwrap_or(true) {@@ -3567,26 +3568,15 @@ fn main() {             match action.as_str() {                 "write" => ok(&format!("timeline written: step {} of {}, {} done", tl["position"], tl["total"], tl["done"]), &[]),                 "serve" => {-                    let l = std::net::TcpListener::bind(("0.0.0.0", *port)).unwrap_or_else(|e| err(&format!("port {port}: {e}"), &["Another timeline server may already be running: `timeline open` reuses it.".into()]));-                    eprintln!("serving {} on port {port}", tdir.display());-                    for st in l.incoming().flatten() {-                        let tdir = tdir.clone();-                        std::thread::spawn(move || {-                            use std::io::{Read, Write};-                            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();-                            let mut name = if path.is_empty() || path.ends_with('/') { "index.html".to_string() } else { path.rsplit('/').next().unwrap_or("index.html").to_string() };-                            if name == "favicon.ico" { name = "favicon.svg".into(); }-                            let file = tdir.join(&name);-                            let (code, body, ctype) = if !name.contains("..") && file.is_file() {-                                (200, std::fs::read(&file).unwrap_or_default(), if name.ends_with(".json") { "application/json" } else if name.ends_with(".html") { "text/html; charset=utf-8" } else if name.ends_with(".svg") { "image/svg+xml" } else { "application/octet-stream" })-                            } else { (404, b"not found".to_vec(), "text/plain") };-                            let _ = write!(st, "HTTP/1.1 {code} OK\r\nContent-Type: {ctype}\r\nContent-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n", body.len());-                            let _ = st.write_all(&body);-                        });-                    }+                    timeline_server::serve(&tdir,*port).unwrap_or_else(|e|err(&e,&["Use the existing run server; do not create another dashboard.".into()]));+                }+                "present" => {+                    let caller=thread(&cli);+                    let patch:Value=serde_json::from_str(presentation.as_deref().unwrap_or("{}")).unwrap_or_else(|e|err(&e.to_string(),&[]));+                    dashboard::ensure(&dir,&caller,None,false).unwrap_or_else(|e|err(&e,&[]));+                    let config=read_json(&dir.join("dashboard.json").to_string_lossy());+                    let reply=timeline_server::update(config["port"].as_u64().unwrap() as u16,&caller,&patch).unwrap_or_else(|e|err(&e,&["The existing server must support the current live channel. Restart only this run server after an upgrade.".into()]));+                    ok(&format!("presentation updated in the existing dashboard: {}",reply["presentation"]), &["SSE updates the open page in place; engineering gates are unchanged.".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());
crates/adom-aiflow/src/timeline_server.rsadded+269
@@ -0,0 +1,269 @@+//! Timeline live channel. Presentation controls never write engineering acceptance.+use serde_json::{json, Value};+use std::{+    fs,+    io::{Read, Write},+    net::{TcpListener, TcpStream},+    path::Path,+    sync::{Arc, Mutex},+    time::{Duration, Instant},+};++fn read_json(path: &Path) -> Option<Value> {+    serde_json::from_slice(&fs::read(path).ok()?).ok()+}+fn state(dir: &Path) -> Value {+    json!({"timeline":read_json(&dir.join("timeline.json")),"presentation":read_json(&dir.join("presentation.json"))})+}+fn response(stream: &mut TcpStream, code: u16, value: Value) {+    let body = value.to_string();+    let _=write!(stream,"HTTP/1.1 {code} Response\r\nContent-Type: application/json\r\nContent-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n{body}",body.len());+}+fn presentation(body: &Value) -> Result<Value, &'static str> {+    if body["aiThread"]+        .as_str()+        .map(str::trim)+        .filter(|s| !s.is_empty())+        .is_none()+    {+        return Err("caller_identity_required");+    }+    let object = body["presentation"]+        .as_object()+        .ok_or("presentation_object_required")?;+    for (key, value) in object {+        if ![+            "image",+            "alt",+            "caption",+            "title",+            "summary",+            "projectTitle",+            "imageRevision",+        ]+        .contains(&key.as_str())+            || !value.is_string()+        {+            return Err("presentation_fields_must_be_known_strings");+        }+        if value.as_str().unwrap().len() > 8192 {+            return Err("presentation_field_too_long");+        }+    }+    if let Some(image) = object.get("image").and_then(Value::as_str) {+        if !image.is_empty()+            && (image.contains(['/', '\\', ':', '?', '#'])+                || image.contains("..")+                || ![".png", ".jpg", ".jpeg", ".webp"]+                    .iter()+                    .any(|ext| image.to_lowercase().ends_with(ext)))+        {+            return Err("image_must_be_a_local_image_filename");+        }+    }+    Ok(body["presentation"].clone())+}+fn handle(mut stream: TcpStream, dir: &Path, mutation: &Mutex<()>) {+    let _ = stream.set_read_timeout(Some(Duration::from_secs(5)));+    let _ = stream.set_write_timeout(Some(Duration::from_secs(5)));+    let mut request = Vec::new();+    let mut chunk = [0u8; 4096];+    let header_end = loop {+        match stream.read(&mut chunk) {+            Ok(0) | Err(_) => return,+            Ok(n) => request.extend_from_slice(&chunk[..n]),+        }+        if let Some(n) = request.windows(4).position(|w| w == b"\r\n\r\n") {+            break n + 4;+        }+        if request.len() > 16384 {+            response(&mut stream, 413, json!({"ok":false}));+            return;+        }+    };+    let head = String::from_utf8_lossy(&request[..header_end]).into_owned();+    let mut words = head.split_whitespace();+    let method = words.next().unwrap_or("");+    let path = words.next().unwrap_or("/").split('?').next().unwrap_or("/");+    if method == "GET" && path == "/events" {+        if write!(stream,"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nCache-Control: no-cache\r\nConnection: keep-alive\r\nX-Accel-Buffering: no\r\n\r\n:{}\n\n"," ".repeat(2048)).is_err(){return;}+        let mut previous = Value::Null;+        let mut heartbeat = Instant::now();+        loop {+            let next = state(dir);+            // Keep the last valid workflow during an incomplete external file write.+            if next["timeline"].is_object() && next != previous {+                if write!(stream, "event: state\ndata: {next}\n\n")+                    .and_then(|_| stream.flush())+                    .is_err()+                {+                    return;+                }+                previous = next;+            }+            if heartbeat.elapsed() >= Duration::from_secs(15) {+                if stream+                    .write_all(b": heartbeat\n\n")+                    .and_then(|_| stream.flush())+                    .is_err()+                {+                    return;+                }+                heartbeat = Instant::now();+            }+            if !dir.is_dir() {+                return;+            }+            std::thread::sleep(Duration::from_millis(250));+        }+    }+    if method == "GET" && path == "/health" {+        response(+            &mut stream,+            200,+            json!({"ok":true,"version":env!("CARGO_PKG_VERSION"),"pid":std::process::id(),"runId":read_json(&dir.join("dashboard.json")).unwrap_or(Value::Null)["runId"],"liveChannel":"sse"}),+        );+        return;+    }+    if method == "GET" && path == "/state" {+        response(&mut stream, 200, state(dir));+        return;+    }+    if method == "POST" && path == "/state" {+        if !head.lines().any(|l| {+            l.split_once(':')+                .map(|(k, v)| {+                    k.eq_ignore_ascii_case("content-type")+                        && v.trim().eq_ignore_ascii_case("application/json")+                })+                .unwrap_or(false)+        }) {+            response(+                &mut stream,+                415,+                json!({"ok":false,"error":"application_json_required"}),+            );+            return;+        }++        let length = head.lines().find_map(|line| {+            line.split_once(':')+                .filter(|(k, _)| k.eq_ignore_ascii_case("content-length"))+                .and_then(|(_, v)| v.trim().parse::<usize>().ok())+        });+        let Some(length) = length.filter(|n| *n <= 65536) else {+            response(+                &mut stream,+                413,+                json!({"ok":false,"error":"bounded_content_length_required"}),+            );+            return;+        };+        while request.len() < header_end + length {+            match stream.read(&mut chunk) {+                Ok(0) | Err(_) => return,+                Ok(n) => request.extend_from_slice(&chunk[..n]),+            }+        }+        let value = serde_json::from_slice::<Value>(&request[header_end..header_end + length])+            .unwrap_or(Value::Null);+        match presentation(&value) {+            Ok(patch) => {+                let Ok(_guard) = mutation.lock() else {+                    return;+                };+                let path = dir.join("presentation.json");+                let mut merged = read_json(&path)+                    .filter(Value::is_object)+                    .unwrap_or(json!({}));+                merged+                    .as_object_mut()+                    .unwrap()+                    .extend(patch.as_object().unwrap().clone());+                let tmp = dir.join("presentation.control.tmp");+                match fs::write(&tmp, merged.to_string()).and_then(|_| fs::rename(tmp, path)) {+                    Ok(()) => response(&mut stream, 200, json!({"ok":true,"presentation":merged})),+                    Err(e) => response(&mut stream, 500, json!({"ok":false,"error":e.to_string()})),+                }+            }+            Err(code) => response(&mut stream, 400, json!({"ok":false,"errorCode":code})),+        }+        return;+    }+    if method != "GET" {+        response(&mut stream, 405, json!({"ok":false}));+        return;+    }+    let name = path.trim_start_matches('/');+    let name = if name.is_empty() {+        "index.html"+    } else if name == "favicon.ico" {+        "favicon.svg"+    } else {+        name+    };+    if name.contains(['/', '\\']) || name.contains("..") {+        response(&mut stream, 404, json!({"ok":false}));+        return;+    }+    match fs::read(dir.join(name)) {+        Ok(body) => {+            let mime = if name.ends_with(".json") {+                "application/json"+            } else if name.ends_with(".html") {+                "text/html; charset=utf-8"+            } else if name.ends_with(".svg") {+                "image/svg+xml"+            } else {+                "application/octet-stream"+            };+            let _=write!(stream,"HTTP/1.1 200 OK\r\nContent-Type: {mime}\r\nContent-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n",body.len());+            let _ = stream.write_all(&body);+        }+        Err(_) => response(&mut stream, 404, json!({"ok":false})),+    }+}+pub fn serve(dir: &Path, port: u16) -> Result<(), String> {+    let listener = TcpListener::bind(("0.0.0.0", port)).map_err(|e| e.to_string())?;+    let mutation = Arc::new(Mutex::new(()));+    for stream in listener.incoming().flatten() {+        let dir = dir.to_path_buf();+        let mutation = mutation.clone();+        std::thread::spawn(move || handle(stream, &dir, &mutation));+    }+    Ok(())+}+pub fn update(port: u16, ai_thread: &str, patch: &Value) -> Result<Value, String> {+    let mut stream = TcpStream::connect(("127.0.0.1", port)).map_err(|e| e.to_string())?;+    stream+        .set_read_timeout(Some(Duration::from_secs(5)))+        .map_err(|e| e.to_string())?;+    let body = json!({"aiThread":ai_thread,"presentation":patch}).to_string();+    write!(stream,"POST /state HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",body.len()).map_err(|e|e.to_string())?;+    let mut reply = String::new();+    stream+        .read_to_string(&mut reply)+        .map_err(|e| e.to_string())?;+    let value: Value =+        serde_json::from_str(reply.split_once("\r\n\r\n").ok_or("invalid HTTP reply")?.1)+            .map_err(|e| e.to_string())?;+    if value["ok"] != true {+        return Err(value.to_string());+    }+    Ok(value)+}+#[cfg(test)]+mod tests {+    use super::*;+    #[test]+    fn presentation_cannot_change_gates_or_load_external_images() {+        assert!(presentation(&json!({"presentation":{}})).is_err());+        assert!(presentation(&json!({"aiThread":"load","presentation":{"done":true}})).is_err());+        for image in ["https://bad/x.png", "../x.png", "/x.png", "x.html"] {+            assert!(+                presentation(&json!({"aiThread":"load","presentation":{"image":image}})).is_err()+            );+        }+        assert!(presentation(&json!({"aiThread":"load","presentation":{"caption":"Thermal remains open","image":"review.png"}})).is_ok());+    }+}
docs/release-0.1.89.mdadded+5
@@ -0,0 +1,5 @@+# 0.1.89: one live AIFlow dashboard++Integration tests could open real temporary-run dashboards, and explicit resume/open could invoke webview creation again. Tests now disable real display unless Hydrogen is mocked. Existing run tabs are activated, and proxy changes navigate the same named tab. Ordinary progress does not reload or create a view.++The timeline receives state over SSE with reconnect replay, proxy padding and heartbeats. `timeline present --json` updates presentation through the server while preserving engineering acceptance. Static exports retain polling fallback. Restart only the verified run server after upgrading; `/health` identifies the live version and SSE support.
package.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-aiflow",   "type": "app",-  "version": "0.1.88",+  "version": "0.1.89",   "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.",
page.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-aiflow",   "type": "app",-  "version": "0.1.88",+  "version": "0.1.89",   "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/component-workflow.py+2
@@ -1,4 +1,6 @@ """Exercise early component inventory, optional marking and review-note preservation."""+import os+os.environ["AIFLOW_DASHBOARD"] = "off"  # Tests must never open the user workspace. import pathlib,subprocess,tempfile,sys,json binary,board,spec=map(lambda p:str(pathlib.Path(p).resolve()),sys.argv[1:]) with tempfile.TemporaryDirectory(prefix='aiflow-components-') as d:
tests/dashboard-workflow.py+26−3
@@ -1,5 +1,5 @@ """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+import json,os,pathlib,subprocess,sys,tempfile,time,urllib.request,urllib.error,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'@@ -11,6 +11,11 @@ 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'}))+elif args[:3]==['hydrogen','webview','navigate']:+ name=args[args.index('--name')+1]+ for tab in d.get('tabs',[]):+  if tab['panelState']['displayName']==name:tab['panelState']['url']=args[3]+ p.write_text(json.dumps(d));print(json.dumps({'ok':True})) 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)@@ -23,15 +28,33 @@ else:print('{}')   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'+  drift=json.loads(state.read_text());drift['tabs'][0]['panelState']['url']='http://old-proxy/';state.write_text(json.dumps(drift));cmd(runs[0],'status');assert opens()==1,'proxy change must navigate the existing named 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'+  cmd(runs[0],'prompt','--text','resume');assert opens()==3,'resume must activate the existing tab without reopening'   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'+  time.sleep(.2);cmd(runs[0],'status');assert opens()==3,'server restart should reuse the tab'   assert json.load(urllib.request.urlopen(a['url']+'dashboard.json'))['runId']==a['runId'] +  # CLI -> HTTP control -> SSE updates an existing view, and reconnect replays it.+  stream=urllib.request.urlopen(a['url']+'events',timeout=5)+  assert stream.headers['X-Accel-Buffering']=='no'+  def event(connection):+   while True:+    line=connection.readline().decode()+    if line.startswith('data: '):return json.loads(line[6:])+  initial=event(stream);assert initial['timeline']['steps']+  count=opens();cmd(runs[0],'timeline','present','--json',json.dumps({'caption':'Live test update','title':'No extra tab'}));assert opens()==count+  received=event(stream);assert received['presentation']['caption']=='Live test update'+  stream.close()+  stream=urllib.request.urlopen(a['url']+'events',timeout=5);assert event(stream)['presentation']['caption']=='Live test update';stream.close()+  req=urllib.request.Request(a['url']+'state',data=json.dumps({'presentation':{'caption':'anonymous'}}).encode(),headers={'Content-Type':'application/json'})+  try:urllib.request.urlopen(req);raise AssertionError('anonymous mutation accepted')+  except urllib.error.HTTPError as e:assert e.code==400 and 'caller_identity_required' in e.read().decode()+  assert json.load(urllib.request.urlopen(a['url']+'state'))['presentation']['caption']=='Live test update'+   # 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()
tests/timeline-sse.mjsadded+23
@@ -0,0 +1,23 @@+import fs from 'node:fs';+import vm from 'node:vm';+import assert from 'node:assert/strict';+const html=fs.readFileSync(new URL('../tools/timeline/index.html',import.meta.url),'utf8');+const code=html.slice(html.indexOf('  // One live page per run.'),html.indexOf('\n  let rz = 0;'));+let channel,fetches=0,renders=0,presentations=[],status;+class EventSource {constructor(url){assert.equal(url,'events');channel=this;this.handlers={};}addEventListener(name,fn){this.handlers[name]=fn;}close(){this.closed=true;}}+const ctx={EventSource,JSON,Date,AbortSignal,SRC:'timeline.json',lastOkAt:0,lastRaw:null,model:null,everOk:false,+ $:()=>({classList:{toggle(){}},querySelector:()=>({set textContent(v){status=v;}})}),+ document:{body:{classList:{remove(){}}}},normalize:x=>x?.steps?x:null,render:()=>renders++,refreshPresentation:async p=>presentations.push(p),+ setTimeout:()=>0,addEventListener(){},fetch:async()=>{fetches++;return {ok:true,json:async()=>({steps:['intake'],current:'intake'})};}};+vm.createContext(ctx);vm.runInContext(code+'\nglobalThis.transport=()=>liveChannel;',ctx);+const emit=state=>channel.handlers.state({data:JSON.stringify(state)});+const first={timeline:{steps:['thermal'],current:'thermal'},presentation:{caption:'Thermal open'}};+emit(first);assert.equal(ctx.transport(),'sse');assert.equal(renders,1);assert.equal(status,'Live');+emit(first);assert.equal(renders,1,'unchanged state must not rerender');+await ctx.poll();assert.equal(fetches,0,'connected SSE must not poll state or navigate');assert(presentations.length>=2);+emit({...first,presentation:{caption:'Current passed; thermal open'}});assert.equal(renders,1);assert.equal(presentations.at(-1).caption,'Current passed; thermal open');+emit({timeline:{steps:['thermal','fields'],current:'fields'}});assert.equal(renders,2);+channel.handlers.state({data:'{'});assert.equal(renders,2,'malformed event preserves last view');+channel.onerror();assert.equal(ctx.transport(),'reconnecting');await ctx.poll();assert.equal(fetches,1,'fallback remains available');+emit(first);assert.equal(ctx.transport(),'sse');assert.equal(ctx.model.current,'thermal');+console.log('PASS: relative SSE, in-place updates, no state polling while connected, presentation updates, reconnect and malformed-event preservation');
tools/timeline/README.md+5−1
@@ -68,4 +68,8 @@ The presentation file is preserved when CLI commands refresh the timeline. The community card links to the public MIT license, source and pull requests. Its prompt helps users contribute through their AI; follow the root skill's contribution guidance. -The optional presentation is polled along with the timeline. Changed captions and saved images appear without navigation. A missing/malformed file or failed image preserves the last good presentation; a valid object without `image` clears the image. For an overwritten image with otherwise identical metadata, change an `imageRevision` field to trigger a reload. Presentation text never changes engineering gate evidence.+The optional presentation arrives with timeline SSE state; static exports use polling. Changed captions and saved images appear without navigation. A missing/malformed file or failed image preserves the last good presentation; a valid object without `image` clears the image. For an overwritten image with otherwise identical metadata, change an `imageRevision` field to trigger a reload. Presentation text never changes engineering gate evidence.++### Live updates++`GET events` streams state over SSE, with current-state replay, proxy padding and 15-second heartbeats. `GET state` reads the current timeline/presentation. `POST state` accepts `{ "aiThread": "load design", "presentation": { "caption": "Thermal open" } }` as JSON. Only presentation strings are accepted; workflow acceptance remains derived from the run. The CLI exposes this as `timeline present --json <JSON>`. Keep the existing webview open. Offline/static copies fall back to polling.
tools/timeline/index.html+51−30
@@ -566,13 +566,17 @@ body.wide .stage { margin-top: 14px; }   let presentation = null;   let presentationRaw = null;   let presentationGeneration = 0;-  // Optional artwork is polled independently of measured workflow state.+  // Optional artwork updates independently of measured workflow state.   // Keep the last good view across half-written JSON and failed image loads.-  async function refreshPresentation() {+  async function refreshPresentation(incoming) {     try {-      const res = await fetch('presentation.json', {cache: 'no-store', signal: AbortSignal.timeout(3000)});-      if (!res.ok) return;-      const raw = await res.text();+      let raw;+      if (incoming !== undefined) raw = JSON.stringify(incoming);+      else {+        const res = await fetch('presentation.json', {cache: 'no-store', signal: AbortSignal.timeout(3000)});+        if (!res.ok) return;+        raw = await res.text();+      }       if (raw === presentationRaw) return;       const p = JSON.parse(raw);       if (!p || typeof p !== 'object' || Array.isArray(p)) return;@@ -1212,38 +1216,55 @@ body.wide .stage { margin-top: 14px; }   document.addEventListener('pointerleave', hideTip);   addEventListener('scroll', () => { if (tipFor) hideTip(); }, { passive: true }); -  // ---------------- polling ----------------+  // One live page per run. CLI writes are delivered without navigation or reload.+  let liveChannel = 'connecting';+  let livePresentation = null;   function setLive(state) {     const el = $('live');     el.classList.toggle('stale', state === 'stale');     el.classList.toggle('idle', state === 'idle');     el.querySelector('.lbl').textContent = state === 'stale' ? 'Reconnecting' : (state === 'idle' ? 'Waiting' : 'Live');   }+  function receiveTimeline(data) {+    const raw = JSON.stringify(data);+    const m = normalize(data);+    if (!m) return;+    lastOkAt = Date.now();+    if (raw !== lastRaw) {+      lastRaw = raw; model = m; everOk = true;+      document.body.classList.remove('waiting-on'); render(m);+    }+    setLive('live');+  }   async function poll() {-    try {-      const sep = SRC.includes('?') ? '&' : '?';-      const res = await fetch(SRC + sep + '_=' + Date.now(), { cache: 'no-store' });-      if (!res.ok) throw new Error('HTTP ' + res.status);-      const raw = await res.text();-      lastOkAt = Date.now();-      if (raw !== lastRaw) {-        const m = normalize(JSON.parse(raw));-        if (!m) throw new Error('no steps');-        lastRaw = raw;-        model = m;-        everOk = true;-        document.body.classList.remove('waiting-on');-        render(m);-      }-      setLive('live');-    } catch (err) {-      // a half-written file or a missing file: keep the last good picture, never a broken page-      if (!everOk) { document.body.classList.add('waiting-on'); setLive('idle'); }-      else if (Date.now() - lastOkAt > 4000) setLive('stale');-    } finally {-      await refreshPresentation();-      setTimeout(poll, 1000);+    // Compatibility only for static exports/older servers; SSE owns normal updates.+    if (liveChannel !== 'sse') {+      try {+        const sep = SRC.includes('?') ? '&' : '?';+        const res = await fetch(SRC + sep + '_=' + Date.now(), {cache:'no-store',signal:AbortSignal.timeout(3000)});+        if (!res.ok) throw new Error('HTTP ' + res.status);+        const snapshot = await res.json();+        if (liveChannel !== 'sse') {+          receiveTimeline(snapshot);+          await refreshPresentation();+        }+      } catch (_) { setLive(everOk ? 'stale' : 'idle'); }     }+    if (liveChannel === 'sse' && livePresentation) await refreshPresentation(livePresentation);+    setTimeout(poll, 3000);+  }+  if (typeof EventSource !== 'undefined' && SRC === 'timeline.json') {+    const channel = new EventSource('events');+    channel.addEventListener('state', event => {+      try {+        const state = JSON.parse(event.data);+        receiveTimeline(state.timeline);+        if (state.presentation) { livePresentation = state.presentation; refreshPresentation(livePresentation); }+        liveChannel = 'sse';+      } catch (_) { setLive('stale'); }+    });+    channel.onerror = () => { liveChannel = 'reconnecting'; setLive('stale'); };+    addEventListener('pagehide', () => channel.close(), {once:true});   }    let rz = 0;@@ -1255,7 +1276,7 @@ body.wide .stage { margin-top: 14px; }    // test and driving hook: window.flowTimeline.state() returns what is on screen   window.flowTimeline = {-    state: () => model && ({ current: model.current, wide, steps: model.steps.length, loops: model.loops.length }),+    state: () => model && ({ current: model.current, liveChannel, wide, steps: model.steps.length, loops: model.loops.length }),     tipCheck: () => { const r = tip.getBoundingClientRect(); return { on: tip.classList.contains('on'), left: r.left, top: r.top, right: r.right, bottom: r.bottom, vw: innerWidth, vh: innerHeight }; },     showTip: (key, x, y) => { cx = x; cy = y; tipFor = key; showTip(key); },   };