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
Replace per-client dashboard disk polling with native filesystem notifications and shared SSE cache
12 files changed
+440−34
Cargo.lock+221−12@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-aiflow"-version = "0.1.89"+version = "0.1.90" dependencies = [ "aiflow-analyze", "aiflow-board",@@ -16,6 +16,7 @@ dependencies = [ "aiflow-router", "aiflow-run", "clap",+ "notify", "regex-lite", "serde", "serde_json",@@ -24,7 +25,7 @@ dependencies = [ [[package]] name = "aiflow-analyze"-version = "0.1.89"+version = "0.1.90" dependencies = [ "aiflow-board", "serde",@@ -34,7 +35,7 @@ dependencies = [ [[package]] name = "aiflow-board"-version = "0.1.89"+version = "0.1.90" dependencies = [ "roxmltree", "serde",@@ -43,7 +44,7 @@ dependencies = [ [[package]] name = "aiflow-bridge"-version = "0.1.89"+version = "0.1.90" dependencies = [ "aiflow-board", "serde",@@ -52,7 +53,7 @@ dependencies = [ [[package]] name = "aiflow-copper"-version = "0.1.89"+version = "0.1.90" dependencies = [ "aiflow-board", "aiflow-grid",@@ -62,7 +63,7 @@ dependencies = [ [[package]] name = "aiflow-grid"-version = "0.1.89"+version = "0.1.90" dependencies = [ "aiflow-board", "serde",@@ -71,7 +72,7 @@ dependencies = [ [[package]] name = "aiflow-place"-version = "0.1.89"+version = "0.1.90" dependencies = [ "aiflow-board", "serde",@@ -80,7 +81,7 @@ dependencies = [ [[package]] name = "aiflow-pours"-version = "0.1.89"+version = "0.1.90" dependencies = [ "aiflow-board", "aiflow-copper",@@ -90,7 +91,7 @@ dependencies = [ [[package]] name = "aiflow-router"-version = "0.1.89"+version = "0.1.90" dependencies = [ "aiflow-board", "aiflow-grid",@@ -100,7 +101,7 @@ dependencies = [ [[package]] name = "aiflow-run"-version = "0.1.89"+version = "0.1.90" dependencies = [ "serde", "serde_json",@@ -142,7 +143,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [- "windows-sys",+ "windows-sys 0.61.2", ] [[package]]@@ -153,9 +154,15 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill",- "windows-sys",+ "windows-sys 0.61.2", ] +[[package]]+name = "bitflags"+version = "2.13.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"+ [[package]] name = "block-buffer" version = "0.10.4"@@ -246,6 +253,15 @@ dependencies = [ "crypto-common", ] +[[package]]+name = "fsevent-sys"+version = "4.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"+dependencies = [+ "libc",+]+ [[package]] name = "generic-array" version = "0.14.7"@@ -262,6 +278,26 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]]+name = "inotify"+version = "0.11.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592"+dependencies = [+ "bitflags",+ "inotify-sys",+ "libc",+]++[[package]]+name = "inotify-sys"+version = "0.1.8"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d"+dependencies = [+ "libc",+]+ [[package]] name = "is_terminal_polyfill" version = "1.70.2"@@ -274,18 +310,83 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]]+name = "kqueue"+version = "1.2.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea"+dependencies = [+ "kqueue-sys",+ "libc",+]++[[package]]+name = "kqueue-sys"+version = "1.1.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087"+dependencies = [+ "bitflags",+ "libc",+]+ [[package]] name = "libc" version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]]+name = "log"+version = "0.4.34"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"+ [[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]]+name = "mio"+version = "1.2.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"+dependencies = [+ "libc",+ "log",+ "wasi",+ "windows-sys 0.61.2",+]++[[package]]+name = "notify"+version = "8.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3"+dependencies = [+ "bitflags",+ "fsevent-sys",+ "inotify",+ "kqueue",+ "libc",+ "log",+ "mio",+ "notify-types",+ "walkdir",+ "windows-sys 0.60.2",+]++[[package]]+name = "notify-types"+version = "2.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a"+dependencies = [+ "bitflags",+]+ [[package]] name = "once_cell_polyfill" version = "1.70.2"@@ -322,6 +423,15 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" +[[package]]+name = "same-file"+version = "1.0.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"+dependencies = [+ "winapi-util",+]+ [[package]] name = "serde" version = "1.0.229"@@ -417,12 +527,46 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]]+name = "walkdir"+version = "2.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"+dependencies = [+ "same-file",+ "winapi-util",+]++[[package]]+name = "wasi"+version = "0.11.1+wasi-snapshot-preview1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"++[[package]]+name = "winapi-util"+version = "0.1.11"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"+dependencies = [+ "windows-sys 0.61.2",+]+ [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]]+name = "windows-sys"+version = "0.60.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"+dependencies = [+ "windows-targets",+]+ [[package]] name = "windows-sys" version = "0.61.2"@@ -432,6 +576,71 @@ dependencies = [ "windows-link", ] +[[package]]+name = "windows-targets"+version = "0.53.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"+dependencies = [+ "windows-link",+ "windows_aarch64_gnullvm",+ "windows_aarch64_msvc",+ "windows_i686_gnu",+ "windows_i686_gnullvm",+ "windows_i686_msvc",+ "windows_x86_64_gnu",+ "windows_x86_64_gnullvm",+ "windows_x86_64_msvc",+]++[[package]]+name = "windows_aarch64_gnullvm"+version = "0.53.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"++[[package]]+name = "windows_aarch64_msvc"+version = "0.53.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"++[[package]]+name = "windows_i686_gnu"+version = "0.53.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"++[[package]]+name = "windows_i686_gnullvm"+version = "0.53.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"++[[package]]+name = "windows_i686_msvc"+version = "0.53.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"++[[package]]+name = "windows_x86_64_gnu"+version = "0.53.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"++[[package]]+name = "windows_x86_64_gnullvm"+version = "0.53.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"++[[package]]+name = "windows_x86_64_msvc"+version = "0.53.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"+ [[package]] name = "zmij" version = "1.0.23"
Cargo.toml+1−1@@ -14,7 +14,7 @@ members = [ ] [workspace.package]-version = "0.1.89"+version = "0.1.90" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
SKILL.md+4@@ -409,3 +409,7 @@ The main timeline uses the app-creator SSE channel: CLI workflow commands update 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.++### Event-driven dashboard state (0.1.90)++The server uses one native filesystem watcher per run and a shared state cache. CLI presentation controls notify SSE clients immediately; external timeline/presentation edits trigger native filesystem notifications, including atomic replacements. Idle SSE connections do not poll or reread state files. The 15-second heartbeat only writes a connection comment. Native watcher failures are reported rather than silently falling back to server-side polling. `/health` reports `stateSource`, `stateFileReads`, and `watcherError` for verification. Keep the existing webview open during server upgrades; SSE reconnects automatically.
bin/adom-aiflow⋯ 1 unchanged line ⋯
crates/adom-aiflow/Cargo.toml+1@@ -25,3 +25,4 @@ aiflow-bridge = { workspace = true } sha2 = "0.10" regex-lite = "0.1"+notify = "8"
crates/adom-aiflow/src/timeline_server.rs+116−20@@ -1,19 +1,100 @@ //! Timeline live channel. Presentation controls never write engineering acceptance.+use notify::{Event, EventKind, RecursiveMode, Watcher}; use serde_json::{json, Value}; use std::{ fs, io::{Read, Write}, net::{TcpListener, TcpStream}, path::Path,- sync::{Arc, Mutex},+ sync::{Arc, Condvar, 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"))})+// One cached snapshot and one native directory watch per server, not per client.+// The directory watch survives atomic file replacement. Access events are ignored+// so reading state cannot trigger another read through the watcher.+#[derive(Default)]+struct Cached {+ value: Value,+ revision: u64,+ reads: u64,+ watcher_error: Option<String>,+}+#[derive(Default)]+struct Live {+ cached: Mutex<Cached>,+ changed: Condvar,+ mutation: Mutex<()>,+}+impl Live {+ fn reload(&self, dir: &Path) {+ let _guard = self.mutation.lock().unwrap();+ self.reload_locked(dir);+ }+ fn reload_locked(&self, dir: &Path) {+ let mut cached = self.cached.lock().unwrap();+ let mut next = cached.value.clone();+ if !next.is_object() {+ next = json!({"timeline":null,"presentation":null});+ }+ for key in ["timeline", "presentation"] {+ cached.reads += 1;+ if let Some(value) =+ read_json(&dir.join(format!("{key}.json"))).filter(Value::is_object)+ {+ next[key] = value;+ }+ }+ // Partial writes/deletion retain the last valid value until a subsequent+ // filesystem event supplies a valid replacement. No retry timer.+ if next != cached.value {+ cached.value = next;+ cached.revision += 1;+ self.changed.notify_all();+ }+ }+ fn snapshot(&self) -> Value {+ self.cached.lock().unwrap().value.clone()+ }+}+fn relevant(event: &Event) -> bool {+ matches!(+ event.kind,+ EventKind::Create(_) | EventKind::Remove(_) | EventKind::Modify(_)+ ) && !matches!(+ event.kind,+ EventKind::Modify(notify::event::ModifyKind::Metadata(_))+ ) && event.paths.iter().any(|path| {+ matches!(+ path.file_name().and_then(|n| n.to_str()),+ Some("timeline.json" | "presentation.json")+ )+ })+}+fn watch(dir: &Path, live: Arc<Live>) -> Result<notify::RecommendedWatcher, String> {+ let root = dir.to_path_buf();+ let watched = live.clone();+ let mut watcher = notify::recommended_watcher(move |event: notify::Result<Event>| {+ match event {+ Ok(event) if relevant(&event) || event.need_rescan() => watched.reload(&root),+ Ok(_) => (),+ Err(error) => {+ // Do not silently substitute polling if a native watch fails.+ let message = error.to_string();+ eprintln!("timeline filesystem watcher failed: {message}");+ watched.cached.lock().unwrap().watcher_error = Some(message);+ }+ }+ })+ .map_err(|e| e.to_string())?;+ watcher+ .watch(dir, RecursiveMode::NonRecursive)+ .map_err(|e| e.to_string())?;+ live.reload(dir);+ Ok(watcher) } fn response(stream: &mut TcpStream, code: u16, value: Value) { let body = value.to_string();@@ -63,7 +144,7 @@ fn presentation(body: &Value) -> Result<Value, &'static str> { } Ok(body["presentation"].clone()) }-fn handle(mut stream: TcpStream, dir: &Path, mutation: &Mutex<()>) {+fn handle(mut stream: TcpStream, dir: &Path, live: &Live) { 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();@@ -87,20 +168,34 @@ fn handle(mut stream: TcpStream, dir: &Path, mutation: &Mutex<()>) { 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 revision = None; 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 {+ let cached = live.cached.lock().unwrap();+ let cached = if revision == Some(cached.revision) {+ live.changed+ .wait_timeout_while(+ cached,+ Duration::from_secs(15).saturating_sub(heartbeat.elapsed()),+ |state| revision == Some(state.revision),+ )+ .unwrap()+ .0+ } else {+ cached+ };+ let next = cached.value.clone();+ let next_revision = cached.revision;+ drop(cached); // A slow client must never block state updates or peers.+ if revision != Some(next_revision) && next["timeline"].is_object() { if write!(stream, "event: state\ndata: {next}\n\n") .and_then(|_| stream.flush()) .is_err() { return; }- previous = next; }+ revision = Some(next_revision); if heartbeat.elapsed() >= Duration::from_secs(15) { if stream .write_all(b": heartbeat\n\n")@@ -111,22 +206,19 @@ fn handle(mut stream: TcpStream, dir: &Path, mutation: &Mutex<()>) { } heartbeat = Instant::now(); }- if !dir.is_dir() {- return;- }- std::thread::sleep(Duration::from_millis(250)); } } if method == "GET" && path == "/health" {+ let cached = live.cached.lock().unwrap(); 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"}),+ json!({"ok":cached.watcher_error.is_none(),"stateSource":"native-filesystem-events","stateFileReads":cached.reads,"watcherError":cached.watcher_error,"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));+ response(&mut stream, 200, live.snapshot()); return; } if method == "POST" && path == "/state" {@@ -169,7 +261,7 @@ fn handle(mut stream: TcpStream, dir: &Path, mutation: &Mutex<()>) { .unwrap_or(Value::Null); match presentation(&value) { Ok(patch) => {- let Ok(_guard) = mutation.lock() else {+ let Ok(_guard) = live.mutation.lock() else { return; }; let path = dir.join("presentation.json");@@ -182,7 +274,10 @@ fn handle(mut stream: TcpStream, dir: &Path, mutation: &Mutex<()>) { .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})),+ Ok(()) => {+ live.reload_locked(dir); // Notify immediately; watcher event is safely deduplicated.+ response(&mut stream, 200, json!({"ok":true,"presentation":merged}));+ } Err(e) => response(&mut stream, 500, json!({"ok":false,"error":e.to_string()})), } }@@ -225,11 +320,12 @@ fn handle(mut stream: TcpStream, dir: &Path, mutation: &Mutex<()>) { } 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(()));+ let live = Arc::new(Live::default());+ let _watcher = watch(dir, live.clone())?; for stream in listener.incoming().flatten() { let dir = dir.to_path_buf();- let mutation = mutation.clone();- std::thread::spawn(move || handle(stream, &dir, &mutation));+ let live = live.clone();+ std::thread::spawn(move || handle(stream, &dir, &live)); } Ok(()) }
docs/release-0.1.90-review.mdadded+7@@ -0,0 +1,7 @@+# Source review: removal of the polling state reader++Base: ae2a40e148992dd9a7ef085748e1c8bfd926f275.++The preflight correctly flags removal of private `fn state(dir: &Path)`. This is intentional: that function performed uncached disk reads on every SSE iteration and GET /state. Its callers now use `Live::snapshot` or the condition-variable-driven cache. `Live::reload_locked` is the only timeline/presentation cache reader and runs at watcher initialization, relevant filesystem notifications, and successful presentation POSTs. No public CLI command, handler route, or engineering gate was removed. All diff hunks, including dependency lock additions, were reviewed. The preflight checker was not weakened or bypassed by renaming obsolete code.++The old periodic `dir.is_dir()` check is also removed with the polling loop. Server lifetime is still explicit; deleting a watched directory is not a shutdown command. Native watch errors are logged and visible through health. Partial/missing state retains the last valid cache until a valid replacement event, with no timed retries.
docs/release-0.1.90.mdadded+9@@ -0,0 +1,9 @@+# 0.1.90: idle dashboards stop polling disk++0.1.89 streamed browser updates over SSE but reread timeline.json and presentation.json every 250 ms for each connected client. This release removes that loop.++One native filesystem watcher observes the run's timeline directory. Relevant file changes refresh one shared state cache and wake subscribed clients; presentation POSTs notify immediately after the durable write. Watching the directory preserves updates across atomic renames. Invalid partial writes retain the last valid state until the next file event. Reconnects replay the cache. The 15-second SSE heartbeat does not read state files, and unrelated directory events and file-read notifications are ignored.++Native watcher startup failures refuse startup, and later errors appear in logs and health rather than silently switching to polling. `/health` exposes `stateSource: native-filesystem-events`, `stateFileReads`, and `watcherError`. The browser's existing disconnected/static-export fallback is unchanged.++Validation: production compilation, Cargo workspace tests, frontend SSE/fallback tests, component/widget integration, dashboard lifecycle tests, and native watcher integration. The latter checks two subscribers across a heartbeat with zero extra state-file reads, external writes, atomic replacements, partial-write recovery, workflow changes, HTTP control, concurrent patch merging, and reconnect replay. Linux native notifications are exercised here; other platforms use notify's native backends but were not runtime-tested in this release.
package.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-aiflow", "type": "app",- "version": "0.1.89",+ "version": "0.1.90", "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+4@@ -367,3 +367,7 @@ The main timeline uses the app-creator SSE channel: CLI workflow commands update 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.++### Event-driven dashboard state (0.1.90)++The server uses one native filesystem watcher per run and a shared state cache. CLI presentation controls notify SSE clients immediately; external timeline/presentation edits trigger native filesystem notifications, including atomic replacements. Idle SSE connections do not poll or reread state files. The 15-second heartbeat only writes a connection comment. Native watcher failures are reported rather than silently falling back to server-side polling. `/health` reports `stateSource`, `stateFileReads`, and `watcherError` for verification. Keep the existing webview open during server upgrades; SSE reconnects automatically.
tests/timeline-notify.pyadded+72@@ -0,0 +1,72 @@+"""Native filesystem events, idle I/O, shared SSE cache and atomic-write recovery.+No Hydrogen tabs or live run files are touched.+"""+import concurrent.futures, json, os, pathlib, socket, subprocess, sys, tempfile, time, urllib.request+binary = str(pathlib.Path(sys.argv[1]).resolve())+with tempfile.TemporaryDirectory(prefix='aiflow-native-watch-') as root:+ run = pathlib.Path(root) / 'run'+ env = dict(os.environ, AIFLOW_DASHBOARD='off')+ subprocess.run([binary, '--run', str(run), '--ai-thread', 'native-watch-test', 'intake', '--prompt-time', '2026-10-11T10:00:00Z', '--engine', 'codex'], env=env, check=True, capture_output=True)+ with socket.socket() as sock:+ sock.bind(('127.0.0.1', 0)); port = sock.getsockname()[1]+ url = f'http://127.0.0.1:{port}/'+ proc = subprocess.Popen([binary, '--run', str(run), 'timeline', 'serve', '--port', str(port)], env=env, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)+ streams = []+ def get(path):+ return json.load(urllib.request.urlopen(url + path, timeout=3))+ def event(stream):+ while True:+ line = stream.readline().decode()+ assert line, 'SSE closed'+ if line.startswith('data: '): return json.loads(line[6:])+ try:+ for attempt in range(100):+ try: health = get('health'); break+ except OSError: time.sleep(.05)+ else: raise AssertionError('server did not start')+ assert health['stateSource'] == 'native-filesystem-events'+ for _ in range(2):+ stream = urllib.request.urlopen(url + 'events', timeout=20)+ streams.append(stream); assert event(stream)['timeline']['steps']+ baseline = get('health')['stateFileReads']+ # Long enough to cover a heartbeat and 60 old polling iterations per client.+ start = time.monotonic()+ while b'heartbeat' not in streams[0].readline():+ assert time.monotonic() - start < 18+ assert get('health')['stateFileReads'] == baseline, 'idle SSE heartbeat read disk'+ timeline = run / 'timeline'+ (timeline / 'unrelated.txt').write_text('not state')+ time.sleep(.3)+ assert get('health')['stateFileReads'] == baseline, 'unrelated file caused state reads'+ state = get('state')+ assert get('health')['stateFileReads'] == baseline, 'GET /state bypassed cache'+ # In-place external write broadcasts to both subscribers.+ (timeline / 'presentation.json').write_text(json.dumps({'caption':'external write'}))+ for stream in streams: assert event(stream)['presentation']['caption'] == 'external write'+ # Partial writes do not erase the last valid presentation.+ (timeline / 'presentation.json').write_text('{')+ time.sleep(.2)+ assert get('state')['presentation']['caption'] == 'external write'+ # Atomic rename keeps the directory watch alive.+ tmp = timeline / 'external.tmp'+ tmp.write_text(json.dumps({'caption':'atomic rename'})); tmp.replace(timeline / 'presentation.json')+ for stream in streams: assert event(stream)['presentation']['caption'] == 'atomic rename'+ # Workflow file replacement, not only presentation controls.+ state['timeline']['notificationTest'] = 'workflow changed'+ tmp.write_text(json.dumps(state['timeline'])); tmp.replace(timeline / 'timeline.json')+ for stream in streams: assert event(stream)['timeline']['notificationTest'] == 'workflow changed'+ def post(patch):+ request = urllib.request.Request(url+'state', data=json.dumps({'aiThread':'native-watch-test','presentation':patch}).encode(), headers={'Content-Type':'application/json'})+ return json.load(urllib.request.urlopen(request, timeout=3))+ assert post({'caption':'HTTP immediate'})['ok']+ for stream in streams: assert event(stream)['presentation']['caption'] == 'HTTP immediate'+ with concurrent.futures.ThreadPoolExecutor() as pool:+ assert all(r['ok'] for r in pool.map(post, [{'title':'concurrent title'}, {'summary':'concurrent summary'}]))+ current = get('state')['presentation']+ assert current['title'] == 'concurrent title' and current['summary'] == 'concurrent summary'+ replay = urllib.request.urlopen(url+'events', timeout=3); streams.append(replay)+ assert event(replay)['presentation'] == current+ print(json.dumps({'result':'PASS','idleSeconds':round(time.monotonic()-start,2),'idleStateFileReads':0,'subscribers':2,'heartbeatWithoutReads':True,'externalWrite':True,'atomicRename':True,'partialWriteRecovery':True,'workflowUpdate':True,'httpControl':True,'concurrentMerge':True,'reconnectReplay':True}))+ finally:+ for stream in streams: stream.close()+ proc.terminate(); proc.wait(timeout=5)
tools/timeline/README.md+4@@ -73,3 +73,7 @@ The optional presentation arrives with timeline SSE state; static exports use po ### 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.++### Event-driven dashboard state (0.1.90)++The server uses one native filesystem watcher per run and a shared state cache. CLI presentation controls notify SSE clients immediately; external timeline/presentation edits trigger native filesystem notifications, including atomic replacements. Idle SSE connections do not poll or reread state files. The 15-second heartbeat only writes a connection comment. Native watcher failures are reported rather than silently falling back to server-side polling. `/health` reports `stateSource`, `stateFileReads`, and `watcherError` for verification. Keep the existing webview open during server upgrades; SSE reconnects automatically.