use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::io::{BufRead, BufReader};
use std::path::Path;
fn is_false(value: &bool) -> bool {
!*value
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RejectReason {
FetchFailed,
WrongUrl,
EmptyContent,
ApiError,
Duplicate,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToolCallStatus {
Ok,
Error,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FactCheckOutcome {
Supported,
Refuted,
Uncertain,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "event", rename_all = "snake_case")]
pub enum SessionEvent {
SessionCreated {
timestamp: DateTime<Utc>,
slug: String,
topic: String,
preset: String,
session_dir_abs: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
note: Option<String>,
},
SourceAttempted {
timestamp: DateTime<Utc>,
url: String,
route_decision: RouteDecision,
#[serde(default, skip_serializing_if = "Option::is_none")]
note: Option<String>,
},
SourceAccepted {
timestamp: DateTime<Utc>,
url: String,
kind: String,
executor: String,
raw_path: String,
bytes: u64,
trust_score: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
note: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
composite: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
parts: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
part_bytes: Option<std::collections::BTreeMap<String, u64>>,
},
FallbackSelected {
timestamp: DateTime<Utc>,
from_hand: String,
to_hand: String,
reason: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
note: Option<String>,
},
OriginalUrlPreserved {
timestamp: DateTime<Utc>,
local_url: String,
original_url: String,
origin_tool: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
origin_note: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
note: Option<String>,
},
FallbackSourceAccepted {
timestamp: DateTime<Utc>,
local_url: String,
original_url: String,
origin_tool: String,
bytes: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
note: Option<String>,
},
SourceRejected {
timestamp: DateTime<Utc>,
url: String,
kind: String,
executor: String,
reason: RejectReason,
#[serde(default, skip_serializing_if = "Option::is_none")]
observed_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
observed_bytes: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
rejected_raw_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
note: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
composite: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
parts: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
failed_part: Option<String>,
},
ToolCallStarted {
timestamp: DateTime<Utc>,
call_id: String,
hand: String,
tool: String,
input_summary: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
note: Option<String>,
},
ToolCallCompleted {
timestamp: DateTime<Utc>,
call_id: String,
status: ToolCallStatus,
duration_ms: u64,
output_summary: String,
artifact_refs: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
error_code: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
note: Option<String>,
},
SynthesizeStarted {
timestamp: DateTime<Utc>,
#[serde(default, skip_serializing_if = "is_false")]
no_render: bool,
#[serde(default, skip_serializing_if = "is_false")]
open: bool,
#[serde(default, skip_serializing_if = "is_false")]
bilingual: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
bilingual_provider: Option<String>,
#[serde(default, skip_serializing_if = "is_false")]
pdf: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pdf_provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
note: Option<String>,
},
SynthesizeCompleted {
timestamp: DateTime<Utc>,
report_json_path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
report_html_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
report_pdf_path: Option<String>,
accepted_sources: u32,
rejected_sources: u32,
duration_ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
note: Option<String>,
},
SynthesizeFailed {
timestamp: DateTime<Utc>,
stage: SynthesizeStage,
reason: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
note: Option<String>,
},
SessionClosed {
timestamp: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
note: Option<String>,
},
SessionRemoved {
timestamp: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
note: Option<String>,
},
SessionResumed {
timestamp: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
note: Option<String>,
},
LoopStarted {
timestamp: DateTime<Utc>,
provider: String,
iterations: u32,
max_actions: u32,
dry_run: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
note: Option<String>,
},
LoopStep {
timestamp: DateTime<Utc>,
iteration: u32,
reasoning: String,
actions_requested: u32,
actions_executed: u32,
actions_rejected: u32,
duration_ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
note: Option<String>,
},
LoopCompleted {
timestamp: DateTime<Utc>,
reason: String,
iterations_run: u32,
actions_executed_total: u32,
report_ready: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
note: Option<String>,
},
SourceDigested {
timestamp: DateTime<Utc>,
iteration: u32,
url: String,
into_section: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
note: Option<String>,
},
FactChecked {
timestamp: DateTime<Utc>,
iteration: u32,
claim: String,
query: String,
sources: Vec<String>,
outcome: FactCheckOutcome,
into_section: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
note: Option<String>,
},
PlanWritten {
timestamp: DateTime<Utc>,
iteration: u32,
body_chars: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
note: Option<String>,
},
DiagramAuthored {
timestamp: DateTime<Utc>,
iteration: u32,
path: String,
bytes: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
note: Option<String>,
},
DiagramRejected {
timestamp: DateTime<Utc>,
iteration: u32,
path: String,
reason: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
note: Option<String>,
},
WikiPageWritten {
timestamp: DateTime<Utc>,
iteration: u32,
slug: String,
mode: String,
body_chars: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
note: Option<String>,
},
SchemaUpdated {
timestamp: DateTime<Utc>,
body_chars: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
note: Option<String>,
},
WikiQuery {
timestamp: DateTime<Utc>,
question: String,
relevant_pages: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
answer_slug: Option<String>,
answer_chars: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
note: Option<String>,
},
WikiLintRan {
timestamp: DateTime<Utc>,
issues: u32,
orphans: u32,
broken_links: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
note: Option<String>,
},
WikiSeeded {
timestamp: DateTime<Utc>,
url: String,
host: String,
site: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
group: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
action: Option<String>,
page: String,
bytes: u64,
source: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
note: Option<String>,
},
ActionbookCalled {
timestamp: DateTime<Utc>,
iteration: u32,
action_type: String,
cmd_summary: String,
outcome: String,
result_bytes: u64,
#[serde(default, skip_serializing_if = "is_false")]
result_truncated: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
wiki_seeded_pages: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
error_code: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
note: Option<String>,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RouteDecision {
pub executor: String,
pub kind: String,
pub command_template: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub composite: Option<Vec<ResolvedPartEvent>>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ResolvedPartEvent {
pub executor: String,
pub command: String,
pub label: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SynthesizeStage {
Build,
Render,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct EventLogDiagnostics {
pub malformed_lines: usize,
pub unknown_events: usize,
pub parse_errors: usize,
}
#[derive(Debug, Clone, Default)]
pub struct EventLogRead {
pub events: Vec<SessionEvent>,
pub diagnostics: EventLogDiagnostics,
}
pub fn read_events(path: &Path) -> std::io::Result<Vec<SessionEvent>> {
let f = std::fs::File::open(path)?;
let mut events = Vec::new();
let reader = BufReader::new(f);
for (idx, line_res) in reader.lines().enumerate() {
let line_no = idx + 1;
let line = match line_res {
Ok(l) => l,
Err(e) => {
eprintln!("⚠ session.jsonl line {line_no} read error: {e}, skipped");
continue;
}
};
if line.trim().is_empty() {
continue;
}
match serde_json::from_str::<SessionEvent>(&line) {
Ok(ev) => events.push(ev),
Err(e) => {
eprintln!(
"⚠ session.jsonl line {line_no} malformed or unknown event: {e}, skipped"
);
}
}
}
Ok(events)
}
pub fn read_events_with_diagnostics(path: &Path) -> std::io::Result<EventLogRead> {
let f = std::fs::File::open(path)?;
let mut out = EventLogRead::default();
let reader = BufReader::new(f);
for line_res in reader.lines() {
let line = match line_res {
Ok(line) => line,
Err(_) => {
out.diagnostics.parse_errors += 1;
continue;
}
};
if line.trim().is_empty() {
continue;
}
match serde_json::from_str::<SessionEvent>(&line) {
Ok(ev) => out.events.push(ev),
Err(e) => {
out.diagnostics.parse_errors += 1;
if serde_json::from_str::<serde_json::Value>(&line).is_err() {
out.diagnostics.malformed_lines += 1;
} else if e.to_string().contains("unknown variant") {
out.diagnostics.unknown_events += 1;
}
}
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn ts() -> DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 4, 19, 12, 0, 0).unwrap()
}
#[test]
fn round_trip_all_variants() {
let events = vec![
SessionEvent::SessionCreated {
timestamp: ts(),
slug: "foo".into(),
topic: "topic".into(),
preset: "tech".into(),
session_dir_abs: "/tmp/foo".into(),
note: None,
},
SessionEvent::SourceAttempted {
timestamp: ts(),
url: "https://example.com".into(),
route_decision: RouteDecision {
executor: "postagent".into(),
kind: "hn-item".into(),
command_template: "...".into(),
composite: None,
},
note: None,
},
SessionEvent::SourceAccepted {
timestamp: ts(),
url: "https://example.com".into(),
kind: "hn-item".into(),
executor: "postagent".into(),
raw_path: "raw/1-hn-item.json".into(),
bytes: 1234,
trust_score: 2.0,
note: None,
composite: None,
parts: None,
part_bytes: None,
},
SessionEvent::FallbackSelected {
timestamp: ts(),
from_hand: "actionbook".into(),
to_hand: "local".into(),
reason: "daemon unavailable".into(),
note: None,
},
SessionEvent::OriginalUrlPreserved {
timestamp: ts(),
local_url: "file:///tmp/source.html".into(),
original_url: "https://example.com".into(),
origin_tool: "curl".into(),
origin_note: Some("browser failed".into()),
note: None,
},
SessionEvent::FallbackSourceAccepted {
timestamp: ts(),
local_url: "file:///tmp/source.html".into(),
original_url: "https://example.com".into(),
origin_tool: "curl".into(),
bytes: 1234,
note: None,
},
SessionEvent::SourceRejected {
timestamp: ts(),
url: "https://example.com".into(),
kind: "browser-fallback".into(),
executor: "browser".into(),
reason: RejectReason::WrongUrl,
observed_url: Some("about:blank".into()),
observed_bytes: Some(0),
rejected_raw_path: None,
note: None,
composite: None,
parts: None,
failed_part: None,
},
SessionEvent::SynthesizeStarted {
timestamp: ts(),
no_render: false,
open: false,
bilingual: true,
bilingual_provider: Some("codex".into()),
pdf: true,
pdf_provider: Some("local".into()),
note: None,
},
SessionEvent::SynthesizeCompleted {
timestamp: ts(),
report_json_path: "report.json".into(),
report_html_path: Some("report.html".into()),
report_pdf_path: Some("report.pdf".into()),
accepted_sources: 3,
rejected_sources: 1,
duration_ms: 500,
note: None,
},
SessionEvent::SynthesizeFailed {
timestamp: ts(),
stage: SynthesizeStage::Render,
reason: "json-ui not found".into(),
note: None,
},
SessionEvent::SessionClosed {
timestamp: ts(),
note: None,
},
SessionEvent::SessionRemoved {
timestamp: ts(),
note: None,
},
SessionEvent::SessionResumed {
timestamp: ts(),
note: None,
},
];
assert_eq!(events.len(), 13, "must have 13 variants");
for ev in events {
let s = serde_json::to_string(&ev).unwrap();
let back: SessionEvent = serde_json::from_str(&s).unwrap();
assert_eq!(back, ev);
}
}
#[test]
fn reject_reason_has_5_values() {
let all = [
RejectReason::FetchFailed,
RejectReason::WrongUrl,
RejectReason::EmptyContent,
RejectReason::ApiError,
RejectReason::Duplicate,
];
for r in all {
let s = serde_json::to_string(&r).unwrap();
let back: RejectReason = serde_json::from_str(&s).unwrap();
assert_eq!(back, r);
}
}
#[test]
fn roundtrips_agent_os_audit_events() {
let events = vec![
SessionEvent::ToolCallStarted {
timestamp: ts(),
call_id: "fetch-1".into(),
hand: "postagent".into(),
tool: "postagent send".into(),
input_summary: "url=https://example.test/".into(),
note: None,
},
SessionEvent::ToolCallCompleted {
timestamp: ts(),
call_id: "fetch-1".into(),
status: ToolCallStatus::Ok,
duration_ms: 42,
output_summary: "bytes=1234 warnings=0".into(),
artifact_refs: vec!["raw/1-example.json".into()],
error_code: None,
note: None,
},
SessionEvent::FactChecked {
timestamp: ts(),
iteration: 2,
claim: "Example claim".into(),
query: "Example claim source".into(),
sources: vec!["https://example.test/".into()],
outcome: FactCheckOutcome::Supported,
into_section: "## 02 - Facts".into(),
note: Some("official source".into()),
},
];
for ev in events {
let s = serde_json::to_string(&ev).unwrap();
let back: SessionEvent = serde_json::from_str(&s).unwrap();
assert_eq!(back, ev);
}
}
#[test]
fn read_events_is_line_tolerant() {
use std::io::Write;
let tmp = tempfile::NamedTempFile::new().unwrap();
let mut f = tmp.reopen().unwrap();
writeln!(f, r#"{{"event":"session_created","timestamp":"2026-04-19T12:00:00Z","slug":"foo","topic":"t","preset":"tech","session_dir_abs":"/tmp"}}"#).unwrap();
writeln!(
f,
r#"{{"event":"source_accepted","timestamp":"2026-04-19T12:00:00Z""#
)
.unwrap(); writeln!(f, r#"{{"event":"source_accepted","timestamp":"2026-04-19T12:00:00Z","url":"u","kind":"k","executor":"postagent","raw_path":"r","bytes":1,"trust_score":2.0}}"#).unwrap();
writeln!(
f,
r#"{{"event":"unknown_future_event","timestamp":"2026-04-19T12:00:00Z"}}"#
)
.unwrap();
let events = read_events(tmp.path()).unwrap();
assert_eq!(events.len(), 2, "only 2 valid events should come through");
}
}