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.
main
Colby Knox
AI Flow 0.2.68: background media worker, invalidated events, blocker fingerprint, page item (#170 #171 #199)
95180a6
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751
//! The run manifest: one JSON file per run carrying the clock, the plan, the decisions, every
//! stage's start and end, the outcomes, the capture markers and the summary. Every command reads
//! and writes it, so the comparison between engines is what the file says.
use std::path::{Path, PathBuf};
use serde_json::{json, Map, Value};
pub const STAGES: [&str; 9] = ["place", "route", "pour", "gate", "land", "measure", "analyze-current", "analyze-thermal", "finish"];
/// Error text within `n` characters that keeps its beginning (where the cause is) and its end:
/// about 70% from the front, " ... ", the rest from the back (issue #76). Never cuts the front off.
pub fn clip_text(s: &str, n: usize) -> String {
let s = s.trim();
let count = s.chars().count();
if count <= n { return s.to_string(); }
let head = n * 7 / 10;
let tail = n.saturating_sub(head + 5);
let front: String = s.chars().take(head).collect();
let back: String = s.chars().skip(count - tail).collect();
format!("{front} ... {back}")
}
pub fn now() -> String {
let t = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
iso(t)
}
/// Seconds since the epoch as an ISO 8601 UTC timestamp.
pub fn iso(t: u64) -> String {
let days = t / 86400;
let rem = t % 86400;
let (h, m, s) = (rem / 3600, (rem % 3600) / 60, rem % 60);
// civil from days (Howard Hinnant's algorithm)
let z = days as i64 + 719468;
let era = if z >= 0 { z } else { z - 146096 } / 146097;
let doe = (z - era * 146097) as u64;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let mth = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if mth <= 2 { y + 1 } else { y };
format!("{y:04}-{mth:02}-{d:02}T{h:02}:{m:02}:{s:02}Z")
}
/// ISO 8601 UTC timestamp to seconds since the epoch (whole seconds).
pub fn parse(ts: &str) -> Option<u64> {
let b = ts.as_bytes();
if b.len() < 19 {
return None;
}
let n = |a: usize, l: usize| ts.get(a..a + l)?.parse::<i64>().ok();
let (y, mo, d, h, mi, s) = (n(0, 4)?, n(5, 2)?, n(8, 2)?, n(11, 2)?, n(14, 2)?, n(17, 2)?);
let y2 = if mo <= 2 { y - 1 } else { y };
let era = if y2 >= 0 { y2 } else { y2 - 399 } / 400;
let yoe = y2 - era * 400;
let mp = if mo > 2 { mo - 3 } else { mo + 9 };
let doy = (153 * mp + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
let days = era * 146097 + doe - 719468;
Some((days * 86400 + h * 3600 + mi * 60 + s) as u64)
}
/// One human prompt and the AI's work on it.
#[derive(Clone, Debug)]
pub struct Session {
pub n: usize,
pub prompt_at: u64,
pub done_at: Option<u64>,
pub text: String,
/// active spans (start, end), idle gaps cut out
pub spans: Vec<(u64, u64)>,
pub idle_seconds: u64,
/// true when the session has no done yet, or work went on after its done
pub open: bool,
}
impl Session {
pub fn active_seconds(&self) -> u64 { self.spans.iter().map(|(s, e)| e.saturating_sub(*s)).sum() }
}
pub struct Run {
pub dir: PathBuf,
pub data: Value,
}
impl Run {
pub fn open(dir: &Path) -> Option<Run> {
let f = dir.join("run.json");
let text = std::fs::read_to_string(&f).ok()?;
Some(Run { dir: dir.to_path_buf(), data: serde_json::from_str(&text).ok()? })
}
pub fn create(dir: &Path, board: &str, spec: &str, engine: &str, prompt_time: &str, ai_thread: &str, target: Option<&str>, remote_board: Option<&str>) -> Result<Run, String> {
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
let r = Run { dir: dir.to_path_buf(), data: json!({
"version": 1, "tool": "adom-aiflow", "engine": engine, "aiThread": ai_thread, "board": board, "spec": spec,
"target": target, "remoteBoard": remote_board,
"clock": {"promptTime": prompt_time, "startTime": now(), "finishTime": null, "deliveredTime": null},
"plan": null, "decisions": {}, "stages": {}, "captures": [], "markers": [], "boards": [board],
"boardAddedAt": {board: now()}, "outcomes": {}, "summary": null,
"currentStep": null, "steps": {}, "turns": [], "delivery": null,
}) };
r.save()?;
r.log("start", json!({"engine": engine, "aiThread": ai_thread, "board": board, "spec": spec, "promptTime": prompt_time, "target": target, "remoteBoard": remote_board}));
Ok(r)
}
/// A run that starts at the prompt, before any board exists: the clock runs, the `intake` step
/// is open, and `start --board` attaches the board later on the same clock and ledger.
pub fn create_intake(dir: &Path, prompt_time: &str, ai_thread: &str, engine: Option<&str>, brief: Option<&str>, requirements: Option<&str>, target: Option<&str>) -> Result<Run, String> {
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
let mut r = Run { dir: dir.to_path_buf(), data: json!({
"version": 1, "tool": "adom-aiflow", "engine": engine, "aiThread": ai_thread, "board": null, "spec": null,
"target": target, "remoteBoard": null, "intake": {"at": now(), "brief": brief, "requirements": requirements},
"requirements": requirements,
"clock": {"promptTime": prompt_time, "startTime": now(), "finishTime": null, "deliveredTime": null},
"plan": null, "decisions": {}, "stages": {}, "captures": [], "markers": [], "boards": [],
"outcomes": {}, "summary": null,
"currentStep": null, "steps": {}, "turns": [], "delivery": null,
}) };
r.save()?;
r.log("start", json!({"engine": engine, "aiThread": ai_thread, "board": null, "spec": null, "promptTime": prompt_time, "target": target, "intake": true, "brief": brief, "requirements": requirements}));
r.step_set("intake", false, None);
r.save()?;
Ok(r)
}
/// True for a run made by `intake` that has no board yet.
pub fn board_pending(&self) -> bool {
self.data["boards"].as_array().map(|a| a.is_empty()).unwrap_or(true)
}
/// Attach the first board (and its spec) to an intake run: the clock, the steps and the
/// ledger are kept; only the board, the spec and the desktop target are filled in.
pub fn attach_board(&mut self, board: &str, spec: &str, engine: &str, target: Option<&str>, remote_board: Option<&str>) {
self.data["board"] = json!(board);
self.data["boards"] = json!([board]);
self.note_board_added(board);
self.data["spec"] = json!(spec);
self.data["engine"] = json!(engine);
if target.is_some() { self.data["target"] = json!(target); }
self.data["remoteBoard"] = json!(remote_board);
self.log("board-attached", json!({"engine": engine, "board": board, "spec": spec, "target": self.data["target"], "remoteBoard": remote_board, "promptTime": self.data["clock"]["promptTime"]}));
}
/// Append one event to run.jsonl, the append-only ledger: one line per event, never rewritten.
/// run.json is the derived state; when the two disagree, the ledger is the record.
pub fn log(&self, event: &str, mut fields: Value) {
fields["t"] = json!(now());
fields["event"] = json!(event);
let line = fields.to_string() + "\n";
let f = self.dir.join("run.jsonl");
if let Ok(mut fh) = std::fs::OpenOptions::new().create(true).append(true).open(&f) {
use std::io::Write;
let _ = fh.write_all(line.as_bytes());
}
}
/// Append an event exactly as another process recorded it (its own time and fields): the media worker's
/// lines, folded in by the run's single writer (issue #170).
pub fn log_raw(&self, event: &Value) {
let f = self.dir.join("run.jsonl");
if let Ok(mut fh) = std::fs::OpenOptions::new().create(true).append(true).open(&f) {
use std::io::Write;
let _ = fh.write_all((event.to_string() + "\n").as_bytes());
}
}
/// Every event in run.jsonl, in order.
pub fn ledger(&self) -> Vec<Value> {
std::fs::read_to_string(self.dir.join("run.jsonl")).unwrap_or_default().lines().filter_map(|l| serde_json::from_str(l).ok()).collect()
}
/// Write run.json. Two commands can run at once (a landing in the background while the AI
/// declares a step or sets the page), so the write merges with what is on disk: a top-level
/// key this process never had is kept from disk, and the ledger-like arrays (turns, markers,
/// captures) are unioned by identity so neither writer drops the other's entries.
pub fn save(&self) -> Result<(), String> {
std::fs::create_dir_all(&self.dir).map_err(|e| e.to_string())?;
let mut data = self.data.clone();
if let Some(disk) = std::fs::read_to_string(self.dir.join("run.json")).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()) {
if let (Some(d), Some(m)) = (disk.as_object(), data.as_object_mut()) {
for (k, v) in d {
if !m.contains_key(k) {
m.insert(k.clone(), v.clone());
}
}
for (key, id) in [("turns", "n"), ("markers", "t"), ("captures", "start")] {
let mine = m.get(key).and_then(|a| a.as_array()).cloned().unwrap_or_default();
let theirs = d.get(key).and_then(|a| a.as_array()).cloned().unwrap_or_default();
let ident = |e: &Value| format!("{}|{}", e[id], e["label"].as_str().or(e["recordingId"].as_str()).or(e["command"].as_str()).unwrap_or(""));
let mut merged: Vec<Value> = Vec::new();
let mut seen: Vec<String> = Vec::new();
for e in theirs.iter().chain(mine.iter()) {
let k = ident(e);
if let Some(pos) = seen.iter().position(|x| *x == k) {
// the later writer's version wins (it may carry the end / file / stop of the same entry)
merged[pos] = e.clone();
} else {
seen.push(k);
merged.push(e.clone());
}
}
merged.sort_by(|a, b| a[id].to_string().cmp(&b[id].to_string()));
if key == "turns" { merged.sort_by_key(|e| e["n"].as_u64().unwrap_or(0)); }
m.insert(key.to_string(), Value::Array(merged));
}
}
}
let tmp = self.dir.join("run.json.tmp");
std::fs::write(&tmp, serde_json::to_string_pretty(&data).unwrap()).map_err(|e| e.to_string())?;
std::fs::rename(&tmp, self.dir.join("run.json")).map_err(|e| e.to_string())
}
pub fn engine_for(&self, stage: &str) -> String {
if let Some(e) = self.data["decisions"].get(stage).and_then(|v| v.as_str()) {
return e.to_string();
}
self.data["plan"].get("stages").and_then(|s| s.get(stage)).and_then(|s| s.get("default")).and_then(|d| d.as_str()).unwrap_or("binary").to_string()
}
pub fn mark(&mut self, label: &str) {
self.data["markers"].as_array_mut().unwrap().push(json!({"t": now(), "label": label}));
if !label.starts_with("stage-") && !label.starts_with("step:") {
self.log("marker", json!({"label": label}));
}
}
pub fn stage_start(&mut self, name: &str, engine: Option<&str>, note: Option<&str>) {
let engine = engine.map(str::to_string).unwrap_or_else(|| self.engine_for(name));
let stages = self.data["stages"].as_object_mut().unwrap();
let st = stages.entry(name.to_string()).or_insert(json!({"runs": []}));
st["runs"].as_array_mut().unwrap().push(json!({"start": now(), "end": null, "engine": engine, "note": note, "result": null}));
self.log("stage-start", json!({"stage": name, "engine": engine, "note": note}));
self.mark(&format!("stage-start:{name}"));
}
pub fn stage_end(&mut self, name: &str, result: Option<Value>, note: Option<&str>) {
let engine = self.engine_for(name);
let stages = self.data["stages"].as_object_mut().unwrap();
let st = stages.entry(name.to_string()).or_insert(json!({"runs": []}));
let runs = st["runs"].as_array_mut().unwrap();
if runs.last().map(|r| !r["end"].is_null()).unwrap_or(true) {
runs.push(json!({"start": now(), "end": null, "engine": engine, "note": null, "result": null}));
}
let last = runs.last_mut().unwrap();
last["end"] = json!(now());
if let Some(r) = result {
last["result"] = r;
}
if let Some(n) = note {
last["note"] = json!(n);
}
let (res, started) = (last["result"].clone(), last["start"].clone());
let secs = parse(&now()).unwrap_or(0).saturating_sub(started.as_str().and_then(parse).unwrap_or(0));
self.log("stage-end", json!({"stage": name, "binarySeconds": secs, "result": res, "note": note}));
self.mark(&format!("stage-end:{name}"));
}
pub fn current_board(&self) -> String {
self.data["boards"].as_array().and_then(|a| a.last()).and_then(|b| b.as_str()).unwrap_or("").to_string()
}
pub fn add_board(&mut self, path: &str) {
let mut invalidated=Vec::new();
if let Some(outcomes)=self.data["outcomes"].as_object_mut() {
for key in Self::COPPER_OUTCOMES {
if outcomes.remove(key).is_some(){invalidated.push(key);}
}
}
if !invalidated.is_empty(){self.log("board-evidence-invalidated",json!({"newBoard":path,"outcomes":invalidated,"reason":"board snapshot changed; historical ledger preserved"}));}
self.data["boards"].as_array_mut().unwrap().push(json!(path));
self.note_board_added(path);
}
/// When a board snapshot joined the run (`boardAddedAt`), so a check can tell what was
/// recorded before the board changed (verify-pours' plan provenance).
pub fn note_board_added(&mut self, path: &str) {
if !self.data["boardAddedAt"].is_object() { self.data["boardAddedAt"] = json!({}); }
self.data["boardAddedAt"][path] = json!(now());
}
/// The copper evidence a new board snapshot normally voids (`add_board`).
pub const COPPER_OUTCOMES: [&'static str; 8] = ["route","gate","land-route","land-vias","land-pours","measure","analyze-current","analyze-thermal"];
/// `add_board`, except that with a carry record (the caller proved the new board's copper
/// fingerprint equals the current one's) the copper evidence is kept and the record is stored
/// in `copperCarry`, where every check that binds evidence to a board hash can verify it again.
pub fn add_board_carrying(&mut self, path: &str, carry: Option<Value>) {
let Some(mut rec) = carry else { return self.add_board(path) };
let kept: Vec<&str> = Self::COPPER_OUTCOMES.iter().copied().filter(|k| self.data["outcomes"].get(*k).is_some()).collect();
rec["kept"] = json!(kept);
self.log("board-evidence-carried", json!({"newBoard": path, "carry": rec, "reason": "copper fingerprint unchanged; copper evidence kept, bound to this record"}));
if !self.data["copperCarry"].is_array() { self.data["copperCarry"] = json!([]); }
self.data["copperCarry"].as_array_mut().unwrap().push(rec);
self.data["boards"].as_array_mut().unwrap().push(json!(path));
self.note_board_added(path);
}
pub fn outcome(&mut self, key: &str, v: Value) {
self.log("outcome", json!({"stage": key, "outcome": v}));
self.data["outcomes"][key] = v;
// when, so a step's proof can be checked against its latest visit
self.data["outcomeAt"][key] = json!(now());
}
pub fn outcomes(&self) -> &Map<String, Value> {
static EMPTY: std::sync::OnceLock<Map<String, Value>> = std::sync::OnceLock::new();
self.data["outcomes"].as_object().unwrap_or_else(|| EMPTY.get_or_init(Map::new))
}
// -- the ledger: every command is a turn, the gap before it is the AI's thinking ----------
/// The step a command belongs to when the AI has not declared one with `step`.
pub fn infer_step(command: &str) -> &'static str {
let c = command.trim();
let first = c.split_whitespace().next().unwrap_or("");
let second = c.split_whitespace().nth(1).unwrap_or("");
match (first, second) {
("start", _) | ("plan", _) | ("take", _) | ("netlist", _) | ("rules", _) | ("intake", _) => "intake",
("molecule", _) => "placement",
("sourcing", _) => "sourcing",
("evidence", _) => if c.contains("simulation") || c.contains("vendor-model") { "simulation" } else { "design" },
("place", _) => "placement",
("route", _) | ("gate", _) => "routing",
("land", "moves") => "placement",
("land", "route") | ("land", "vias") => "routing",
("verify-pours", _) | ("pour", _) | ("land", "pours") | ("measure", _) => "pours",
("analyze", "current") => "current",
("analyze", "thermal") => "thermal",
("capture", _) => "capture",
("finish", _) | ("deliver", _) => "finish",
_ => "other",
}
}
/// Record the start of a command. The seconds since the previous turn ended (or since the
/// prompt, for the first turn) are the AI's thinking time and are charged to this turn's step:
/// the thinking that led to this command. Returns the turn index.
pub fn turn_begin(&mut self, command: &str) -> usize {
let t = now();
let now_s = parse(&t).unwrap_or(0);
let last_end = self.data["turns"].as_array().and_then(|a| a.last()).and_then(|l| l["end"].as_str().or(l["at"].as_str())).and_then(parse);
let since = last_end.or_else(|| self.data["clock"]["promptTime"].as_str().and_then(parse)).map(|p| now_s.saturating_sub(p)).unwrap_or(0);
// a `step X` command is charged to X: the thinking that chose the step belongs to it
let declared = command.trim().strip_prefix("step ").and_then(|r| r.split_whitespace().next()).map(str::to_string);
let step = declared.or_else(|| self.data["currentStep"].as_str().map(str::to_string)).unwrap_or_else(|| Run::infer_step(command).to_string());
let n = self.data["turns"].as_array().map(|a| a.len()).unwrap_or(0) + 1;
if !self.data["turns"].is_array() {
self.data["turns"] = json!([]);
}
self.data["turns"].as_array_mut().unwrap().push(json!({"n": n, "step": step, "command": command, "at": t, "thinkingSeconds": since, "end": null, "binarySeconds": null, "ok": null}));
self.log("turn", json!({"n": n, "step": step, "command": command, "thinkingSeconds": since}));
n - 1
}
pub fn turn_end(&mut self, idx: usize, ok: bool, first_line: &str) {
let t = now();
if let Some(turn) = self.data["turns"].as_array_mut().and_then(|a| a.get_mut(idx)) {
let started = turn["at"].as_str().and_then(parse).unwrap_or(0);
turn["end"] = json!(t);
turn["binarySeconds"] = json!(parse(&t).unwrap_or(0).saturating_sub(started));
turn["ok"] = json!(ok);
turn["said"] = json!(first_line.chars().take(200).collect::<String>());
let (n, step, bs) = (turn["n"].clone(), turn["step"].clone(), turn["binarySeconds"].clone());
self.log("turn-end", json!({"n": n, "step": step, "binarySeconds": bs, "ok": ok, "said": first_line.chars().take(200).collect::<String>()}));
}
}
/// The AI declares the step it is working on. `back` marks a return to an earlier step
/// (rework found by a later step); `why` says what sent it back. Every command until the next
/// `step` is charged to this step.
pub fn step_set(&mut self, name: &str, back: bool, why: Option<&str>) {
let t = now();
if let Some(prev) = self.data["currentStep"].as_str().map(str::to_string) {
if let Some(v) = self.data["steps"][&prev]["visits"].as_array_mut().and_then(|a| a.last_mut()) {
if v["end"].is_null() {
v["end"] = json!(t);
}
}
}
if !self.data["steps"].is_object() {
self.data["steps"] = json!({});
}
let st = self.data["steps"].as_object_mut().unwrap().entry(name.to_string()).or_insert(json!({"visits": []}));
let n = st["visits"].as_array().map(|a| a.len()).unwrap_or(0) + 1;
st["visits"].as_array_mut().unwrap().push(json!({"n": n, "at": t, "end": null, "back": back, "why": why}));
self.data["currentStep"] = json!(name);
self.log("step", json!({"step": name, "visit": n, "back": back, "why": why}));
self.mark(&format!("step:{name}{}", if back { " (back)" } else { "" }));
}
/// Per step: AI thinking seconds, binary seconds, wall seconds, turns, visits, returns, and
/// the longest single think. Wall = thinking + binary, so the sum over steps is the whole run.
pub fn step_report(&self) -> Map<String, Value> {
let mut m: Map<String, Value> = Map::new();
if let Some(rep) = self.step_report_timeline() {
return rep;
}
// the ledger is the record: rebuild the turns from run.jsonl when it exists
let ledger = self.ledger();
let mut turns: Vec<Value> = Vec::new();
if !ledger.is_empty() {
for e in &ledger {
match e["event"].as_str() {
Some("turn") => turns.push(json!({"n": e["n"], "step": e["step"], "thinkingSeconds": e["thinkingSeconds"], "binarySeconds": 0})),
Some("turn-end") => {
if let Some(t) = turns.iter_mut().rev().find(|t| t["n"] == e["n"]) {
t["binarySeconds"] = e["binarySeconds"].clone();
}
}
_ => {}
}
}
} else {
turns = self.data["turns"].as_array().cloned().unwrap_or_default();
}
for t in turns {
let step = t["step"].as_str().unwrap_or("other").to_string();
let e = m.entry(step).or_insert(json!({"thinkingSeconds": 0, "binarySeconds": 0, "wallSeconds": 0, "turns": 0, "longestThinkSeconds": 0, "visits": 0, "returns": 0}));
let th = t["thinkingSeconds"].as_u64().unwrap_or(0);
let bi = t["binarySeconds"].as_u64().unwrap_or(0);
e["thinkingSeconds"] = json!(e["thinkingSeconds"].as_u64().unwrap_or(0) + th);
e["binarySeconds"] = json!(e["binarySeconds"].as_u64().unwrap_or(0) + bi);
e["wallSeconds"] = json!(e["thinkingSeconds"].as_u64().unwrap_or(0) + e["binarySeconds"].as_u64().unwrap_or(0));
e["turns"] = json!(e["turns"].as_u64().unwrap_or(0) + 1);
if th > e["longestThinkSeconds"].as_u64().unwrap_or(0) {
e["longestThinkSeconds"] = json!(th);
}
}
for (name, st) in self.data["steps"].as_object().cloned().unwrap_or_default() {
let visits = st["visits"].as_array().cloned().unwrap_or_default();
let e = m.entry(name).or_insert(json!({"thinkingSeconds": 0, "binarySeconds": 0, "wallSeconds": 0, "turns": 0, "longestThinkSeconds": 0, "visits": 0, "returns": 0}));
e["visits"] = json!(visits.len());
e["returns"] = json!(visits.iter().filter(|v| v["back"].as_bool() == Some(true)).count());
e["why"] = json!(visits.iter().filter_map(|v| v["why"].as_str().map(str::to_string)).collect::<Vec<_>>());
}
self.add_media_and_hold(&mut m);
m
}
/// Issue #171: media processing (`media-time`: ffmpeg checks and cuts inside a command, charged to the step
/// that ran it) and owner hold (`handson-wait`: the AI waiting on the human) as their own numbers per step.
/// Media seconds are part of the step's tool time; hold seconds are a pause, outside the AI's time.
fn add_media_and_hold(&self, acc: &mut Map<String, Value>) {
let led = self.ledger();
let mut current = "intake".to_string();
for e in &led {
match e["event"].as_str() {
Some("step") => { if let Some(s) = e["step"].as_str() { current = s.to_string(); } }
Some("media-time") if e["background"] != true => {
let st = e["chargedTo"].as_str().unwrap_or(¤t).to_string();
let x = acc.entry(st).or_insert(json!({"thinkingSeconds": 0, "binarySeconds": 0, "wallSeconds": 0, "turns": 0, "longestThinkSeconds": 0, "visits": 0, "returns": 0}));
x["mediaSeconds"] = json!(x["mediaSeconds"].as_f64().unwrap_or(0.0) + e["seconds"].as_f64().unwrap_or(0.0));
}
Some("handson-wait") => {
let x = acc.entry(current.clone()).or_insert(json!({"thinkingSeconds": 0, "binarySeconds": 0, "wallSeconds": 0, "turns": 0, "longestThinkSeconds": 0, "visits": 0, "returns": 0}));
x["holdSeconds"] = json!(x["holdSeconds"].as_f64().unwrap_or(0.0) + e["humanWaitSeconds"].as_f64().unwrap_or(0.0));
}
_ => {}
}
}
}
/// The per-step report from the ledger's timeline: every second between the prompt and the
/// last event is either tool time (at least one turn running) or thinking (no turn running),
/// charged to the step current at that moment. Overlapping turns (a landing in the background
/// while the AI declares the next step) count once, so the steps sum to the wall clock.
pub fn step_report_timeline(&self) -> Option<Map<String, Value>> {
let led = self.ledger();
if led.is_empty() {
return None;
}
let t0 = self.data["clock"]["promptTime"].as_str().and_then(parse)?;
// turns as intervals, and step changes as (time, step)
let mut turns: Vec<(u64, u64, String, u64)> = Vec::new(); // start, end, step, n
let mut open: Vec<(u64, String, u64)> = Vec::new();
let mut steps: Vec<(u64, String)> = vec![(t0, "intake".to_string())];
let mut last = t0;
for e in &led {
let t = e["t"].as_str().and_then(parse).unwrap_or(0);
last = last.max(t);
match e["event"].as_str() {
Some("turn") => open.push((t, e["step"].as_str().unwrap_or("other").to_string(), e["n"].as_u64().unwrap_or(0))),
Some("turn-end") => {
let n = e["n"].as_u64().unwrap_or(0);
if let Some(pos) = open.iter().position(|o| o.2 == n) {
let (s, st, _) = open.remove(pos);
turns.push((s, t.max(s), st, n));
}
}
Some("step") => steps.push((t, e["step"].as_str().unwrap_or("other").to_string())),
_ => {}
}
}
let delivered = self.data["clock"]["deliveredTime"].as_str().and_then(parse);
let end = delivered.or_else(|| self.data["clock"]["finishTime"].as_str().and_then(parse)).unwrap_or(last).max(last);
for (s, st, n) in open { turns.push((s, end, st, n)); }
// second by second (runs are hours, not years)
let step_at = |t: u64| -> String { steps.iter().rev().find(|(ts, _)| *ts <= t).map(|(_, s)| s.clone()).unwrap_or_else(|| "intake".to_string()) };
let mut acc: Map<String, Value> = Map::new();
let mut think_run: (String, u64) = (String::new(), 0);
let mut longest: std::collections::BTreeMap<String, u64> = Default::default();
let spans = self.active_spans();
let mut t = t0;
while t < end {
// the human away is not the AI thinking: seconds outside the active spans are skipped
if !spans.iter().any(|(s, e)| *s <= t && t < *e) { t += 1; continue; }
let running: Vec<&(u64, u64, String, u64)> = turns.iter().filter(|(s, e, _, _)| *s <= t && t < *e).collect();
let step = if let Some(r) = running.first() { r.2.clone() } else { step_at(t) };
// after the delivery, work that happens to run under `finish` (recuts, composes, reports) is
// follow-up work, listed under Sessions; the finish step's own time ends at the delivery
if step == "finish" && delivered.map(|d| t >= d).unwrap_or(false) { t += 1; continue; }
let e = acc.entry(step.clone()).or_insert(json!({"thinkingSeconds": 0, "binarySeconds": 0, "wallSeconds": 0, "turns": 0, "longestThinkSeconds": 0, "visits": 0, "returns": 0}));
if running.is_empty() {
e["thinkingSeconds"] = json!(e["thinkingSeconds"].as_u64().unwrap_or(0) + 1);
if think_run.0 == step { think_run.1 += 1; } else { think_run = (step.clone(), 1); }
let l = longest.entry(step.clone()).or_insert(0);
if think_run.1 > *l { *l = think_run.1; }
} else {
e["binarySeconds"] = json!(e["binarySeconds"].as_u64().unwrap_or(0) + 1);
think_run = (String::new(), 0);
}
e["wallSeconds"] = json!(e["thinkingSeconds"].as_u64().unwrap_or(0) + e["binarySeconds"].as_u64().unwrap_or(0));
t += 1;
}
for (_, _, st, _) in &turns {
let e = acc.entry(st.clone()).or_insert(json!({"thinkingSeconds": 0, "binarySeconds": 0, "wallSeconds": 0, "turns": 0, "longestThinkSeconds": 0, "visits": 0, "returns": 0}));
e["turns"] = json!(e["turns"].as_u64().unwrap_or(0) + 1);
}
for (st, l) in longest { if let Some(e) = acc.get_mut(&st) { e["longestThinkSeconds"] = json!(l); } }
self.add_media_and_hold(&mut acc);
for (name, st) in self.data["steps"].as_object().cloned().unwrap_or_default() {
let visits = st["visits"].as_array().cloned().unwrap_or_default();
let e = acc.entry(name).or_insert(json!({"thinkingSeconds": 0, "binarySeconds": 0, "wallSeconds": 0, "turns": 0, "longestThinkSeconds": 0, "visits": 0, "returns": 0}));
e["visits"] = json!(visits.len());
e["returns"] = json!(visits.iter().filter(|v| v["back"].as_bool() == Some(true)).count());
e["why"] = json!(visits.iter().filter_map(|v| v["why"].as_str().map(str::to_string)).collect::<Vec<_>>());
}
Some(acc)
}
/// The idle threshold in minutes: a gap longer than this between two ledger events, with no
/// command running, is unobserved (not proven human idle); it is cut out of the AI's time.
pub fn idle_minutes(&self) -> u64 {
self.data["idleMinutes"].as_u64().unwrap_or(15)
}
/// The run as sessions: one human prompt (the `start` event, then every `prompt`) and the AI's
/// work on it until its `done` (or `deliver`), as active spans with the idle gaps cut out.
/// A session without a `done` ends at its last event. The AI's time is the sum of the spans;
/// the wall clock is not a measure of the AI.
pub fn sessions(&self) -> Vec<Session> {
let led = self.ledger();
let idle = self.idle_minutes() * 60;
let t0 = self.data["clock"]["promptTime"].as_str().and_then(parse).unwrap_or(0);
let times: Vec<u64> = led.iter().map(|e| e["t"].as_str().and_then(parse).unwrap_or(0)).collect();
// a command runs from its `turn` to its `turn-end`; one that died without a turn-end (an ERROR
// exit) ran until the next ledger event at most. A gap covered by a running command is not idle.
let mut running: Vec<(u64, u64)> = Vec::new();
for (i, e) in led.iter().enumerate() {
if e["event"] != "turn" { continue; }
let n = e["n"].as_u64();
let end = led.iter().enumerate().skip(i + 1).find(|(_, x)| x["event"] == "turn-end" && x["n"].as_u64() == n).map(|(j, _)| times[j]).or_else(|| times.get(i + 1).copied()).unwrap_or(times[i]);
running.push((times[i], end));
}
let covered = |a: u64, b: u64| running.iter().any(|(s, e)| *s <= a && *e >= b);
let mut out: Vec<Session> = Vec::new();
let mut cur: Option<Session> = None;
let mut last_activity: u64 = t0;
let extend = |s: &mut Session, at: u64| { if let Some(sp) = s.spans.last_mut() { if at > sp.1 { sp.1 = at; } } };
let open = |out: &mut Vec<Session>, cur: &mut Option<Session>, at: u64, text: String| {
if let Some(s) = cur.take() { out.push(s); }
*cur = Some(Session { n: out.len() + 1, prompt_at: at, done_at: None, text, spans: vec![(at, at)], idle_seconds: 0, open: true });
};
if t0 > 0 { open(&mut out, &mut cur, t0, self.data["prompt"].as_str().unwrap_or("the first prompt").to_string()); }
for (i, e) in led.iter().enumerate() {
let t = times[i];
let gap_idle = t.saturating_sub(last_activity) > idle && !covered(last_activity, t);
match e["event"].as_str().unwrap_or("") {
"start" => { if cur.is_none() { open(&mut out, &mut cur, t0.max(1), "the first prompt".into()); } last_activity = t.max(t0); continue; }
"prompt" => {
let at = e["at"].as_str().and_then(parse).unwrap_or(t);
open(&mut out, &mut cur, at, e["text"].as_str().unwrap_or("").to_string());
last_activity = at;
continue;
}
"done" | "deliver" => {
if let Some(s) = cur.as_mut() {
if s.done_at.is_none() {
if gap_idle { s.idle_seconds += t - last_activity; s.spans.push((t, t)); } else { extend(s, t); }
s.done_at = Some(t); s.open = false;
}
}
last_activity = t;
continue;
}
_ => {}
}
let after_done = cur.as_ref().map(|s| s.done_at.is_some() || (s.text.starts_with("follow-up") && gap_idle)).unwrap_or(false);
if after_done {
// work after a done with no prompt mark: a follow-up the AI did not mark; its own row,
// and every idle gap inside it starts another, since a gap there is most likely a new prompt
if gap_idle || cur.as_ref().map(|s| !s.open).unwrap_or(true) {
open(&mut out, &mut cur, t, "follow-up (no prompt mark; say `prompt --text` next time)".into());
last_activity = t;
continue;
}
}
let Some(s) = cur.as_mut() else { continue };
if gap_idle { s.idle_seconds += t - last_activity; s.spans.push((t, t)); } else { extend(s, t); }
last_activity = last_activity.max(t);
}
if let Some(s) = cur.take() { out.push(s); }
out
}
/// Returns to earlier steps before the delivery: the reworks of the task itself, not of follow-ups.
pub fn returns_before_delivery(&self) -> u64 {
let cutoff = self.data["clock"]["deliveredTime"].as_str().and_then(parse).unwrap_or(u64::MAX);
let mut n = 0;
for (_, st) in self.data["steps"].as_object().cloned().unwrap_or_default() {
for v in st["visits"].as_array().cloned().unwrap_or_default() {
if v["back"].as_bool() == Some(true) && v["at"].as_str().or(v["start"].as_str()).and_then(parse).map(|t| t < cutoff).unwrap_or(true) { n += 1; }
}
}
n
}
/// Every active span of the run, in order.
pub fn active_spans(&self) -> Vec<(u64, u64)> {
self.sessions().into_iter().flat_map(|s| s.spans).collect()
}
/// Seconds of AI time between two moments (the overlap of the active spans with [a, b]).
pub fn active_between(&self, a: u64, b: u64) -> u64 {
if b <= a { return 0; }
self.active_spans().iter().map(|(s, e)| { let lo = (*s).max(a); let hi = (*e).min(b); hi.saturating_sub(lo) }).sum()
}
/// Seconds of AI time from the first prompt to the moment `t`: the clock the video shows.
pub fn active_offset(&self, t: u64) -> u64 {
let t0 = self.data["clock"]["promptTime"].as_str().and_then(parse).unwrap_or(0);
self.active_between(t0, t)
}
/// Minutes of AI time from the first prompt to the delivery, if delivered: the benchmark number.
pub fn delivered_active_minutes(&self) -> Option<f64> {
let t1 = self.data["clock"]["deliveredTime"].as_str().and_then(parse)?;
Some(((self.active_offset(t1) as f64) / 6.0).round() / 10.0)
}
/// Minutes of AI time over the whole run, every session.
pub fn active_minutes_total(&self) -> f64 {
(self.active_spans().iter().map(|(s, e)| e.saturating_sub(*s)).sum::<u64>() as f64 / 6.0).round() / 10.0
}
/// Minutes from the prompt to the delivery (the AI's "done, here is your video"), if delivered.
pub fn delivered_minutes(&self) -> Option<f64> {
let t0 = self.data["clock"]["promptTime"].as_str().and_then(parse)?;
let t1 = self.data["clock"]["deliveredTime"].as_str().and_then(parse)?;
Some((t1.saturating_sub(t0)) as f64 / 60.0)
}
pub fn elapsed_minutes(&self) -> f64 {
let t0 = self.data["clock"]["promptTime"].as_str().and_then(parse).unwrap_or(0);
let t1 = self.data["clock"]["finishTime"].as_str().and_then(parse).unwrap_or_else(|| std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0));
((t1 as f64 - t0 as f64) / 6.0).round() / 10.0
}
pub fn stage_minutes(&self) -> Map<String, Value> {
let mut out = Map::new();
if let Some(st) = self.data["stages"].as_object() {
for (name, s) in st {
let mut tot = 0.0;
for r in s["runs"].as_array().cloned().unwrap_or_default() {
if let (Some(a), Some(b)) = (r["start"].as_str().and_then(parse), r["end"].as_str().and_then(parse)) {
tot += (b as f64 - a as f64) / 60.0;
}
}
out.insert(name.clone(), json!((tot * 10.0).round() / 10.0));
}
}
out
}
}
#[cfg(test)]
mod tests {
#[test]
fn clip_text_keeps_the_cause() {
let msg = format!("CAUSE: the footprint is missing{}END", " x".repeat(400));
let c = super::clip_text(&msg, 200);
assert!(c.starts_with("CAUSE: the footprint is missing"), "{c}");
assert!(c.ends_with("END") && c.contains(" ... "), "{c}");
assert_eq!(c.chars().count(), 200);
assert_eq!(super::clip_text("short", 200), "short");
}
use super::*;
#[test]
fn external_turn_covers_a_long_gap_without_inventing_idle_activity() {
let dir=std::env::temp_dir().join(format!("aiflow-session-{}",std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let t=1_789_299_600;
let r=Run{dir:dir.clone(),data:json!({"clock":{"promptTime":iso(t)},"idleMinutes":15})};
let events=vec![json!({"event":"start","t":iso(t)}),json!({"event":"turn","t":iso(t),"n":0}),json!({"event":"turn-end","t":iso(t+3600),"n":0}),json!({"event":"done","t":iso(t+3600)})];
let write=|events:Vec<Value>|std::fs::write(dir.join("run.jsonl"),events.iter().map(|v|v.to_string()+"\n").collect::<String>()).unwrap();
write(events);
assert_eq!(r.sessions()[0].active_seconds(),3600);
assert_eq!(r.sessions()[0].idle_seconds,0);
write(vec![json!({"event":"start","t":iso(t)}),json!({"event":"done","t":iso(t+3600)})]);
assert_eq!(r.sessions()[0].active_seconds(),0);
assert_eq!(r.sessions()[0].idle_seconds,3600);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn intake_run_keeps_its_clock_and_ledger_when_the_board_arrives() {
let dir = std::env::temp_dir().join(format!("aiflow-intake-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut r = Run::create_intake(&dir, "2026-09-29T00:39:00Z", "t", Some("claude"), None, None, None).unwrap();
assert!(r.board_pending());
assert_eq!(r.data["currentStep"], "intake");
r.step_set("sourcing", false, None);
r.save().unwrap();
let mut r = Run::open(&dir).unwrap();
r.attach_board("/x/board-0.kicad_pcb", "/x/spec.json", "claude", Some("box"), Some("C:/b.kicad_pcb"));
r.save().unwrap();
let r = Run::open(&dir).unwrap();
assert!(!r.board_pending());
assert_eq!(r.current_board(), "/x/board-0.kicad_pcb");
assert_eq!(r.data["clock"]["promptTime"], "2026-09-29T00:39:00Z");
assert_eq!(r.data["steps"]["intake"]["visits"].as_array().unwrap().len(), 1);
assert_eq!(r.data["steps"]["sourcing"]["visits"].as_array().unwrap().len(), 1);
let events: Vec<String> = r.ledger().iter().filter_map(|e| e["event"].as_str().map(str::to_string)).collect();
assert_eq!(events.iter().filter(|e| *e == "start").count(), 1);
assert!(events.contains(&"board-attached".to_string()));
assert_eq!(Run::infer_step("sourcing check --bom b.csv"), "sourcing");
assert_eq!(Run::infer_step("evidence add --kind simulation --file s.json"), "simulation");
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn iso_round_trips() {
assert_eq!(iso(0), "1970-01-01T00:00:00Z");
let t = 1_789_299_600; // some 2026 second
assert_eq!(parse(&iso(t)), Some(t));
assert_eq!(parse("2026-09-13T15:38:04Z").map(iso).as_deref(), Some("2026-09-13T15:38:04Z"));
}
}