Skip to main content

warden/store/
record.rs

1//! Normalized record shapes.
2//!
3//! Fields an adapter cannot populate are `Option` and serialize as absent, so a
4//! reader can tell "not supported" from "genuinely zero".
5
6use serde::{Deserialize, Serialize};
7
8/// Current record schema version, written on every event line.
9pub const RECORD_VERSION: u32 = 1;
10
11/// One normalized event. Unknown fields are tolerated on read so newer stores
12/// can be consumed by older binaries.
13#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
14pub struct Event {
15    /// Record schema version, per line.
16    pub v: u32,
17    /// Content-derived id; identical content always yields the same id.
18    pub id: String,
19    /// Event time, epoch milliseconds UTC. Decides the month partition.
20    pub ts: i64,
21    /// Source agent, e.g. `claude-code`.
22    pub agent: String,
23    /// Model provider, e.g. `anthropic`.
24    pub provider: String,
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub model: Option<String>,
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub project: Option<String>,
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub session_id: Option<String>,
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub turn_id: Option<String>,
33    /// `assistant`, `user`, ...
34    pub role: String,
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub input_tok: Option<u64>,
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub output_tok: Option<u64>,
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub cache_read_tok: Option<u64>,
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub cache_write_tok: Option<u64>,
43    /// Unsupported by adapters that do not log it — absent, never `0`.
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub duration_ms: Option<u64>,
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub stop_reason: Option<String>,
48    /// Estimated cost; absent when the model has no configured price.
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub cost_est: Option<f64>,
51    /// Nested rather than a separate file, to avoid a join on every scan.
52    #[serde(default, skip_serializing_if = "Vec::is_empty")]
53    pub tool_calls: Vec<ToolCall>,
54    /// Whether this event came from a subagent/sidechain transcript.
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub is_sidechain: Option<bool>,
57}
58
59impl Event {
60    /// A minimally populated event at the current record version.
61    pub fn new(id: impl Into<String>, ts: i64, agent: &str, provider: &str, role: &str) -> Self {
62        Self {
63            v: RECORD_VERSION,
64            id: id.into(),
65            ts,
66            agent: agent.to_string(),
67            provider: provider.to_string(),
68            model: None,
69            project: None,
70            session_id: None,
71            turn_id: None,
72            role: role.to_string(),
73            input_tok: None,
74            output_tok: None,
75            cache_read_tok: None,
76            cache_write_tok: None,
77            duration_ms: None,
78            stop_reason: None,
79            cost_est: None,
80            tool_calls: Vec::new(),
81            is_sidechain: None,
82        }
83    }
84
85    /// Whether this record carries usage at all.
86    ///
87    /// Usage is logged once per request and most records legitimately carry
88    /// none, so "did this cost anything?" is asked on every read path. It is
89    /// answered here, once: a reader that forgets one of the four fields —
90    /// cache reads especially, which dominate agentic volume — would quietly
91    /// classify billable requests as free.
92    /// Every token this request consumed. Cache reads are the bulk of an
93    /// agentic loop's volume, so any figure that excluded them would read far
94    /// too low; absent counts contribute nothing rather than zero.
95    pub fn total_tokens(&self) -> u64 {
96        self.input_tok.unwrap_or(0)
97            + self.output_tok.unwrap_or(0)
98            + self.cache_read_tok.unwrap_or(0)
99            + self.cache_write_tok.unwrap_or(0)
100    }
101
102    pub fn has_usage(&self) -> bool {
103        self.input_tok.is_some()
104            || self.output_tok.is_some()
105            || self.cache_read_tok.is_some()
106            || self.cache_write_tok.is_some()
107    }
108}
109
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
111pub struct ToolCall {
112    pub tool_name: String,
113    /// File path or other target the tool acted on, when one is discernible.
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub tool_target: Option<String>,
116}
117
118impl ToolCall {
119    pub fn new(tool_name: impl Into<String>, tool_target: Option<String>) -> Self {
120        Self {
121            tool_name: tool_name.into(),
122            tool_target,
123        }
124    }
125}
126
127/// Prompt text, kept out of `events/` so it can be disabled independently.
128/// `text_hash` is always written so dedup works with text off.
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130pub struct PromptRecord {
131    pub event_id: String,
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub text: Option<String>,
134    pub text_hash: String,
135}
136
137/// Per-source ingest cursor. Append-only, last record for a `path` wins.
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
139pub struct IngestCursor {
140    /// Source file the cursor refers to.
141    pub path: String,
142    /// Source mtime, epoch milliseconds.
143    pub mtime: i64,
144    /// Byte offset consumed so far.
145    pub offset: u64,
146    /// Adapter that produced it.
147    pub adapter: String,
148    /// When the cursor was written, epoch milliseconds.
149    pub ts: i64,
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn unsupported_fields_serialize_as_absent_not_zero() {
158        let event = Event::new("abc", 1_754_300_000_000, "claude-code", "anthropic", "user");
159        let json = serde_json::to_string(&event).unwrap();
160        assert!(!json.contains("duration_ms"), "{json}");
161        assert!(!json.contains("cost_est"), "{json}");
162        assert!(!json.contains("tool_calls"), "{json}");
163        assert!(json.contains("\"v\":1"));
164    }
165
166    #[test]
167    fn tolerates_unknown_fields_and_missing_optionals() {
168        let line = r#"{"v":1,"id":"x","ts":5,"agent":"claude-code","provider":"anthropic",
169            "role":"assistant","future_field":{"nested":true}}"#;
170        let event: Event = serde_json::from_str(line).unwrap();
171        assert_eq!(event.id, "x");
172        assert_eq!(event.input_tok, None);
173        assert!(event.tool_calls.is_empty());
174    }
175
176    #[test]
177    fn round_trips_a_fully_populated_event() {
178        let mut event = Event::new("id", 1, "claude-code", "anthropic", "assistant");
179        event.model = Some("claude-sonnet-4-6".into());
180        event.input_tok = Some(412);
181        event.cache_write_tok = Some(0);
182        event.cost_est = Some(0.0412);
183        event.is_sidechain = Some(true);
184        event.tool_calls = vec![ToolCall::new("Read", Some("src/lib.rs".into()))];
185        let json = serde_json::to_string(&event).unwrap();
186        assert_eq!(serde_json::from_str::<Event>(&json).unwrap(), event);
187        // An explicit zero survives; it means "measured zero".
188        assert!(json.contains("\"cache_write_tok\":0"));
189    }
190}