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}