Skip to main content

imp_core/
run_evidence.rs

1use std::fs::{self, File, OpenOptions};
2use std::io::{BufRead, BufReader, Write};
3use std::path::{Path, PathBuf};
4
5use serde::{Deserialize, Serialize};
6
7use crate::agent::{AgentEvent, RunFinalStatus};
8use crate::error::Result;
9
10const VERSION: u32 = 1;
11
12#[derive(Debug, Clone)]
13pub struct RunArtifacts {
14    root: PathBuf,
15}
16
17impl RunArtifacts {
18    pub fn create(root: impl Into<PathBuf>) -> Result<Self> {
19        let artifacts = Self { root: root.into() };
20        fs::create_dir_all(&artifacts.root)?;
21        Ok(artifacts)
22    }
23
24    pub fn root(&self) -> &Path {
25        &self.root
26    }
27
28    pub fn events_path(&self) -> PathBuf {
29        self.root.join("events.jsonl")
30    }
31
32    pub fn evidence_html_path(&self) -> PathBuf {
33        self.root.join("evidence.html")
34    }
35}
36
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38pub struct RunIndexRecord {
39    pub version: u32,
40    pub run_id: String,
41    pub cwd: PathBuf,
42    pub started_at: u64,
43    pub completed_at: Option<u64>,
44    pub status: Option<String>,
45    pub objective: String,
46    pub events_path: PathBuf,
47    pub evidence_html_path: PathBuf,
48}
49
50impl RunIndexRecord {
51    pub fn started(
52        run_id: impl Into<String>,
53        cwd: impl Into<PathBuf>,
54        started_at: u64,
55        objective: impl Into<String>,
56        artifacts: &RunArtifacts,
57    ) -> Self {
58        Self {
59            version: VERSION,
60            run_id: run_id.into(),
61            cwd: cwd.into(),
62            started_at,
63            completed_at: None,
64            status: None,
65            objective: objective.into(),
66            events_path: artifacts.events_path(),
67            evidence_html_path: artifacts.evidence_html_path(),
68        }
69    }
70}
71
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73pub struct RunEvent {
74    pub version: u32,
75    pub run_id: String,
76    pub timestamp: u64,
77    pub kind: String,
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub turn: Option<u32>,
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub tool_call_id: Option<String>,
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub tool_name: Option<String>,
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub status: Option<String>,
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub summary: Option<String>,
88}
89
90impl RunEvent {
91    pub fn from_agent_event(run_id: &str, event: &AgentEvent) -> Self {
92        let mut run_event = Self {
93            version: VERSION,
94            run_id: run_id.to_string(),
95            timestamp: imp_llm::now(),
96            kind: agent_event_kind(event).to_string(),
97            turn: None,
98            tool_call_id: None,
99            tool_name: None,
100            status: None,
101            summary: None,
102        };
103
104        match event {
105            AgentEvent::AgentStart { model, timestamp } => {
106                run_event.timestamp = *timestamp;
107                run_event.summary = Some(format!("model={model}"));
108            }
109            AgentEvent::AgentEnd { status, .. } => {
110                run_event.status = Some(status_label(status));
111            }
112            AgentEvent::TurnStart { index }
113            | AgentEvent::TurnAssessment { index, .. }
114            | AgentEvent::TurnEnd { index, .. } => {
115                run_event.turn = Some(*index);
116            }
117            AgentEvent::ToolExecutionStart {
118                tool_call_id,
119                tool_name,
120                ..
121            } => {
122                run_event.tool_call_id = Some(tool_call_id.clone());
123                run_event.tool_name = Some(tool_name.clone());
124            }
125            AgentEvent::ToolOutputDelta { tool_call_id, text } => {
126                run_event.tool_call_id = Some(tool_call_id.clone());
127                run_event.summary = Some(truncate(text, 240));
128            }
129            AgentEvent::ToolExecutionEnd {
130                tool_call_id,
131                result,
132                ..
133            } => {
134                run_event.tool_call_id = Some(tool_call_id.clone());
135                run_event.tool_name = Some(result.tool_name.clone());
136                run_event.status = Some(if result.is_error { "error" } else { "ok" }.into());
137            }
138            AgentEvent::Warning { message } => {
139                run_event.summary = Some(truncate(message, 240));
140            }
141            AgentEvent::Error { error } => {
142                run_event.status = Some("error".into());
143                run_event.summary = Some(truncate(error, 240));
144            }
145            AgentEvent::Timing { timing } => {
146                run_event.turn = Some(timing.turn);
147                run_event.summary = Some(timing.stage.as_str().into());
148            }
149            AgentEvent::RecoveryCheckpoint { checkpoint } => {
150                run_event.turn = Some(checkpoint.turn);
151                run_event.tool_call_id = checkpoint.tool_call_id.clone();
152                run_event.tool_name = checkpoint.tool_name.clone();
153                run_event.status = checkpoint
154                    .success
155                    .map(|ok| if ok { "ok" } else { "error" }.into());
156                run_event.summary = Some(checkpoint.kind.as_str().into());
157            }
158            AgentEvent::PolicyChecked { record } => {
159                run_event.tool_name = Some(record.tool_name.clone());
160                run_event.status = Some(
161                    if record.decision.is_allowed() {
162                        "allowed"
163                    } else {
164                        "denied"
165                    }
166                    .into(),
167                );
168                run_event.summary = Some(truncate(
169                    &format!(
170                        "action={:?} scope={:?}",
171                        record.action_kind, record.resource_scope
172                    ),
173                    240,
174                ));
175            }
176            AgentEvent::VerificationStarted { gate } => {
177                run_event.status = Some("started".into());
178                run_event.summary = Some(truncate(&gate.name, 240));
179            }
180            AgentEvent::VerificationCompleted {
181                gate,
182                closeout_effect,
183            } => {
184                run_event.status = Some(format!("{:?}", gate.status));
185                run_event.summary = Some(truncate(
186                    &format!("{} ({closeout_effect:?})", gate.name),
187                    240,
188                ));
189            }
190            AgentEvent::EvidenceWritten { path } => {
191                run_event.summary = Some(path.display().to_string());
192            }
193            AgentEvent::MessageStart { .. }
194            | AgentEvent::MessageDelta { .. }
195            | AgentEvent::MessageEnd { .. } => {}
196        }
197
198        run_event
199    }
200}
201
202pub struct RunEventWriter {
203    file: File,
204}
205
206impl RunEventWriter {
207    pub fn create(path: impl AsRef<Path>) -> Result<Self> {
208        if let Some(parent) = path.as_ref().parent() {
209            fs::create_dir_all(parent)?;
210        }
211        let file = OpenOptions::new().create(true).append(true).open(path)?;
212        Ok(Self { file })
213    }
214
215    pub fn write_event(&mut self, event: &RunEvent) -> Result<()> {
216        let line = serde_json::to_string(event)?;
217        writeln!(self.file, "{line}")?;
218        Ok(())
219    }
220
221    pub fn flush(&mut self) -> Result<()> {
222        self.file.flush()?;
223        Ok(())
224    }
225}
226
227pub fn append_index_record(path: impl AsRef<Path>, record: &RunIndexRecord) -> Result<()> {
228    if let Some(parent) = path.as_ref().parent() {
229        fs::create_dir_all(parent)?;
230    }
231    let mut file = OpenOptions::new().create(true).append(true).open(path)?;
232    let line = serde_json::to_string(record)?;
233    writeln!(file, "{line}")?;
234    Ok(())
235}
236
237pub fn read_index_records(path: impl AsRef<Path>) -> Result<Vec<RunIndexRecord>> {
238    let file = match File::open(path) {
239        Ok(file) => file,
240        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
241        Err(err) => return Err(err.into()),
242    };
243    let mut records = Vec::new();
244    for line in BufReader::new(file).lines() {
245        let line = line?;
246        if line.trim().is_empty() {
247            continue;
248        }
249        records.push(serde_json::from_str(&line)?);
250    }
251    Ok(records)
252}
253
254pub fn write_evidence_html(
255    path: impl AsRef<Path>,
256    record: &RunIndexRecord,
257    events: &[RunEvent],
258) -> Result<()> {
259    if let Some(parent) = path.as_ref().parent() {
260        fs::create_dir_all(parent)?;
261    }
262
263    let mut html = String::new();
264    html.push_str("<!doctype html><meta charset=\"utf-8\">");
265    html.push_str("<title>imp evidence</title>");
266    html.push_str("<style>body{font:14px system-ui,sans-serif;margin:2rem;max-width:960px}code,pre{background:#f6f6f6;padding:.2rem .35rem;border-radius:4px}table{border-collapse:collapse;width:100%}td,th{border-top:1px solid #ddd;padding:.4rem;text-align:left} .ok{color:#067d17}.error{color:#b00020}</style>");
267    html.push_str("<h1>imp evidence</h1>");
268    html.push_str("<dl>");
269    html.push_str(&format!(
270        "<dt>Run</dt><dd><code>{}</code></dd>",
271        escape_html(&record.run_id)
272    ));
273    html.push_str(&format!(
274        "<dt>CWD</dt><dd><code>{}</code></dd>",
275        escape_html(&record.cwd.display().to_string())
276    ));
277    html.push_str(&format!(
278        "<dt>Objective</dt><dd>{}</dd>",
279        escape_html(&record.objective)
280    ));
281    if let Some(status) = &record.status {
282        html.push_str(&format!("<dt>Status</dt><dd>{}</dd>", escape_html(status)));
283    }
284    html.push_str("</dl>");
285    html.push_str("<h2>Timeline</h2><table><thead><tr><th>Time</th><th>Event</th><th>Tool</th><th>Status</th><th>Summary</th></tr></thead><tbody>");
286    for event in events {
287        let status_class = match event.status.as_deref() {
288            Some("ok") | Some("done") => "ok",
289            Some("error") | Some("blocked") => "error",
290            _ => "",
291        };
292        html.push_str("<tr>");
293        html.push_str(&format!("<td>{}</td>", event.timestamp));
294        html.push_str(&format!(
295            "<td><code>{}</code></td>",
296            escape_html(&event.kind)
297        ));
298        html.push_str(&format!(
299            "<td>{}</td>",
300            escape_html(event.tool_name.as_deref().unwrap_or(""))
301        ));
302        html.push_str(&format!(
303            "<td class=\"{}\">{}</td>",
304            status_class,
305            escape_html(event.status.as_deref().unwrap_or(""))
306        ));
307        html.push_str(&format!(
308            "<td>{}</td>",
309            escape_html(event.summary.as_deref().unwrap_or(""))
310        ));
311        html.push_str("</tr>");
312    }
313    html.push_str("</tbody></table>");
314    html.push_str(&format!(
315        "<p>Source: <code>{}</code></p>",
316        escape_html(&record.events_path.display().to_string())
317    ));
318    fs::write(path, html)?;
319    Ok(())
320}
321
322pub fn read_run_events(path: impl AsRef<Path>) -> Result<Vec<RunEvent>> {
323    let file = match File::open(path) {
324        Ok(file) => file,
325        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
326        Err(err) => return Err(err.into()),
327    };
328    let mut events = Vec::new();
329    for line in BufReader::new(file).lines() {
330        let line = line?;
331        if line.trim().is_empty() {
332            continue;
333        }
334        events.push(serde_json::from_str(&line)?);
335    }
336    Ok(events)
337}
338
339fn agent_event_kind(event: &AgentEvent) -> &'static str {
340    match event {
341        AgentEvent::AgentStart { .. } => "run.started",
342        AgentEvent::AgentEnd { .. } => "run.completed",
343        AgentEvent::TurnStart { .. } => "turn.started",
344        AgentEvent::TurnAssessment { .. } => "turn.assessed",
345        AgentEvent::TurnEnd { .. } => "turn.completed",
346        AgentEvent::MessageStart { .. } => "message.started",
347        AgentEvent::MessageDelta { .. } => "message.delta",
348        AgentEvent::MessageEnd { .. } => "message.completed",
349        AgentEvent::ToolExecutionStart { .. } => "tool.started",
350        AgentEvent::ToolOutputDelta { .. } => "tool.output_delta",
351        AgentEvent::ToolExecutionEnd { .. } => "tool.completed",
352        AgentEvent::Warning { .. } => "warning",
353        AgentEvent::Timing { .. } => "timing",
354        AgentEvent::RecoveryCheckpoint { .. } => "recovery.checkpoint",
355        AgentEvent::PolicyChecked { .. } => "policy.checked",
356        AgentEvent::VerificationStarted { .. } => "verification.started",
357        AgentEvent::VerificationCompleted { .. } => "verification.completed",
358        AgentEvent::EvidenceWritten { .. } => "evidence.written",
359        AgentEvent::Error { .. } => "error",
360    }
361}
362
363fn status_label(status: &RunFinalStatus) -> String {
364    match status {
365        RunFinalStatus::Done { .. } => "done".into(),
366        RunFinalStatus::DoneWithConcerns { .. } => "done_with_concerns".into(),
367        RunFinalStatus::Blocked { .. } => "blocked".into(),
368        RunFinalStatus::NeedsUserInput { .. } => "needs_user_input".into(),
369        RunFinalStatus::Cancelled => "cancelled".into(),
370        RunFinalStatus::Failed { .. } => "failed".into(),
371    }
372}
373
374fn truncate(text: &str, max_chars: usize) -> String {
375    let mut out = String::new();
376    for (idx, ch) in text.chars().enumerate() {
377        if idx >= max_chars {
378            out.push('…');
379            return out;
380        }
381        out.push(ch);
382    }
383    out
384}
385
386fn escape_html(text: &str) -> String {
387    text.replace('&', "&amp;")
388        .replace('<', "&lt;")
389        .replace('>', "&gt;")
390        .replace('"', "&quot;")
391        .replace('\'', "&#39;")
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397
398    #[test]
399    fn run_index_appends_and_reads_jsonl_records() {
400        let temp = tempfile::tempdir().unwrap();
401        let artifacts = RunArtifacts::create(temp.path().join("run_1")).unwrap();
402        let index = temp.path().join("index.jsonl");
403        let record =
404            RunIndexRecord::started("run_1", temp.path(), 123, "fix evidence UX", &artifacts);
405
406        append_index_record(&index, &record).unwrap();
407        assert_eq!(read_index_records(&index).unwrap(), vec![record]);
408    }
409
410    #[test]
411    fn run_events_are_jsonl_and_html_viewer_renders_timeline() {
412        let temp = tempfile::tempdir().unwrap();
413        let artifacts = RunArtifacts::create(temp.path().join("run_1")).unwrap();
414        let mut writer = RunEventWriter::create(artifacts.events_path()).unwrap();
415        let event = RunEvent {
416            version: VERSION,
417            run_id: "run_1".into(),
418            timestamp: 123,
419            kind: "tool.completed".into(),
420            turn: Some(0),
421            tool_call_id: Some("tc_1".into()),
422            tool_name: Some("bash".into()),
423            status: Some("ok".into()),
424            summary: Some("cargo test".into()),
425        };
426        writer.write_event(&event).unwrap();
427        writer.flush().unwrap();
428
429        let events = read_run_events(artifacts.events_path()).unwrap();
430        assert_eq!(events, vec![event]);
431
432        let record = RunIndexRecord::started("run_1", temp.path(), 123, "test", &artifacts);
433        write_evidence_html(artifacts.evidence_html_path(), &record, &events).unwrap();
434        let html = fs::read_to_string(artifacts.evidence_html_path()).unwrap();
435        assert!(html.contains("imp evidence"));
436        assert!(html.contains("tool.completed"));
437        assert!(html.contains("cargo test"));
438    }
439}