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('&', "&")
388 .replace('<', "<")
389 .replace('>', ">")
390 .replace('"', """)
391 .replace('\'', "'")
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}