Skip to main content

koda_core/
persistence.rs

1//! Persistence trait — the storage contract for koda.
2//!
3//! Types and trait definition for the storage layer. The engine
4//! depends on this trait, not the concrete SQLite implementation.
5//!
6//! The default implementation is `Database` in `db.rs`.
7//!
8//! ## Design (DESIGN.md)
9//!
10//! - **Database Backend: SQLite + Persistence Trait (P3)**: This trait
11//!   exists because P3 says the storage backend will change. But there's
12//!   only one real implementation because P1 says we don't need a second
13//!   one yet. When P1 and P3 conflict: P1 wins on timing, P3 wins on
14//!   architecture.
15
16use anyhow::Result;
17use std::path::Path;
18
19/// Message roles in the conversation.
20#[derive(Debug, Clone, PartialEq)]
21#[allow(dead_code)]
22pub enum Role {
23    /// System prompt.
24    System,
25    /// User message.
26    User,
27    /// Assistant (LLM) response.
28    Assistant,
29    /// Tool result.
30    Tool,
31}
32
33impl Role {
34    /// String representation for database storage.
35    ///
36    /// # Examples
37    ///
38    /// ```
39    /// use koda_core::persistence::Role;
40    ///
41    /// assert_eq!(Role::User.as_str(), "user");
42    /// assert_eq!(Role::Assistant.as_str(), "assistant");
43    /// ```
44    pub fn as_str(&self) -> &'static str {
45        match self {
46            Self::System => "system",
47            Self::User => "user",
48            Self::Assistant => "assistant",
49            Self::Tool => "tool",
50        }
51    }
52}
53
54impl std::fmt::Display for Role {
55    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        write!(f, "{}", self.as_str())
57    }
58}
59
60impl std::str::FromStr for Role {
61    type Err = String;
62    fn from_str(s: &str) -> Result<Self, Self::Err> {
63        match s {
64            "system" => Ok(Self::System),
65            "user" => Ok(Self::User),
66            "assistant" => Ok(Self::Assistant),
67            "tool" => Ok(Self::Tool),
68            other => Err(format!("unknown role: {other}")),
69        }
70    }
71}
72
73/// A stored message row.
74#[derive(Debug, Clone)]
75#[allow(dead_code)]
76pub struct Message {
77    /// Database row ID.
78    pub id: i64,
79    /// Session this message belongs to.
80    pub session_id: String,
81    /// Message role (system, user, assistant, tool).
82    pub role: Role,
83    /// Text content (may be a summary for Bash results).
84    pub content: Option<String>,
85    /// Full untruncated output (only set for Bash tool results).
86    pub full_content: Option<String>,
87    /// Serialized tool calls JSON.
88    pub tool_calls: Option<String>,
89    /// ID of the tool call this responds to.
90    pub tool_call_id: Option<String>,
91    /// Input tokens for this message.
92    pub prompt_tokens: Option<i64>,
93    /// Output tokens for this message.
94    pub completion_tokens: Option<i64>,
95    /// Cached input tokens.
96    pub cache_read_tokens: Option<i64>,
97    /// Tokens written to cache.
98    pub cache_creation_tokens: Option<i64>,
99    /// Reasoning/thinking tokens.
100    pub thinking_tokens: Option<i64>,
101    /// Full thinking/reasoning text produced by Claude extended thinking.
102    ///
103    /// `None` for non-Claude models, or when thinking was disabled.
104    /// Persisted so the content can be re-rendered on session resume.
105    pub thinking_content: Option<String>,
106    /// ISO 8601 creation timestamp.
107    pub created_at: Option<String>,
108}
109
110/// Detected interruption state for a resumed session.
111///
112/// Returned by [`detect_interruption`](crate::db::queries::detect_interruption)
113/// after inspecting the tail of the message history.
114///
115/// ## Design decision: banner, not auto-resume
116///
117/// Claude Code auto-continues interrupted turns (re-sends the prompt or
118/// injects "Continue from where you left off"). Koda deliberately shows a
119/// banner and lets the user decide, for three reasons:
120///
121/// 1. **Safety** — auto-resuming a destructive tool call (e.g. `rm -rf`)
122///    after a VPN drop is surprising. The user should see the state first.
123/// 2. **Stale context** — the user may have fixed the issue manually while
124///    Koda was disconnected. Auto-resume wastes tokens re-doing work.
125/// 3. **Cost** — resuming to *check history* shouldn't burn an API call.
126///
127/// A single "type `continue` or rephrase" banner is near-zero friction
128/// (one word + Enter) and handles all three cases.
129#[derive(Debug, Clone, PartialEq)]
130pub enum InterruptionKind {
131    /// The user's prompt was never answered (last message is `Role::User`).
132    /// Contains a preview of the unanswered prompt.
133    Prompt(String),
134    /// A tool finished but the assistant never processed the result
135    /// (last message is `Role::Tool`).
136    Tool,
137}
138
139/// Token usage totals for a session.
140#[derive(Debug, Clone, Default)]
141pub struct SessionUsage {
142    /// Total input tokens.
143    pub prompt_tokens: i64,
144    /// Total output tokens.
145    pub completion_tokens: i64,
146    /// Total cached input tokens.
147    pub cache_read_tokens: i64,
148    /// Total tokens written to cache.
149    pub cache_creation_tokens: i64,
150    /// Total reasoning/thinking tokens.
151    pub thinking_tokens: i64,
152    /// Number of API calls made.
153    pub api_calls: i64,
154}
155
156/// Summary info for a stored session.
157#[derive(Debug, Clone)]
158pub struct SessionInfo {
159    /// Session identifier.
160    pub id: String,
161    /// Agent name for this session.
162    pub agent_name: String,
163    /// ISO 8601 creation timestamp.
164    pub created_at: String,
165    /// Total messages in the session.
166    pub message_count: i64,
167    /// Cumulative token count.
168    pub total_tokens: i64,
169    /// Auto-generated title from first user message.
170    pub title: Option<String>,
171    /// Last active approval mode (for restore on resume).
172    pub mode: Option<String>,
173}
174
175/// Stats about compacted (archived) messages in the database.
176#[derive(Debug, Clone, Default)]
177pub struct CompactedStats {
178    /// Number of compacted messages.
179    pub message_count: i64,
180    /// Number of sessions with compacted messages.
181    pub session_count: i64,
182    /// Approximate size in bytes of compacted message content.
183    pub size_bytes: i64,
184    /// ISO 8601 timestamp of the oldest compacted message.
185    pub oldest: Option<String>,
186}
187
188/// Core storage contract for sessions, messages, and metadata.
189#[async_trait::async_trait]
190pub trait Persistence: Send + Sync {
191    // ── Sessions ──
192
193    /// Create a new session, returning its unique ID.
194    async fn create_session(&self, agent_name: &str, project_root: &Path) -> Result<String>;
195    /// List recent sessions for the given project root.
196    async fn list_sessions(&self, limit: i64, project_root: &Path) -> Result<Vec<SessionInfo>>;
197    /// Delete a session by ID. Returns `true` if it existed.
198    async fn delete_session(&self, session_id: &str) -> Result<bool>;
199    /// Set the auto-generated title for a session.
200    async fn set_session_title(&self, session_id: &str, title: &str) -> Result<()>;
201    /// Persist the current approval mode for a session (restored on resume).
202    async fn set_session_mode(&self, session_id: &str, mode: &str) -> Result<()>;
203    /// Get the stored approval mode for a session.
204    async fn get_session_mode(&self, session_id: &str) -> Result<Option<String>>;
205    /// Seconds elapsed since the session was last accessed (`last_accessed_at`).
206    /// Returns `None` if the column is NULL (session never had a context load).
207    async fn get_session_idle_secs(&self, session_id: &str) -> Result<Option<i64>>;
208
209    // ── Messages ──
210
211    /// Insert a message into a session.
212    async fn insert_message(
213        &self,
214        session_id: &str,
215        role: &Role,
216        content: Option<&str>,
217        tool_calls: Option<&str>,
218        tool_call_id: Option<&str>,
219        usage: Option<&crate::providers::TokenUsage>,
220    ) -> Result<i64>;
221
222    /// Insert a message with an explicit agent name (for sub-agent tracking).
223    #[allow(clippy::too_many_arguments)]
224    async fn insert_message_with_agent(
225        &self,
226        session_id: &str,
227        role: &Role,
228        content: Option<&str>,
229        tool_calls: Option<&str>,
230        tool_call_id: Option<&str>,
231        usage: Option<&crate::providers::TokenUsage>,
232        agent_name: Option<&str>,
233    ) -> Result<i64>;
234
235    /// Insert a tool message with full (untruncated) output stored separately.
236    ///
237    /// `content` holds the model-facing summary; `full_content` holds the
238    /// complete output for later retrieval via RecallContext.
239    #[allow(clippy::too_many_arguments)]
240    async fn insert_tool_message_with_full(
241        &self,
242        session_id: &str,
243        content: &str,
244        tool_call_id: &str,
245        full_content: &str,
246    ) -> Result<i64>;
247
248    /// Load active (non-compacted) conversation context for a session.
249    async fn load_context(&self, session_id: &str) -> Result<Vec<Message>>;
250    /// Load all messages in a session (no token limit).
251    async fn load_all_messages(&self, session_id: &str) -> Result<Vec<Message>>;
252    /// Recent user messages across all sessions (for startup hints).
253    async fn recent_user_messages(&self, limit: i64) -> Result<Vec<String>>;
254    /// Last assistant message in a session.
255    async fn last_assistant_message(&self, session_id: &str) -> Result<String>;
256    /// Last user message in a session.
257    async fn last_user_message(&self, session_id: &str) -> Result<String>;
258    /// Check if the session has unresolved tool calls.
259    async fn has_pending_tool_calls(&self, session_id: &str) -> Result<bool>;
260
261    /// Mark an assistant message as fully delivered.
262    ///
263    /// Sets `completed_at = CURRENT_TIMESTAMP`. Only called after a legitimate
264    /// `StreamChunk::Done` — not after user cancellation or a network error.
265    /// A `NULL` `completed_at` means the message is in-progress or was interrupted.
266    async fn mark_message_complete(&self, message_id: i64) -> Result<()>;
267
268    /// Atomically copy a slice of pre-loaded messages into a session.
269    ///
270    /// Used by `fork` sub-agent dispatch (#1022 B20). Pre-fix, the fork
271    /// path looped over `Persistence::insert_message` row-by-row — each
272    /// iteration was 2 queries (INSERT + UPDATE last_accessed_at) plus,
273    /// for assistant rows, a separate `mark_message_complete` (1 more
274    /// query). For an N-message parent history that's ~3N round-trips
275    /// to SQLite, each its own commit, on the synchronous fork hot path.
276    ///
277    /// This method:
278    /// - Wraps every INSERT in a single transaction (one fsync at COMMIT
279    ///   instead of N).
280    /// - Sets `completed_at = datetime('now')` inline at INSERT time for
281    ///   `Role::Assistant` rows, eliminating the separate
282    ///   `mark_message_complete` round-trip per row.
283    /// - Updates `last_accessed_at` on the destination session exactly
284    ///   once at the end (not per-row).
285    ///
286    /// Token usage stats are intentionally **not** copied: fork creates a
287    /// new conversation perspective and cost-attributing parent tokens
288    /// to the child would double-count session usage. Same policy as the
289    /// pre-fix loop, which passed `usage = None` for every copied row.
290    async fn copy_messages_into_session(
291        &self,
292        dst_session: &str,
293        messages: &[Message],
294    ) -> Result<()>;
295
296    /// Persist thinking/reasoning text for an assistant message.
297    ///
298    /// Called only for Claude with extended thinking enabled. All other
299    /// providers leave `thinking_content` NULL.
300    async fn update_message_thinking_content(&self, message_id: i64, content: &str) -> Result<()>;
301
302    // ── Token usage ──
303
304    /// Token usage totals for a session.
305    async fn session_token_usage(&self, session_id: &str) -> Result<SessionUsage>;
306    /// Token usage broken down by agent name.
307    async fn session_usage_by_agent(&self, session_id: &str)
308    -> Result<Vec<(String, SessionUsage)>>;
309
310    // ── Compaction ──
311
312    /// Compact old messages into a summary, preserving the last N messages.
313    async fn compact_session(
314        &self,
315        session_id: &str,
316        summary: &str,
317        preserve_count: usize,
318    ) -> Result<usize>;
319
320    // ── Microcompact ──
321
322    /// Replace message content for the given IDs with a stub string.
323    /// Used by microcompact to clear old tool results without full compaction.
324    async fn clear_message_content(&self, message_ids: &[i64], stub: &str) -> Result<()>;
325
326    // ── Purge ──
327
328    /// Stats about compacted (archived) messages across all sessions.
329    async fn compacted_stats(&self) -> Result<CompactedStats>;
330    /// Permanently delete compacted messages older than `min_age_days`.
331    /// Returns the number of messages deleted.
332    async fn purge_compacted(&self, min_age_days: u32) -> Result<usize>;
333
334    // ── Metadata ──
335
336    /// Get a session metadata value by key.
337    async fn get_metadata(&self, session_id: &str, key: &str) -> Result<Option<String>>;
338    /// Set a session metadata value.
339    async fn set_metadata(&self, session_id: &str, key: &str, value: &str) -> Result<()>;
340    /// Get the TODO list for a session.
341    async fn get_todo(&self, session_id: &str) -> Result<Option<String>>;
342    /// Set the TODO list for a session.
343    async fn set_todo(&self, session_id: &str, content: &str) -> Result<()>;
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349
350    // ── Role::as_str ──────────────────────────────────────────────────────
351
352    #[test]
353    fn test_role_as_str_all_variants() {
354        assert_eq!(Role::System.as_str(), "system");
355        assert_eq!(Role::User.as_str(), "user");
356        assert_eq!(Role::Assistant.as_str(), "assistant");
357        assert_eq!(Role::Tool.as_str(), "tool");
358    }
359
360    // ── Role FromStr ──────────────────────────────────────────────────────
361
362    #[test]
363    fn test_role_from_str_round_trips() {
364        for (s, expected) in [
365            ("system", Role::System),
366            ("user", Role::User),
367            ("assistant", Role::Assistant),
368            ("tool", Role::Tool),
369        ] {
370            let parsed: Role = s.parse().expect(s);
371            assert_eq!(parsed.as_str(), expected.as_str());
372        }
373    }
374
375    #[test]
376    fn test_role_from_str_unknown_returns_error() {
377        let result: Result<Role, _> = "unknown".parse();
378        assert!(result.is_err());
379        assert!(result.unwrap_err().contains("unknown role"));
380    }
381
382    // ── Role Display ──────────────────────────────────────────────────────
383
384    #[test]
385    fn test_role_display_matches_as_str() {
386        for role in [Role::System, Role::User, Role::Assistant, Role::Tool] {
387            assert_eq!(role.to_string(), role.as_str());
388        }
389    }
390}