1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct DurableTranscriptEntry {
28 pub entry_id: String,
30 pub turn_id: Option<String>,
32 pub timestamp: chrono::DateTime<Utc>,
34 pub role: String,
36 pub content: String,
38 pub reasoning: Option<AssistantReasoning>,
40 pub tool_call_id: Option<String>,
42 pub tool_name: Option<String>,
44 pub tool_result: Option<String>,
46 pub is_error: bool,
48 pub parent_id: Option<String>,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
54#[serde(default)]
55pub struct PersistencePolicy {
56 pub persist_reasoning: bool,
58 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73pub struct SessionCapabilities {
74 pub write_format: String,
76 pub readable_formats: Vec<String>,
78 pub schema_version: u8,
80}
81
82#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct TurnCommit {
85 pub entry_ids: Vec<String>,
87 pub newly_committed: bool,
89}
90
91#[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 #[must_use]
114 pub fn id(&self) -> Uuid {
115 self.session.id
116 }
117
118 #[must_use]
120 pub fn external_id(&self) -> &str {
121 &self.external_id
122 }
123
124 #[must_use]
126 pub fn file_path(&self) -> &std::path::Path {
127 &self.session.file_path
128 }
129
130 pub fn read_messages(&self) -> Result<Vec<Message>, SessionError> {
132 self.session.read_messages()
133 }
134
135 #[must_use]
137 pub fn session(&self) -> &Session {
138 &self.session
139 }
140
141 #[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 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 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 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 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 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 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 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 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 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}