Skip to main content

talos_session/
durable.rs

1//! Host-directory durable session binding and atomic turn persistence.
2
3use std::fs;
4use std::sync::Arc;
5
6use chrono::Utc;
7use rusqlite::{Connection, OptionalExtension, params};
8use serde::{Deserialize, Serialize};
9use talos_core::message::{
10    AssistantReasoning, Message, MessageToolResult, ReasoningBlock, ToolCall,
11};
12use uuid::Uuid;
13
14use crate::diagnostic::is_terminal_diagnostic_content;
15use crate::jsonl::message_parts;
16use crate::turn_outcome::{
17    decode_turn_transcript_outcome, encode_turn_transcript_outcome,
18    is_turn_transcript_outcome_content,
19};
20use crate::{
21    Session, SessionEntry, SessionError, SessionMetadata, TurnTranscriptOutcome,
22    TurnTranscriptOutcomeRecord,
23};
24
25/// One normalized, cursor-addressable durable transcript entry.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct DurableTranscriptEntry {
28    /// Stable entry identity.
29    pub entry_id: String,
30    /// Durable turn identity.
31    pub turn_id: Option<String>,
32    /// Entry timestamp.
33    pub timestamp: chrono::DateTime<Utc>,
34    /// Transcript role.
35    pub role: String,
36    /// Redacted model-visible content.
37    pub content: String,
38    /// Optional displayable reasoning when the policy allowed it.
39    pub reasoning: Option<AssistantReasoning>,
40    /// Tool call ID for a tool call or result.
41    pub tool_call_id: Option<String>,
42    /// Tool name for assistant tool calls.
43    pub tool_name: Option<String>,
44    /// Tool result text when this entry is a result.
45    pub tool_result: Option<String>,
46    /// Whether the tool result represents an error.
47    pub is_error: bool,
48    /// Parent entry relationship.
49    pub parent_id: Option<String>,
50}
51
52/// Controls what a durable embedded transcript is allowed to retain.
53#[derive(Debug, Clone, Serialize, Deserialize)]
54#[serde(default)]
55pub struct PersistencePolicy {
56    /// Whether finalized assistant reasoning is retained. Defaults to `false`.
57    pub persist_reasoning: bool,
58    /// Whether model-visible tool result text is retained after redaction.
59    pub persist_raw_tool_output: bool,
60}
61
62impl Default for PersistencePolicy {
63    fn default() -> Self {
64        Self {
65            persist_reasoning: false,
66            persist_raw_tool_output: true,
67        }
68    }
69}
70
71/// Format capabilities exposed to embedded hosts.
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73pub struct SessionCapabilities {
74    /// Format used for all newly created durable sessions.
75    pub write_format: String,
76    /// Formats this runtime can read.
77    pub readable_formats: Vec<String>,
78    /// Current TLOG schema version.
79    pub schema_version: u8,
80}
81
82/// Result of an idempotent durable turn commit.
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct TurnCommit {
85    /// Stable IDs for model-visible entries committed by the turn.
86    pub entry_ids: Vec<String>,
87    /// Whether this call wrote the turn rather than returning a prior commit.
88    pub newly_committed: bool,
89}
90
91/// A UUID-backed durable session bound to an opaque host external ID.
92#[derive(Debug, Clone)]
93pub struct DurableSession {
94    external_id: String,
95    session: Session,
96    bindings_path: std::path::PathBuf,
97}
98
99impl DurableSession {
100    pub(crate) fn new(
101        external_id: String,
102        session: Session,
103        bindings_path: std::path::PathBuf,
104    ) -> Self {
105        Self {
106            external_id,
107            session,
108            bindings_path,
109        }
110    }
111
112    /// Returns Talos's UUID identity used for the safe TLOG filename.
113    #[must_use]
114    pub fn id(&self) -> Uuid {
115        self.session.id
116    }
117
118    /// Returns the host-provided logical key; it is never used as a filename.
119    #[must_use]
120    pub fn external_id(&self) -> &str {
121        &self.external_id
122    }
123
124    /// Returns the UUID-named TLOG path under the host-selected directory.
125    #[must_use]
126    pub fn file_path(&self) -> &std::path::Path {
127        &self.session.file_path
128    }
129
130    /// Returns model messages suitable for automatic Runtime history recovery.
131    pub fn read_messages(&self) -> Result<Vec<Message>, SessionError> {
132        self.session.read_messages()
133    }
134
135    /// Returns the underlying session for read-only inspection.
136    #[must_use]
137    pub fn session(&self) -> &Session {
138        &self.session
139    }
140
141    /// Returns durable format capabilities without inferring support from extensions.
142    #[must_use]
143    pub fn capabilities(&self) -> SessionCapabilities {
144        SessionCapabilities {
145            write_format: "tlog".into(),
146            readable_formats: vec!["tlog".into(), "jsonl".into()],
147            schema_version: 1,
148        }
149    }
150
151    /// Returns a normalized transcript page after an optional entry-ID cursor.
152    ///
153    /// The cursor entry is excluded. The method exposes no raw TLOG lines,
154    /// provider payloads, HTTP headers, `raw_content` metadata, or hidden
155    /// terminal outcome markers.
156    pub fn transcript(
157        &self,
158        cursor: Option<&str>,
159        limit: usize,
160    ) -> Result<Vec<DurableTranscriptEntry>, SessionError> {
161        let entries = self.session.read_entries()?;
162        let start = cursor
163            .and_then(|cursor| {
164                entries
165                    .iter()
166                    .position(|entry| entry.id == cursor)
167                    .map(|index| index + 1)
168            })
169            .unwrap_or(0);
170        Ok(entries
171            .into_iter()
172            .skip(start)
173            .filter(|entry| {
174                !is_terminal_diagnostic_content(&entry.content)
175                    && !is_turn_transcript_outcome_content(&entry.content)
176            })
177            .take(limit.min(200))
178            .map(transcript_entry)
179            .collect())
180    }
181
182    /// Atomically commits every model-visible message and the hidden Success
183    /// marker of one completed turn.
184    ///
185    /// Repeating a committed `turn_id` returns the original model-visible IDs
186    /// without writing duplicates. The outcome marker is written in the same
187    /// atomic replacement as the messages so startup can distinguish a proven
188    /// Success from partial or ambiguous transcript state.
189    pub fn commit_turn(
190        &self,
191        turn_id: &str,
192        messages: &[Message],
193        policy: &PersistencePolicy,
194    ) -> Result<TurnCommit, SessionError> {
195        self.finalize_turn(turn_id, messages, policy, TurnTranscriptOutcome::Success)
196    }
197
198    /// Atomically finalizes one turn with its admitted display-safe prefix.
199    ///
200    /// The first terminal outcome wins. Retrying the same outcome with the same
201    /// filtered role/content sequence returns the original entry IDs without a
202    /// write. A conflicting outcome or payload is rejected without mutation.
203    pub fn finalize_turn(
204        &self,
205        turn_id: &str,
206        messages: &[Message],
207        policy: &PersistencePolicy,
208        outcome: TurnTranscriptOutcome,
209    ) -> Result<TurnCommit, SessionError> {
210        if turn_id.is_empty() {
211            return Err(SessionError::DurableTurn(
212                "turn_id must not be empty".into(),
213            ));
214        }
215        let _lock = self
216            .session
217            .write_lock
218            .lock()
219            .map_err(|_| SessionError::LockPoisoned)?;
220        let mut existing = self.session.store.read_entries(&self.session.file_path)?;
221        let prior_entries = existing
222            .iter()
223            .filter(|entry| {
224                entry.metadata.turn_id.as_deref() == Some(turn_id)
225                    && !is_turn_transcript_outcome_content(&entry.content)
226            })
227            .collect::<Vec<_>>();
228        let prior_ids = prior_entries
229            .iter()
230            .map(|entry| entry.id.clone())
231            .collect::<Vec<_>>();
232        let marker = existing.iter().find_map(|entry| {
233            decode_turn_transcript_outcome(&entry.content)
234                .filter(|record| record.turn_id == turn_id)
235        });
236
237        let filtered = messages
238            .iter()
239            .map(|message| filtered_message(message, policy))
240            .collect::<Vec<_>>();
241        let requested_parts = filtered.iter().map(message_parts).collect::<Vec<_>>();
242        let prior_parts = prior_entries
243            .iter()
244            .map(|entry| (entry.role.clone(), entry.content.clone()))
245            .collect::<Vec<_>>();
246
247        if let Some(prior) = marker {
248            if prior.outcome == outcome && prior_parts == requested_parts {
249                return Ok(TurnCommit {
250                    entry_ids: prior_ids,
251                    newly_committed: false,
252                });
253            }
254            return Err(SessionError::DurableTurnConflict {
255                turn_id: turn_id.to_string(),
256                existing: format!("{:?}", prior.outcome),
257                requested: format!("{:?}", outcome),
258            });
259        }
260        if !prior_entries.is_empty() {
261            return Err(SessionError::DurableTurnConflict {
262                turn_id: turn_id.to_string(),
263                existing: "ambiguous_unfinalized_entries".into(),
264                requested: format!("{:?}", outcome),
265            });
266        }
267
268        let mut parent_id = existing.last().map(|entry| entry.id.clone());
269        let mut entry_ids = Vec::with_capacity(messages.len());
270        for message in filtered {
271            let (role, content) = message_parts(&message);
272            let id = Uuid::new_v4().to_string();
273            existing.push(SessionEntry {
274                id: id.clone(),
275                parent_id: parent_id.clone(),
276                timestamp: Utc::now(),
277                role,
278                content,
279                metadata: SessionMetadata {
280                    turn_id: Some(turn_id.to_string()),
281                    ..SessionMetadata::default()
282                },
283            });
284            parent_id = Some(id.clone());
285            entry_ids.push(id);
286        }
287
288        let marker = TurnTranscriptOutcomeRecord::new(turn_id, outcome);
289        let marker_content = encode_turn_transcript_outcome(&marker)
290            .map_err(|error| SessionError::InvalidJson(error.to_string()))?;
291        let marker_id = Uuid::new_v4().to_string();
292        existing.push(SessionEntry {
293            id: marker_id,
294            parent_id,
295            timestamp: Utc::now(),
296            role: "system".into(),
297            content: marker_content,
298            metadata: SessionMetadata {
299                turn_id: Some(turn_id.to_string()),
300                ..SessionMetadata::default()
301            },
302        });
303
304        self.session
305            .store
306            .replace_entries_atomically(&self.session.file_path, &existing)?;
307        Ok(TurnCommit {
308            entry_ids,
309            newly_committed: true,
310        })
311    }
312
313    /// Marks an uncommitted turn as aborted. No transcript state is written.
314    pub fn abort_turn(&self, turn_id: &str, _reason: &str) -> Result<(), SessionError> {
315        if turn_id.is_empty() {
316            return Err(SessionError::DurableTurn(
317                "turn_id must not be empty".into(),
318            ));
319        }
320        Ok(())
321    }
322
323    /// Deletes the TLOG and its external-ID binding.
324    pub fn delete(self) -> Result<(), SessionError> {
325        if self.session.file_path.exists() {
326            fs::remove_file(&self.session.file_path)?;
327        }
328        let connection = open_bindings(&self.bindings_path)?;
329        connection
330            .execute(
331                "DELETE FROM durable_bindings WHERE external_id = ?1",
332                params![self.external_id],
333            )
334            .map_err(sql_error)?;
335        Ok(())
336    }
337}
338
339fn transcript_entry(entry: SessionEntry) -> DurableTranscriptEntry {
340    let (is_error, tool_call_id, tool_result) = if entry.role == "system" {
341        let (is_error, id, content) = crate::jsonl::parse_tool_result(&entry.content);
342        (
343            is_error,
344            (id != "unknown").then_some(id),
345            (entry.content.starts_with("__OK__:") || entry.content.starts_with("__ERROR__:"))
346                .then_some(content),
347        )
348    } else {
349        (false, None, None)
350    };
351    let tool_name = if entry.role == "assistant" {
352        talos_core::message::extract_tool_calls_from_text(&entry.content)
353            .first()
354            .map(|call| call.name.clone())
355    } else {
356        None
357    };
358    DurableTranscriptEntry {
359        entry_id: entry.id,
360        turn_id: entry.metadata.turn_id.clone(),
361        timestamp: entry.timestamp,
362        role: entry.role,
363        content: entry.content,
364        reasoning: entry.metadata.reasoning,
365        tool_call_id,
366        tool_name,
367        tool_result,
368        is_error,
369        parent_id: entry.parent_id,
370    }
371}
372
373pub(crate) fn create_or_open(
374    root: &std::path::Path,
375    external_id: &str,
376) -> Result<DurableSession, SessionError> {
377    validate_external_id(external_id)?;
378    fs::create_dir_all(root)?;
379    let bindings_path = root.join("durable-bindings.sqlite");
380    let mut connection = open_bindings(&bindings_path)?;
381    let transaction = connection.transaction().map_err(sql_error)?;
382    transaction
383        .execute(
384            "INSERT OR IGNORE INTO durable_bindings (external_id, session_id) VALUES (?1, ?2)",
385            params![external_id, Uuid::new_v4().to_string()],
386        )
387        .map_err(sql_error)?;
388    let bound_id: String = transaction
389        .query_row(
390            "SELECT session_id FROM durable_bindings WHERE external_id = ?1",
391            params![external_id],
392            |row| row.get(0),
393        )
394        .map_err(sql_error)?;
395    let id = Uuid::parse_str(&bound_id)
396        .map_err(|_| SessionError::DurableTurn("binding contains invalid UUID".into()))?;
397    let session_dir = root.join("durable");
398    fs::create_dir_all(&session_dir)?;
399    let file_path = session_dir.join(format!("{id}.tlog"));
400    let session = Session::with_store(
401        id,
402        "embedded".into(),
403        "embedded".into(),
404        file_path,
405        Arc::new(crate::CompactTextSessionStore),
406    );
407    if !session.file_path.exists() {
408        session
409            .store
410            .replace_entries_atomically(&session.file_path, &[])?;
411    }
412    transaction.commit().map_err(sql_error)?;
413    Ok(DurableSession::new(
414        external_id.to_string(),
415        session,
416        bindings_path,
417    ))
418}
419
420pub(crate) fn get_by_external_id(
421    root: &std::path::Path,
422    external_id: &str,
423) -> Result<Option<DurableSession>, SessionError> {
424    validate_external_id(external_id)?;
425    let bindings_path = root.join("durable-bindings.sqlite");
426    if !bindings_path.exists() {
427        return Ok(None);
428    }
429    let connection = open_bindings(&bindings_path)?;
430    let id: Option<String> = connection
431        .query_row(
432            "SELECT session_id FROM durable_bindings WHERE external_id = ?1",
433            params![external_id],
434            |row| row.get(0),
435        )
436        .optional()
437        .map_err(sql_error)?;
438    let Some(id) = id else {
439        return Ok(None);
440    };
441    let id = Uuid::parse_str(&id)
442        .map_err(|_| SessionError::DurableTurn("binding contains invalid UUID".into()))?;
443    let file_path = root.join("durable").join(format!("{id}.tlog"));
444    if !file_path.exists() {
445        return Err(SessionError::SessionNotFound(id));
446    }
447    let session = Session::with_store(
448        id,
449        "embedded".into(),
450        "embedded".into(),
451        file_path,
452        Arc::new(crate::CompactTextSessionStore),
453    );
454    Ok(Some(DurableSession::new(
455        external_id.to_string(),
456        session,
457        bindings_path,
458    )))
459}
460
461pub(crate) fn remove_binding_for_session(
462    root: &std::path::Path,
463    session_id: &Uuid,
464) -> Result<(), SessionError> {
465    let bindings_path = root.join("durable-bindings.sqlite");
466    if !bindings_path.exists() {
467        return Ok(());
468    }
469    let connection = open_bindings(&bindings_path)?;
470    connection
471        .execute(
472            "DELETE FROM durable_bindings WHERE session_id = ?1",
473            params![session_id.to_string()],
474        )
475        .map_err(sql_error)?;
476    Ok(())
477}
478
479fn open_bindings(path: &std::path::Path) -> Result<Connection, SessionError> {
480    if let Some(parent) = path.parent() {
481        fs::create_dir_all(parent)?;
482    }
483    let connection = Connection::open(path).map_err(sql_error)?;
484    // Initialize WAL mode and schema without busy_timeout so concurrent
485    // first-time opens fail fast instead of blocking for 5s each. The
486    // bounded retry loop (20 × 25 ms ≤ 500 ms total) lets the first writer
487    // complete. Only DatabaseBusy / DatabaseLocked are retried; all other
488    // SQLite errors propagate immediately. After init succeeds, apply the
489    // 5-second busy_timeout for subsequent transaction operations.
490    let init_sql = "PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; \
491         CREATE TABLE IF NOT EXISTS durable_bindings \
492         (external_id TEXT PRIMARY KEY, session_id TEXT NOT NULL UNIQUE);";
493    const MAX_INIT_RETRIES: u32 = 20;
494    const RETRY_INTERVAL: std::time::Duration = std::time::Duration::from_millis(25);
495    let mut retries = 0u32;
496    loop {
497        match connection.execute_batch(init_sql) {
498            Ok(()) => break,
499            Err(rusqlite::Error::SqliteFailure(err, _))
500                if retries < MAX_INIT_RETRIES
501                    && matches!(
502                        err.code,
503                        rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked
504                    ) =>
505            {
506                retries += 1;
507                std::thread::sleep(RETRY_INTERVAL);
508            }
509            // Non-retryable error or retries exhausted: return the
510            // structured SQLite error without panicking.
511            Err(e) => return Err(sql_error(e)),
512        }
513    }
514    connection
515        .busy_timeout(std::time::Duration::from_secs(5))
516        .map_err(sql_error)?;
517    Ok(connection)
518}
519
520fn validate_external_id(external_id: &str) -> Result<(), SessionError> {
521    if external_id.is_empty()
522        || external_id.len() > 1024
523        || external_id.contains('\0')
524        || external_id.contains(['/', '\\'])
525        || external_id.contains("..")
526    {
527        return Err(SessionError::InvalidExternalId(
528            "must be non-empty, at most 1024 bytes, contain no NUL or path separators, and not contain '..'".into(),
529        ));
530    }
531    Ok(())
532}
533
534fn sql_error(error: rusqlite::Error) -> SessionError {
535    SessionError::DurableTurn(format!("binding index error: {error}"))
536}
537
538fn filtered_message(message: &Message, policy: &PersistencePolicy) -> Message {
539    match message {
540        Message::User { content } => Message::User {
541            content: redact(content),
542        },
543        Message::Context { content } => Message::Context {
544            content: redact(content),
545        },
546        Message::System {
547            content,
548            cache_markers,
549        } => Message::System {
550            content: redact(content),
551            cache_markers: cache_markers.clone(),
552        },
553        Message::Assistant {
554            content,
555            tool_calls,
556            reasoning,
557        } => Message::Assistant {
558            content: redact(content),
559            tool_calls: tool_calls
560                .iter()
561                .map(|call| ToolCall {
562                    id: call.id.clone(),
563                    name: call.name.clone(),
564                    input: redact_json(&call.input),
565                })
566                .collect(),
567            reasoning: if policy.persist_reasoning {
568                reasoning.clone().map(redact_reasoning)
569            } else {
570                None
571            },
572        },
573        Message::Tool { result } => Message::Tool {
574            result: MessageToolResult {
575                tool_use_id: result.tool_use_id.clone(),
576                content: if policy.persist_raw_tool_output {
577                    redact(&result.content)
578                } else {
579                    "[tool output omitted by persistence policy]".into()
580                },
581                is_error: result.is_error,
582            },
583        },
584        Message::Multimodal { parts } => Message::Multimodal {
585            parts: parts
586                .iter()
587                .map(|part| match part {
588                    talos_core::message::ContentPart::Text { text } => {
589                        talos_core::message::ContentPart::Text { text: redact(text) }
590                    }
591                    talos_core::message::ContentPart::Image {
592                        path,
593                        mime,
594                        byte_count,
595                        content_digest,
596                    } => talos_core::message::ContentPart::Image {
597                        path: path.clone(),
598                        mime: mime.clone(),
599                        byte_count: *byte_count,
600                        content_digest: content_digest.clone(),
601                    },
602                })
603                .collect(),
604        },
605    }
606}
607
608fn redact_json(value: &serde_json::Value) -> serde_json::Value {
609    match value {
610        serde_json::Value::String(value) => serde_json::Value::String(redact(value)),
611        serde_json::Value::Array(values) => {
612            serde_json::Value::Array(values.iter().map(redact_json).collect())
613        }
614        serde_json::Value::Object(values) => serde_json::Value::Object(
615            values
616                .iter()
617                .map(|(key, value)| {
618                    (
619                        key.clone(),
620                        if is_sensitive_key(key) {
621                            serde_json::Value::String("[REDACTED]".into())
622                        } else {
623                            redact_json(value)
624                        },
625                    )
626                })
627                .collect(),
628        ),
629        _ => value.clone(),
630    }
631}
632
633fn redact_reasoning(reasoning: AssistantReasoning) -> AssistantReasoning {
634    AssistantReasoning {
635        provider: redact(&reasoning.provider),
636        model: redact(&reasoning.model),
637        blocks: reasoning
638            .blocks
639            .into_iter()
640            .map(|block| match block {
641                ReasoningBlock::Thinking { text, signature } => ReasoningBlock::Thinking {
642                    text: redact(&text),
643                    signature: signature.map(|value| redact(&value)),
644                },
645                ReasoningBlock::Redacted { data } => ReasoningBlock::Redacted {
646                    data: redact(&data),
647                },
648                ReasoningBlock::Plain { text } => ReasoningBlock::Plain {
649                    text: redact(&text),
650                },
651            })
652            .collect(),
653    }
654}
655
656fn redact(value: &str) -> String {
657    value
658        .lines()
659        .map(|line| {
660            let lower = line.to_ascii_lowercase();
661            if lower.contains("authorization:")
662                || lower.contains("cookie:")
663                || lower.contains("set-cookie:")
664                || lower.contains("x-api-key:")
665                || lower.contains("api_key")
666                || lower.contains("apikey")
667                || lower.contains("api-key")
668                || lower.contains("token=")
669                || lower.contains("bearer ")
670                || lower.contains("sk-")
671            {
672                "[REDACTED]".into()
673            } else {
674                line.to_string()
675            }
676        })
677        .collect::<Vec<_>>()
678        .join("\n")
679}
680
681fn is_sensitive_key(key: &str) -> bool {
682    let key = key.to_ascii_lowercase();
683    key.contains("token")
684        || key.contains("api_key")
685        || key.contains("apikey")
686        || key.contains("authorization")
687        || key.contains("cookie")
688        || key.contains("password")
689}
690
691#[cfg(test)]
692mod tests {
693    use super::*;
694
695    #[test]
696    fn colon_external_id_maps_to_uuid_tlog_and_is_idempotent() {
697        let directory = tempfile::tempdir().expect("temporary host directory");
698        let first = create_or_open(
699            directory.path(),
700            "assistant:8c8cb03a-9a54-43c2-93a9-42f5d0a3bf56",
701        )
702        .expect("first open");
703        let second = create_or_open(
704            directory.path(),
705            "assistant:8c8cb03a-9a54-43c2-93a9-42f5d0a3bf56",
706        )
707        .expect("second open");
708        assert_eq!(first.id(), second.id());
709        assert_eq!(
710            first
711                .file_path()
712                .extension()
713                .and_then(|value| value.to_str()),
714            Some("tlog")
715        );
716        let filename = first.id().to_string();
717        assert_eq!(
718            first
719                .file_path()
720                .file_stem()
721                .and_then(|value| value.to_str()),
722            Some(filename.as_str())
723        );
724        assert!(!first.file_path().to_string_lossy().contains("assistant:"));
725    }
726
727    #[test]
728    fn atomic_turn_is_idempotent_and_redacts_credentials() {
729        let directory = tempfile::tempdir().expect("temporary host directory");
730        let session = create_or_open(directory.path(), "task:one").expect("session");
731        let messages = vec![
732            Message::User {
733                content: "Authorization: Bearer token-value\napi_key=sk-secret\nx-api-key: key-value\nplain sk-live-secret".into(),
734            },
735            Message::Assistant {
736                content: "Cookie: session=secret".into(),
737                tool_calls: vec![],
738                reasoning: None,
739            },
740        ];
741        let first = session
742            .commit_turn("turn-1", &messages, &PersistencePolicy::default())
743            .expect("commit");
744        let retry = session
745            .commit_turn("turn-1", &messages, &PersistencePolicy::default())
746            .expect("retry");
747        assert!(first.newly_committed);
748        assert!(!retry.newly_committed);
749        assert_eq!(first.entry_ids, retry.entry_ids);
750        let disk = std::fs::read_to_string(session.file_path()).expect("TLOG readable");
751        assert!(!disk.contains("token-value"));
752        assert!(!disk.contains("sk-secret"));
753        assert!(!disk.contains("key-value"));
754        assert!(!disk.contains("sk-live-secret"));
755        assert!(!disk.contains("session=secret"));
756        assert_eq!(session.transcript(None, 20).expect("transcript").len(), 2);
757        assert_eq!(
758            session
759                .session()
760                .read_turn_transcript_outcomes()
761                .expect("outcomes"),
762            vec![TurnTranscriptOutcomeRecord::new(
763                "turn-1",
764                TurnTranscriptOutcome::Success,
765            )]
766        );
767    }
768
769    #[test]
770    fn partial_finalization_is_atomic_idempotent_and_reconstructs_on_reopen() {
771        let directory = tempfile::tempdir().expect("temporary host directory");
772        let session = create_or_open(directory.path(), "task:partial").expect("session");
773        let messages = vec![
774            Message::User {
775                content: "read file".into(),
776            },
777            Message::Assistant {
778                content: "tool result follows".into(),
779                tool_calls: vec![],
780                reasoning: None,
781            },
782        ];
783        let first = session
784            .finalize_turn(
785                "turn-cancelled",
786                &messages,
787                &PersistencePolicy::default(),
788                TurnTranscriptOutcome::Cancelled,
789            )
790            .expect("partial finalization");
791        let retry = session
792            .finalize_turn(
793                "turn-cancelled",
794                &messages,
795                &PersistencePolicy::default(),
796                TurnTranscriptOutcome::Cancelled,
797            )
798            .expect("idempotent retry");
799        assert!(first.newly_committed);
800        assert!(!retry.newly_committed);
801        assert_eq!(first.entry_ids, retry.entry_ids);
802        assert_eq!(session.read_messages().expect("messages"), messages);
803        assert_eq!(
804            session
805                .session()
806                .read_turn_transcript_outcomes()
807                .expect("outcome"),
808            vec![TurnTranscriptOutcomeRecord::new(
809                "turn-cancelled",
810                TurnTranscriptOutcome::Cancelled,
811            )]
812        );
813        let reopened = get_by_external_id(directory.path(), "task:partial")
814            .expect("lookup")
815            .expect("reopen");
816        assert_eq!(
817            reopened.read_messages().expect("replayed messages"),
818            messages
819        );
820    }
821
822    #[test]
823    fn partial_finalization_conflicting_retry_preserves_original_state() {
824        let directory = tempfile::tempdir().expect("temporary host directory");
825        let session = create_or_open(directory.path(), "task:conflict").expect("session");
826        let original = [Message::User {
827            content: "original".into(),
828        }];
829        session
830            .finalize_turn(
831                "turn-conflict",
832                &original,
833                &PersistencePolicy::default(),
834                TurnTranscriptOutcome::Error,
835            )
836            .expect("initial finalization");
837        let before = std::fs::read(session.file_path()).expect("snapshot");
838        let conflict = session.finalize_turn(
839            "turn-conflict",
840            &[Message::User {
841                content: "different".into(),
842            }],
843            &PersistencePolicy::default(),
844            TurnTranscriptOutcome::Cancelled,
845        );
846        assert!(matches!(
847            conflict,
848            Err(SessionError::DurableTurnConflict { .. })
849        ));
850        assert_eq!(std::fs::read(session.file_path()).expect("after"), before);
851    }
852
853    #[test]
854    fn partial_finalization_rejects_ambiguous_legacy_entries() {
855        let directory = tempfile::tempdir().expect("temporary host directory");
856        let session = create_or_open(directory.path(), "task:ambiguous").expect("session");
857        session
858            .session()
859            .append_with_metadata(
860                &Message::User {
861                    content: "legacy partial".into(),
862                },
863                SessionMetadata {
864                    turn_id: Some("turn-ambiguous".into()),
865                    ..SessionMetadata::default()
866                },
867            )
868            .expect("legacy entry");
869        let result = session.finalize_turn(
870            "turn-ambiguous",
871            &[],
872            &PersistencePolicy::default(),
873            TurnTranscriptOutcome::Cancelled,
874        );
875        assert!(matches!(
876            result,
877            Err(SessionError::DurableTurnConflict { .. })
878        ));
879        assert!(
880            session
881                .session()
882                .read_turn_transcript_outcomes()
883                .expect("outcomes")
884                .is_empty()
885        );
886    }
887
888    #[test]
889    fn empty_partial_finalization_writes_only_hidden_cancelled_evidence() {
890        let directory = tempfile::tempdir().expect("temporary host directory");
891        let session = create_or_open(directory.path(), "task:empty-cancel").expect("session");
892
893        let commit = session
894            .finalize_turn(
895                "turn-empty-cancel",
896                &[],
897                &PersistencePolicy::default(),
898                TurnTranscriptOutcome::Cancelled,
899            )
900            .expect("empty cancellation finalization");
901
902        assert!(commit.newly_committed);
903        assert!(commit.entry_ids.is_empty());
904        assert!(session.transcript(None, 10).expect("transcript").is_empty());
905        assert!(session.read_messages().expect("messages").is_empty());
906        assert_eq!(
907            session
908                .session()
909                .read_turn_transcript_outcomes()
910                .expect("outcome"),
911            vec![TurnTranscriptOutcomeRecord::new(
912                "turn-empty-cancel",
913                TurnTranscriptOutcome::Cancelled,
914            )]
915        );
916    }
917
918    #[test]
919    fn partial_finalization_applies_reasoning_secret_and_tool_output_policy() {
920        let directory = tempfile::tempdir().expect("temporary host directory");
921        let session = create_or_open(directory.path(), "task:partial-policy").expect("session");
922        let messages = vec![
923            Message::Assistant {
924                content: "Authorization: Bearer secret".into(),
925                tool_calls: vec![ToolCall {
926                    id: "call-policy".into(),
927                    name: "lookup".into(),
928                    input: serde_json::json!({"api_key": "sk-private", "query": "safe"}),
929                }],
930                reasoning: Some(AssistantReasoning {
931                    provider: "provider".into(),
932                    model: "model".into(),
933                    blocks: vec![ReasoningBlock::Plain {
934                        text: "private reasoning".into(),
935                    }],
936                }),
937            },
938            Message::Tool {
939                result: MessageToolResult {
940                    tool_use_id: "call-policy".into(),
941                    content: "raw private output".into(),
942                    is_error: false,
943                },
944            },
945        ];
946        let policy = PersistencePolicy {
947            persist_reasoning: false,
948            persist_raw_tool_output: false,
949        };
950
951        session
952            .finalize_turn(
953                "turn-policy",
954                &messages,
955                &policy,
956                TurnTranscriptOutcome::Error,
957            )
958            .expect("filtered partial finalization");
959
960        let replay = session.read_messages().expect("replay");
961        let serialized = serde_json::to_string(&replay).expect("serialize replay");
962        assert!(!serialized.contains("private reasoning"));
963        assert!(!serialized.contains("raw private output"));
964        assert!(!serialized.contains("sk-private"));
965        assert!(!serialized.contains("Bearer secret"));
966        assert!(serialized.contains("[REDACTED]"));
967        assert!(serialized.contains("[tool output omitted by persistence policy]"));
968    }
969
970    #[test]
971    fn external_id_rejects_path_traversal_and_concurrent_opens_share_one_uuid() {
972        let directory = tempfile::tempdir().expect("temporary host directory");
973        for invalid in ["../task", "task/one", "task\\one"] {
974            assert!(matches!(
975                create_or_open(directory.path(), invalid),
976                Err(SessionError::InvalidExternalId(_))
977            ));
978        }
979
980        let root = directory.path().to_path_buf();
981        let barrier = std::sync::Arc::new(std::sync::Barrier::new(6));
982        let handles = (0..6)
983            .map(|_| {
984                let root = root.clone();
985                let barrier = std::sync::Arc::clone(&barrier);
986                std::thread::spawn(move || {
987                    barrier.wait();
988                    create_or_open(&root, "task:concurrent")
989                        .expect("concurrent open")
990                        .id()
991                })
992            })
993            .collect::<Vec<_>>();
994        let ids = handles
995            .into_iter()
996            .map(|handle| handle.join().expect("worker did not panic"))
997            .collect::<Vec<_>>();
998        assert!(ids.iter().all(|id| *id == ids[0]));
999    }
1000
1001    #[test]
1002    fn abort_and_delete_leave_no_entries_or_stale_binding() {
1003        let directory = tempfile::tempdir().expect("temporary host directory");
1004        let session = create_or_open(directory.path(), "task:abort").expect("session");
1005        session
1006            .abort_turn("turn-aborted", "cancelled")
1007            .expect("abort");
1008        assert!(session.transcript(None, 10).expect("transcript").is_empty());
1009        let id = session.id();
1010        session.delete().expect("delete");
1011        assert!(
1012            get_by_external_id(directory.path(), "task:abort")
1013                .expect("lookup")
1014                .is_none()
1015        );
1016        assert!(
1017            !directory
1018                .path()
1019                .join("durable")
1020                .join(format!("{id}.tlog"))
1021                .exists()
1022        );
1023    }
1024
1025    #[test]
1026    fn transcript_reconstructs_tool_messages_and_policy_can_omit_output() {
1027        let directory = tempfile::tempdir().expect("temporary host directory");
1028        let session = create_or_open(directory.path(), "task:tools").expect("session");
1029        let messages = vec![
1030            Message::Assistant {
1031                content: String::new(),
1032                tool_calls: vec![ToolCall {
1033                    id: "call-1".into(),
1034                    name: "fixture".into(),
1035                    input: serde_json::json!({"Authorization": "Bearer secret"}),
1036                }],
1037                reasoning: None,
1038            },
1039            Message::Tool {
1040                result: MessageToolResult {
1041                    tool_use_id: "call-1".into(),
1042                    content: "Cookie: secret".into(),
1043                    is_error: false,
1044                },
1045            },
1046        ];
1047        session
1048            .commit_turn(
1049                "turn-tools",
1050                &messages,
1051                &PersistencePolicy {
1052                    persist_raw_tool_output: false,
1053                    ..PersistencePolicy::default()
1054                },
1055            )
1056            .expect("commit");
1057        let entries = session.transcript(None, 10).expect("transcript");
1058        assert_eq!(entries[0].tool_name.as_deref(), Some("fixture"));
1059        assert_eq!(entries[1].tool_call_id.as_deref(), Some("call-1"));
1060        assert_eq!(
1061            entries[1].tool_result.as_deref(),
1062            Some("[tool output omitted by persistence policy]")
1063        );
1064    }
1065
1066    #[test]
1067    fn persistence_failure_is_returned_without_a_successful_commit() {
1068        let directory = tempfile::tempdir().expect("temporary host directory");
1069        let session = create_or_open(directory.path(), "task:write-failure").expect("session");
1070        std::fs::remove_file(session.file_path()).expect("remove initial TLOG");
1071        std::fs::remove_dir(directory.path().join("durable")).expect("remove durable directory");
1072        std::fs::write(directory.path().join("durable"), "not a directory")
1073            .expect("block durable directory");
1074
1075        let result = session.commit_turn(
1076            "turn-failure",
1077            &[Message::User {
1078                content: "must not commit".into(),
1079            }],
1080            &PersistencePolicy::default(),
1081        );
1082        assert!(result.is_err());
1083    }
1084
1085    #[test]
1086    fn open_bindings_non_busy_error_returns_immediately() {
1087        let dir = tempfile::tempdir().expect("tempdir");
1088        let db_path = dir.path().join("durable-bindings.sqlite");
1089        // Write invalid content so Connection::open succeeds but the PRAGMA
1090        // fails with SQLITE_NOTADB — not BUSY/LOCKED. The retry loop must
1091        // NOT engage; the error returns in well under the 500 ms retry floor.
1092        std::fs::write(&db_path, b"not a database").expect("write");
1093        let start = std::time::Instant::now();
1094        let result = open_bindings(&db_path);
1095        let elapsed = start.elapsed();
1096        assert!(result.is_err(), "should fail on corrupt database");
1097        assert!(
1098            matches!(result, Err(SessionError::DurableTurn(_))),
1099            "expected DurableTurn, got {result:?}"
1100        );
1101        assert!(
1102            elapsed < std::time::Duration::from_millis(400),
1103            "non-BUSY error took {elapsed:?}; should return immediately"
1104        );
1105    }
1106
1107    #[test]
1108    fn open_bindings_busy_exhaustion_returns_structured_error() {
1109        let dir = tempfile::tempdir().expect("tempdir");
1110        let db_path = dir.path().join("durable-bindings.sqlite");
1111        // Create the database in default (rollback journal) mode and hold
1112        // a write lock via BEGIN IMMEDIATE so open_bindings cannot change
1113        // to WAL or create the schema table. With busy_timeout unset
1114        // during init, every retry fails immediately; after 20 retries
1115        // the function must return a structured SessionError, not panic.
1116        let holder = Connection::open(&db_path).expect("open holder");
1117        holder
1118            .execute_batch("BEGIN IMMEDIATE; CREATE TABLE t(x);")
1119            .expect("begin + create");
1120        let result = open_bindings(&db_path);
1121        assert!(
1122            result.is_err(),
1123            "should fail after retry exhaustion, got Ok"
1124        );
1125        match result {
1126            Err(SessionError::DurableTurn(msg)) => {
1127                assert!(
1128                    msg.contains("binding index error"),
1129                    "unexpected error message: {msg}"
1130                );
1131            }
1132            other => panic!("expected DurableTurn, got {other:?}"),
1133        }
1134    }
1135}