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(&current).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"));
    }
}