Skip to main content

hh_core/
event.rs

1//! Event and session data model (SRS §4.1, §1.3).
2//!
3//! These types mirror the SQLite schema columns. The store converts between
4//! them and the DB via string mapping; they are the public surface used by
5//! adapters and the CLI.
6
7use serde::{Deserialize, Serialize};
8use std::fmt;
9use std::path::PathBuf;
10use std::str::FromStr;
11use uuid::Uuid;
12
13/// The `schema` integer stamped on every `hh` JSON object — session objects
14/// (`hh list --json`), event/step objects (`hh inspect --json`), and the
15/// diagnostic/lifecycle command objects (`hh doctor`/`gc`/`stats`/`scan`
16/// `--json`, `hh export`). One counter shared across all of them (see
17/// `docs/json.md`).
18///
19/// Frozen at `2` for the 1.0 series (STABILITY.md): additive changes — a new
20/// field, a new enum value, a new `body` shape — are documented in
21/// `docs/json.md` without bumping this constant further. It only moves again
22/// for a breaking change, which ships behind `hh`'s own major-version bump.
23/// `2` itself folds in the beta-era additive drift that never got a version
24/// bump: three new `agent_kind` values (`claude-desktop`, `codex-cli`,
25/// `gemini-cli`) and the `lifecycle` event's `redaction_audit` body shape.
26pub const JSON_SCHEMA_VERSION: u64 = 2;
27
28/// Session status (SRS §4.1 `sessions.status`).
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum SessionStatus {
31    /// Session is actively being recorded.
32    Recording,
33    /// Agent exited cleanly (exit code 0).
34    Ok,
35    /// Agent exited with a nonzero exit code.
36    Error,
37    /// `hh` or the agent was killed before finalization (FR-1.7).
38    Interrupted,
39}
40
41impl fmt::Display for SessionStatus {
42    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43        f.write_str(match self {
44            Self::Recording => "recording",
45            Self::Ok => "ok",
46            Self::Error => "error",
47            Self::Interrupted => "interrupted",
48        })
49    }
50}
51
52impl FromStr for SessionStatus {
53    type Err = String;
54    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
55        match s {
56            "recording" => Ok(Self::Recording),
57            "ok" => Ok(Self::Ok),
58            "error" => Ok(Self::Error),
59            "interrupted" => Ok(Self::Interrupted),
60            other => Err(format!("unknown session status `{other}`")),
61        }
62    }
63}
64
65/// Detected agent kind (SRS §4.1 `sessions.agent_kind`).
66///
67/// `#[non_exhaustive]`: new agent kinds are added over time (this PR added
68/// `ClaudeDesktop`, `CodexCli`, `GeminiCli`). Marking it non-exhaustive keeps
69/// that additive under `cargo-semver-checks --release-type minor` instead of
70/// registering as a break — a downstream `match` must already carry a wildcard
71/// arm, matching CLAUDE.md's v1.0.0 addendum ("additive changes ... not
72/// breaking"). New variants are appended at the end so existing discriminant
73/// values (used by `as isize` casts) do not shift.
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
75#[serde(rename_all = "kebab-case")]
76#[non_exhaustive]
77pub enum AgentKind {
78    /// Claude Code (structured adapter active).
79    ClaudeCode,
80    /// Generic agent, PTY-only capture.
81    Generic,
82    /// Standalone `hh mcp-proxy` session (FR-2.2).
83    McpOnly,
84    /// Claude Desktop app (structured adapter active).
85    ClaudeDesktop,
86    /// OpenAI Codex CLI (structured adapter active).
87    CodexCli,
88    /// Google Gemini CLI (structured adapter active).
89    GeminiCli,
90}
91
92impl fmt::Display for AgentKind {
93    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94        f.write_str(match self {
95            Self::ClaudeCode => "claude-code",
96            Self::ClaudeDesktop => "claude-desktop",
97            Self::CodexCli => "codex-cli",
98            Self::GeminiCli => "gemini-cli",
99            Self::Generic => "generic",
100            Self::McpOnly => "mcp-only",
101        })
102    }
103}
104
105impl FromStr for AgentKind {
106    type Err = String;
107    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
108        match s {
109            "claude-code" => Ok(Self::ClaudeCode),
110            "claude-desktop" => Ok(Self::ClaudeDesktop),
111            "codex-cli" => Ok(Self::CodexCli),
112            "gemini-cli" => Ok(Self::GeminiCli),
113            "generic" => Ok(Self::Generic),
114            "mcp-only" => Ok(Self::McpOnly),
115            other => Err(format!("unknown agent kind `{other}`")),
116        }
117    }
118}
119
120/// Adapter state (SRS §4.1 `sessions.adapter_status`).
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
122pub enum AdapterStatus {
123    /// No adapter applicable.
124    #[default]
125    None,
126    /// Adapter is tailing structured events.
127    Active,
128    /// Adapter failed but PTY/FS recording continues (FR-1.5).
129    Degraded,
130}
131
132impl fmt::Display for AdapterStatus {
133    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
134        f.write_str(match self {
135            Self::None => "none",
136            Self::Active => "active",
137            Self::Degraded => "degraded",
138        })
139    }
140}
141
142/// Event kind (SRS §4.1 `events.kind`).
143#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
144#[serde(rename_all = "snake_case")]
145pub enum EventKind {
146    /// Session lifecycle marker (start/end/exit).
147    Lifecycle,
148    /// A user prompt.
149    UserMessage,
150    /// An assistant message.
151    AgentMessage,
152    /// Model reasoning/thinking block.
153    Thinking,
154    /// A tool call.
155    ToolCall,
156    /// A tool result.
157    ToolResult,
158    /// An MCP request.
159    McpRequest,
160    /// An MCP response.
161    McpResponse,
162    /// An MCP notification.
163    McpNotification,
164    /// A file change.
165    FileChange,
166    /// Raw terminal output chunk (not a step; FR-3.4).
167    TerminalOutput,
168    /// An error.
169    Error,
170}
171
172impl fmt::Display for EventKind {
173    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
174        f.write_str(match self {
175            Self::Lifecycle => "lifecycle",
176            Self::UserMessage => "user_message",
177            Self::AgentMessage => "agent_message",
178            Self::Thinking => "thinking",
179            Self::ToolCall => "tool_call",
180            Self::ToolResult => "tool_result",
181            Self::McpRequest => "mcp_request",
182            Self::McpResponse => "mcp_response",
183            Self::McpNotification => "mcp_notification",
184            Self::FileChange => "file_change",
185            Self::TerminalOutput => "terminal_output",
186            Self::Error => "error",
187        })
188    }
189}
190
191impl FromStr for EventKind {
192    type Err = String;
193    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
194        match s {
195            "lifecycle" => Ok(Self::Lifecycle),
196            "user_message" => Ok(Self::UserMessage),
197            "agent_message" => Ok(Self::AgentMessage),
198            "thinking" => Ok(Self::Thinking),
199            "tool_call" => Ok(Self::ToolCall),
200            "tool_result" => Ok(Self::ToolResult),
201            "mcp_request" => Ok(Self::McpRequest),
202            "mcp_response" => Ok(Self::McpResponse),
203            "mcp_notification" => Ok(Self::McpNotification),
204            "file_change" => Ok(Self::FileChange),
205            "terminal_output" => Ok(Self::TerminalOutput),
206            "error" => Ok(Self::Error),
207            other => Err(format!("unknown event kind `{other}`")),
208        }
209    }
210}
211
212/// File change kind (SRS §4.1 `file_changes.change_kind`).
213#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
214#[serde(rename_all = "lowercase")]
215pub enum ChangeKind {
216    /// File was created.
217    Created,
218    /// File was modified.
219    Modified,
220    /// File was deleted.
221    Deleted,
222}
223
224impl fmt::Display for ChangeKind {
225    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
226        f.write_str(match self {
227            Self::Created => "created",
228            Self::Modified => "modified",
229            Self::Deleted => "deleted",
230        })
231    }
232}
233
234impl FromStr for ChangeKind {
235    type Err = String;
236    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
237        match s {
238            "created" => Ok(Self::Created),
239            "modified" => Ok(Self::Modified),
240            "deleted" => Ok(Self::Deleted),
241            other => Err(format!("unknown change kind `{other}`")),
242        }
243    }
244}
245
246/// Input for creating a new session (FR-1.2).
247#[derive(Debug, Clone)]
248pub struct NewSession {
249    /// UUIDv7 id (generated by the caller via [`Uuid::now_v7`] or supplied).
250    pub id: Uuid,
251    /// Unix-ms UTC start timestamp.
252    pub started_at: i64,
253    /// Detected agent kind.
254    pub agent_kind: AgentKind,
255    /// Adapter state for this session (FR-1.5): `Active` while a structured
256    /// adapter is tailing events, `Degraded` if it failed but PTY/FS recording
257    /// continues, `None` for a generic PTY-only session. Written to the
258    /// `sessions.adapter_status` column by [`crate::store::Store::create_session`]
259    /// and updated at finalize via
260    /// [`crate::store::Store::set_session_adapter_meta`].
261    pub adapter_status: AdapterStatus,
262    /// Full command line as an argv vector (stored as JSON).
263    pub command: Vec<String>,
264    /// Working directory.
265    pub cwd: PathBuf,
266    /// Hostname, if known.
267    pub hostname: Option<String>,
268    /// Halfhand version string.
269    pub hh_version: String,
270    /// Model name, if an adapter reported one.
271    pub model: Option<String>,
272    /// Git branch, if cwd is a repo.
273    pub git_branch: Option<String>,
274    /// Git HEAD sha, if cwd is a repo.
275    pub git_sha: Option<String>,
276    /// Whether the git tree had uncommitted changes.
277    pub git_dirty: Option<bool>,
278}
279
280/// Generate a fresh UUIDv7 session id (SRS §1.3). Centralized here so the
281/// recorder does not need to depend on `uuid` directly.
282#[must_use]
283pub fn now_v7() -> Uuid {
284    Uuid::now_v7()
285}
286
287impl NewSession {
288    /// The 6-hex-char short id (SRS §1.3/FR-1.2).
289    ///
290    /// # SRS deviation (flagged)
291    ///
292    /// The SRS says "first 6 hex of the UUID", but a UUIDv7's first 12 hex are
293    /// the 48-bit unix-ms timestamp, so the first 6 hex are ~constant for ~194
294    /// days and would collide immediately under the `short_id UNIQUE`
295    /// constraint (SRS §4.1). To keep short ids actually unique and useful as
296    /// the SRS intends, we derive the 6 hex from the random tail of the UUIDv7
297    /// (`simple[26..32]`, part of `rand_b`). This is a deviation from the
298    /// literal wording that should be reconciled with the SRS author; see the
299    /// decisions summary and `docs/adr/`.
300    pub fn short_id(&self) -> String {
301        let simple = self.id.simple().to_string();
302        simple[26..32].to_string()
303    }
304}
305
306/// A session row as read back from the DB.
307///
308/// `#[non_exhaustive]`: this is a growth-prone read model (this PR adds
309/// `imported_from`; more fields — `hostname`/`model`/`git_*`, none of which
310/// `SessionRow` exposes today — are a plausible future addition). Marking it
311/// non-exhaustive now means a future added field stays additive under
312/// `cargo-semver-checks --release-type minor` instead of registering as a
313/// break, matching the precedent set for `Config`/the error enums. External
314/// construction goes through [`Default`] + functional-record-update
315/// (`SessionRow { field: value, ..SessionRow::default() }`), which — unlike
316/// a struct literal naming every field — stays valid across a
317/// `#[non_exhaustive]` boundary; `hh`'s own test fixtures use this pattern.
318#[derive(Debug, Clone)]
319#[non_exhaustive]
320pub struct SessionRow {
321    /// Full UUID string.
322    pub id: String,
323    /// 6-hex-char short id.
324    pub short_id: String,
325    /// Unix-ms UTC start timestamp.
326    pub started_at: i64,
327    /// Unix-ms UTC end timestamp, if finalized.
328    pub ended_at: Option<i64>,
329    /// Process exit code, if finalized.
330    pub exit_code: Option<i32>,
331    /// Session status.
332    pub status: SessionStatus,
333    /// Agent kind.
334    pub agent_kind: AgentKind,
335    /// Adapter status.
336    pub adapter_status: AdapterStatus,
337    /// Full command line.
338    pub command: Vec<String>,
339    /// Working directory.
340    pub cwd: PathBuf,
341    /// Count of steps (semantic events) in the session.
342    pub step_count: i64,
343    /// Count of distinct file paths changed.
344    pub files_changed: i64,
345    /// The original session id this session was imported from (`hh import`),
346    /// or `None` for a locally-recorded session (SRS v1.0.0 addendum: additive
347    /// column, migration 0003).
348    pub imported_from: Option<String>,
349}
350
351impl Default for SessionRow {
352    /// Neutral placeholder values — the sanctioned way to build a
353    /// `#[non_exhaustive]` `SessionRow` from outside this crate is
354    /// `SessionRow { field: value, ..SessionRow::default() }`, not a full
355    /// struct literal. Real rows always come from [`crate::store::Store`]
356    /// queries; this exists for that FRU pattern (test fixtures today, any
357    /// future caller tomorrow), not for direct use.
358    fn default() -> Self {
359        Self {
360            id: String::new(),
361            short_id: String::new(),
362            started_at: 0,
363            ended_at: None,
364            exit_code: None,
365            status: SessionStatus::Recording,
366            agent_kind: AgentKind::Generic,
367            adapter_status: AdapterStatus::None,
368            command: Vec::new(),
369            cwd: PathBuf::new(),
370            step_count: 0,
371            files_changed: 0,
372            imported_from: None,
373        }
374    }
375}
376
377/// An event to append to a session (SRS §4.1 `events`).
378#[derive(Debug, Clone, Serialize)]
379pub struct Event {
380    /// Owning session id (full UUID string).
381    pub session_id: String,
382    /// Milliseconds since session start.
383    pub ts_ms: i64,
384    /// Event kind.
385    pub kind: EventKind,
386    /// 1-based step ordinal, or `None` for non-step events (FR-3.4).
387    pub step: Option<i64>,
388    /// One-line summary (≤ 120 chars per SRS §4.1).
389    pub summary: String,
390    /// Kind-specific structured payload.
391    ///
392    /// # Cross-event correlation (`correlate_key`)
393    ///
394    /// Adapters cannot know the DB row id an event will receive, so to express
395    /// "this tool_result belongs to that tool_call" they emit a string
396    /// `correlate_key` field inside `body_json` (the Claude adapter uses the
397    /// `tool_use.id` for a `tool_call` and the `tool_use_id` for a
398    /// `tool_result`). The recorder's drain thread resolves that key to the
399    /// referenced event's row id and stores it in [`Event::correlates`]. The
400    /// FR-3.4 step pass then makes a correlated `tool_result` share its
401    /// `tool_call`'s step. See the `adapter` module and FR-1.5.
402    pub body_json: Option<serde_json::Value>,
403    /// Blob hash for large payloads stored out-of-line.
404    pub blob_hash: Option<String>,
405    /// Uncompressed size of the blob, if `blob_hash` is set. The writer uses
406    /// this to seed the `blobs.size` column on first reference.
407    pub blob_size: Option<u64>,
408    /// Correlated event id (e.g. tool_result → tool_call).
409    pub correlates: Option<i64>,
410}
411
412/// An event row read back from the DB (input to the FR-3.4 step pass).
413///
414/// Unlike [`Event`] (the append form), this carries the DB row `id` so the
415/// step pass can express correlation by id and the store can write the
416/// assigned `step` back to the right row.
417#[derive(Debug, Clone)]
418pub struct EventRow {
419    /// The event row id (`events.id`).
420    pub id: i64,
421    /// Owning session id (full UUID string).
422    pub session_id: String,
423    /// Milliseconds since session start.
424    pub ts_ms: i64,
425    /// Event kind.
426    pub kind: EventKind,
427    /// 1-based step ordinal, or `None` for non-step events (FR-3.4).
428    pub step: Option<i64>,
429    /// Correlated event id (e.g. tool_result → tool_call).
430    pub correlates: Option<i64>,
431}
432
433/// A lightweight per-event row for the replay/inspect timeline index (FR-3.5):
434/// carries the one-line `summary` but not `body_json`/`blob_hash`, so loading
435/// the full index for a session (including `terminal_output`) is cheap. Full
436/// payloads are fetched on demand per selected row via
437/// [`crate::store::Store::get_event_detail`].
438#[derive(Debug, Clone)]
439pub struct EventIndexRow {
440    /// The event row id (`events.id`).
441    pub id: i64,
442    /// Milliseconds since session start.
443    pub ts_ms: i64,
444    /// Event kind.
445    pub kind: EventKind,
446    /// 1-based step ordinal, or `None` for non-step events (`terminal_output`).
447    pub step: Option<i64>,
448    /// Correlated event id (e.g. `tool_result` → `tool_call`).
449    pub correlates: Option<i64>,
450    /// One-line summary (≤ 120 chars).
451    pub summary: String,
452}
453
454/// A fully-loaded event, fetched lazily for the selected row (FR-3.5). Unlike
455/// [`EventIndexRow`], `body_json` here is display-ready: a blob-overflowed
456/// payload has already been resolved (fetched + decompressed) by
457/// [`crate::store::Store::get_event_detail`] so callers never see the raw
458/// `{"overflow": true, ...}` envelope.
459#[derive(Debug, Clone)]
460pub struct EventDetail {
461    /// The event row id.
462    pub id: i64,
463    /// Owning session id.
464    pub session_id: String,
465    /// Milliseconds since session start.
466    pub ts_ms: i64,
467    /// Event kind.
468    pub kind: EventKind,
469    /// 1-based step ordinal, or `None` for non-step events.
470    pub step: Option<i64>,
471    /// Correlated event id.
472    pub correlates: Option<i64>,
473    /// One-line summary.
474    pub summary: String,
475    /// Kind-specific structured payload, resolved from the blob store if it
476    /// had overflowed inline storage.
477    pub body_json: Option<serde_json::Value>,
478    /// The attached `file_changes` row, present only for `kind == FileChange`.
479    pub file_change: Option<FileChange>,
480}
481
482/// One event row exactly as stored — no blob-overflow resolution (unlike
483/// [`EventDetail`]). Used by `hh-core::bundle` (`hh export --bundle`), which
484/// must carry every referenced blob byte-for-byte rather than inlining only
485/// the ones that happen to resolve as JSON (see
486/// [`crate::store::Store::for_each_event_raw`]).
487#[derive(Debug, Clone)]
488pub struct RawEventRow {
489    /// The event row id.
490    pub id: i64,
491    /// Milliseconds since session start.
492    pub ts_ms: i64,
493    /// Event kind.
494    pub kind: EventKind,
495    /// 1-based step ordinal, or `None` for non-step events.
496    pub step: Option<i64>,
497    /// Correlated event id.
498    pub correlates: Option<i64>,
499    /// One-line summary.
500    pub summary: String,
501    /// Kind-specific structured payload, exactly as stored (may be an
502    /// unresolved `{"overflow": true, ...}` envelope).
503    pub body_json: Option<serde_json::Value>,
504    /// Blob hash referenced by this event's `events.blob_hash` column, if any.
505    pub blob_hash: Option<String>,
506    /// Uncompressed size of the referenced blob, if `blob_hash` is set.
507    pub blob_size: Option<u64>,
508    /// The attached `file_changes` row, present only for `kind == FileChange`.
509    pub file_change: Option<FileChange>,
510}
511
512/// A file change row attached to an event (SRS §4.1 `file_changes`).
513#[derive(Debug, Clone)]
514pub struct FileChange {
515    /// The owning event id.
516    pub event_id: i64,
517    /// Path relative to the session cwd.
518    pub path: String,
519    /// Change kind.
520    pub change_kind: ChangeKind,
521    /// Pre-change blob hash, if known.
522    pub before_hash: Option<String>,
523    /// Post-change blob hash, if known.
524    pub after_hash: Option<String>,
525    /// Whether the file was detected as binary.
526    pub is_binary: bool,
527}
528
529/// Truncate a summary to the SRS §4.1 limit of 120 chars, appending `…` if it
530/// was longer. Shared by the recorder (PTY/FS capture) and the adapters so the
531/// `events.summary` length constraint is enforced in one place.
532#[must_use]
533pub fn truncate_summary(s: &str) -> String {
534    const LIMIT: usize = 120;
535    if s.chars().count() <= LIMIT {
536        return s.to_string();
537    }
538    let truncated: String = s.chars().take(LIMIT - 1).collect();
539    format!("{truncated}…")
540}