use std::fs::OpenOptions;
use std::io::Write;
use std::path::Path;
use anyhow::Result;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum EventKind {
#[default]
ToolCall,
Human,
}
impl EventKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::ToolCall => "tool_call",
Self::Human => "human",
}
}
pub fn is_tool_call(&self) -> bool {
*self == Self::ToolCall
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct HumanEvent {
pub source: HumanSource,
pub action: HumanAction,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target: Option<HumanTarget>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub value: Option<HumanValue>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub verification_hint: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub frame_ref: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(transparent)]
pub struct HumanAction(pub String);
impl HumanAction {
pub fn as_str(&self) -> &str {
&self.0
}
}
impl From<&str> for HumanAction {
fn from(value: &str) -> Self {
Self(value.to_string())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum HumanSource {
Browser {
#[serde(default, skip_serializing_if = "Option::is_none")]
url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
tab_id: Option<String>,
},
MacApp {
#[serde(default, skip_serializing_if = "Option::is_none")]
app: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
window_title: Option<String>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct HumanTarget {
pub primary: TargetLocator,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub alternates: Vec<TargetLocator>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub role: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub placeholder: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub element_summary: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum TargetLocator {
Role {
role: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
name: Option<String>,
},
Label {
value: String,
},
Placeholder {
value: String,
},
TestId {
value: String,
},
Css {
value: String,
},
XPath {
value: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "policy", rename_all = "snake_case")]
pub enum HumanValue {
Omitted {
reason: String,
},
Redacted {
kind: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
chars: Option<usize>,
},
Literal {
value: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Event {
pub ts: String,
pub seq: u64,
pub tool_name: String,
#[serde(default)]
pub tool_input: serde_json::Value,
#[serde(default)]
pub tool_response: serde_json::Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
#[serde(default, skip_serializing_if = "EventKind::is_tool_call")]
pub event_kind: EventKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub human: Option<HumanEvent>,
}
pub fn append_event(span_path: &Path, event: &Event) -> Result<()> {
let line = serde_json::to_string(event)?;
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(span_path)?;
file.write_all(line.as_bytes())?;
file.write_all(b"\n")?;
Ok(())
}
pub fn fsync(span_path: &Path) -> Result<()> {
OpenOptions::new().read(true).open(span_path)?.sync_all()?;
Ok(())
}
pub fn count_events(span_path: &Path) -> u64 {
match std::fs::read_to_string(span_path) {
Ok(contents) => contents.lines().filter(|l| !l.trim().is_empty()).count() as u64,
Err(_) => 0,
}
}
pub fn read_span(span_path: &Path) -> Result<Vec<Event>> {
let contents = std::fs::read_to_string(span_path)?;
let mut events = Vec::new();
for line in contents.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if let Ok(event) = serde_json::from_str::<Event>(line) {
events.push(event);
}
}
Ok(events)
}
#[cfg(test)]
mod tests {
use super::*;
fn sample(seq: u64) -> Event {
Event {
ts: "2026-06-19T00:00:00Z".into(),
seq,
tool_name: "Bash".into(),
tool_input: serde_json::json!({ "command": "echo hi" }),
tool_response: serde_json::json!({ "exit_code": 0 }),
cwd: Some("/tmp".into()),
session_id: Some("s1".into()),
event_kind: EventKind::ToolCall,
human: None,
}
}
#[test]
fn append_then_read_roundtrips() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("span.jsonl");
assert_eq!(count_events(&path), 0, "missing file counts as zero events");
append_event(&path, &sample(0)).unwrap();
append_event(&path, &sample(1)).unwrap();
assert_eq!(count_events(&path), 2);
let events = read_span(&path).unwrap();
assert_eq!(events.len(), 2);
assert_eq!(events[0].seq, 0);
assert_eq!(events[1].seq, 1);
assert_eq!(events[0].tool_name, "Bash");
}
#[test]
fn read_skips_corrupt_lines() {
use std::io::Write;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("span.jsonl");
append_event(&path, &sample(0)).unwrap();
let mut file = OpenOptions::new().append(true).open(&path).unwrap();
writeln!(file, "{{ not valid json").unwrap();
assert_eq!(count_events(&path), 2);
assert_eq!(read_span(&path).unwrap().len(), 1);
}
#[test]
fn old_tool_call_json_defaults_event_kind() {
let event: Event = serde_json::from_str(
r#"{"ts":"2026-06-19T00:00:00Z","seq":0,"tool_name":"Bash","tool_input":{"command":"git status"}}"#,
)
.unwrap();
assert_eq!(event.event_kind, EventKind::ToolCall);
assert!(event.human.is_none());
}
#[test]
fn human_event_roundtrips() {
let event = Event {
ts: "2026-06-19T00:00:00Z".into(),
seq: 0,
tool_name: "human.browser.click".into(),
tool_input: serde_json::Value::Null,
tool_response: serde_json::Value::Null,
cwd: None,
session_id: None,
event_kind: EventKind::Human,
human: Some(HumanEvent {
source: HumanSource::Browser {
url: Some("https://example.test".into()),
title: Some("Example".into()),
tab_id: None,
},
action: HumanAction::from("human.browser.click"),
target: Some(HumanTarget {
primary: TargetLocator::Role {
role: "button".into(),
name: Some("Create issue".into()),
},
alternates: vec![TargetLocator::Css {
value: "button#create".into(),
}],
role: Some("button".into()),
name: Some("Create issue".into()),
text: None,
label: None,
placeholder: None,
element_summary: None,
}),
value: None,
verification_hint: Some("Confirm the issue was created.".into()),
frame_ref: None,
}),
};
let encoded = serde_json::to_string(&event).unwrap();
assert!(encoded.contains(r#""event_kind":"human""#));
let decoded: Event = serde_json::from_str(&encoded).unwrap();
assert_eq!(decoded.event_kind, EventKind::Human);
assert_eq!(
decoded.human.unwrap().action.as_str(),
"human.browser.click"
);
}
}