1use std::fs;
10use std::fs::OpenOptions;
11use std::io::{BufRead, BufReader, Write};
12use std::path::Path as StdPath;
13use std::path::PathBuf;
14use std::sync::Arc;
15use std::sync::Mutex as StdMutex;
16use std::sync::atomic::{AtomicU64, Ordering};
17
18use base64::Engine as _;
19use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
20use handled::SError;
21use serde::{Deserialize, Serialize};
22use time::OffsetDateTime;
23use utf8path::Path;
24
25pub const SESSIONS_DIR: &str = "sessions";
27pub const SID_SESSIONS_ENV: &str = "SID_SESSIONS";
29pub const SID_SESSION_DIR_ENV: &str = "SID_SESSION_DIR";
31pub const SID_SESSION_ID_ENV: &str = "SID_SESSION_ID";
33pub const SID_KEEP_TOOL_SCRATCH_ENV: &str = "SID_KEEP_TOOL_SCRATCH";
35pub const SID_KEEP_FAILED_TOOL_SCRATCH_ENV: &str = "SID_KEEP_FAILED_TOOL_SCRATCH";
37
38const SESSION_METADATA_FILE: &str = "session.json";
39const TRANSCRIPT_FILE: &str = "transcript.json";
40const EVENTS_JOURNAL_FILE: &str = "events.jsonl";
41const API_JOURNAL_FILE: &str = "api.jsonl";
42const TOOL_STREAMS_JOURNAL_FILE: &str = "tool-streams.jsonl";
43const BASH_STATE_FILE: &str = "bash-state.sh";
44
45#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
50pub struct CompactionExpertConfig {
51 #[serde(skip_serializing_if = "Option::is_none")]
53 pub agent_id: Option<String>,
54 pub model: String,
56 #[serde(skip_serializing_if = "Option::is_none")]
58 pub system_prompt: Option<String>,
59 #[serde(skip_serializing_if = "Option::is_none")]
61 pub memory_expert_prompt: Option<String>,
62}
63
64#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
66pub struct CompactionProvenance {
67 pub session_id: String,
69 pub session_dir: String,
71 pub expert: CompactionExpertConfig,
73}
74
75#[derive(Debug)]
80pub struct SidSession {
81 id: String,
82 sessions_root: PathBuf,
83 root: PathBuf,
84 tmp_dir: PathBuf,
85 bash_tmp_dir: PathBuf,
86 events_path: PathBuf,
87 api_path: PathBuf,
88 tool_streams_path: PathBuf,
89 bash_state_path: PathBuf,
90 compaction_provenance: Option<CompactionProvenance>,
91 counters: SessionCounters,
92 journal_lock: StdMutex<()>,
93 stream_lock: Arc<StdMutex<()>>,
94}
95
96#[derive(Debug, Default)]
97struct SessionCounters {
98 event_entry: AtomicU64,
99 api_entry: AtomicU64,
100 api_call: AtomicU64,
101 active_api_call: AtomicU64,
102 tool_invocation: AtomicU64,
103}
104
105#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
106struct SessionCounterState {
107 event_entry: u64,
108 api_entry: u64,
109 api_call: u64,
110 tool_invocation: u64,
111}
112
113impl SessionCounters {
114 fn from_state(state: SessionCounterState) -> Self {
115 Self {
116 event_entry: AtomicU64::new(state.event_entry),
117 api_entry: AtomicU64::new(state.api_entry),
118 api_call: AtomicU64::new(state.api_call),
119 active_api_call: AtomicU64::new(state.api_call),
120 tool_invocation: AtomicU64::new(state.tool_invocation),
121 }
122 }
123
124 fn next_event_entry(&self) -> u64 {
125 self.event_entry.fetch_add(1, Ordering::Relaxed) + 1
126 }
127
128 fn next_api_entry(&self) -> u64 {
129 self.api_entry.fetch_add(1, Ordering::Relaxed) + 1
130 }
131
132 fn start_api_call(&self) -> u64 {
133 let api_seq = self.api_call.fetch_add(1, Ordering::Relaxed) + 1;
134 self.active_api_call.store(api_seq, Ordering::Relaxed);
135 api_seq
136 }
137
138 fn current_api_call(&self) -> u64 {
139 let api_seq = self.active_api_call.load(Ordering::Relaxed);
140 if api_seq == 0 {
141 self.start_api_call()
142 } else {
143 api_seq
144 }
145 }
146
147 fn next_tool_invocation(&self) -> u64 {
148 self.tool_invocation.fetch_add(1, Ordering::Relaxed) + 1
149 }
150}
151
152#[derive(Clone, Debug, Eq, PartialEq)]
153pub(crate) struct ToolInvocationDirs {
154 pub(crate) sequence: u64,
155 pub(crate) root: PathBuf,
156 pub(crate) scratch_dir: PathBuf,
157 pub(crate) temp_dir: PathBuf,
158}
159
160#[derive(Clone, Debug)]
161pub(crate) struct ToolStreamJournal {
162 path: PathBuf,
163 lock: Arc<StdMutex<()>>,
164}
165
166#[derive(Clone, Debug)]
167pub(crate) struct ToolStartEvent<'a> {
168 pub(crate) tool_seq: u64,
169 pub(crate) request_id: &'a str,
170 pub(crate) tool: &'a str,
171 pub(crate) canonical_tool: &'a str,
172 pub(crate) tool_use_id: &'a str,
173 pub(crate) agent: &'a str,
174 pub(crate) scratch_dir: &'a StdPath,
175}
176
177#[derive(Clone, Debug)]
178pub(crate) struct ToolFinishEvent<'a> {
179 pub(crate) tool_seq: u64,
180 pub(crate) request_id: &'a str,
181 pub(crate) status: Option<&'a str>,
182 pub(crate) exit_code: Option<i32>,
183 pub(crate) success: bool,
184 pub(crate) result_ok: Option<bool>,
185 pub(crate) output_len: Option<usize>,
186 pub(crate) error: Option<&'a str>,
187 pub(crate) scratch_preserved: bool,
188 pub(crate) scratch_dir: Option<&'a StdPath>,
189 pub(crate) cleanup_error: Option<&'a str>,
190}
191
192#[derive(Clone, Debug)]
193struct SessionTimestamp {
194 id: String,
195 created_at: String,
196 created_unix_micros: i128,
197}
198
199#[derive(Debug, Deserialize)]
200struct SessionMetadata {
201 id: String,
202 #[serde(default)]
203 compacted_from: Option<CompactionProvenance>,
204}
205
206#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
207struct EventJournalState {
208 max_seq: u64,
209 max_tool_seq: u64,
210}
211
212#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
213struct ApiJournalState {
214 max_seq: u64,
215 max_api_seq: u64,
216}
217
218impl SidSession {
219 pub fn create(config_root: &Path) -> Result<Self, SError> {
226 Self::create_in(resolve_sessions_root(config_root)?)
227 }
228
229 pub fn create_compacted(
236 config_root: &Path,
237 provenance: CompactionProvenance,
238 ) -> Result<Self, SError> {
239 Self::create_compacted_in(resolve_sessions_root(config_root)?, provenance)
240 }
241
242 pub fn resume(config_root: &Path, spec: &str) -> Result<Self, SError> {
253 let default_sessions_root = resolve_sessions_root(config_root)?;
254 let root = resolve_existing_session_root(&default_sessions_root, spec)?;
255 let sessions_root = root
256 .parent()
257 .map(StdPath::to_path_buf)
258 .unwrap_or(default_sessions_root);
259 Self::resume_from_root(sessions_root, root)
260 }
261
262 pub(crate) fn create_in(sessions_root: PathBuf) -> Result<Self, SError> {
263 Self::create_in_with_provenance(sessions_root, None)
264 }
265
266 pub(crate) fn create_compacted_in(
267 sessions_root: PathBuf,
268 provenance: CompactionProvenance,
269 ) -> Result<Self, SError> {
270 Self::create_in_with_provenance(sessions_root, Some(provenance))
271 }
272
273 fn create_in_with_provenance(
274 sessions_root: PathBuf,
275 provenance: Option<CompactionProvenance>,
276 ) -> Result<Self, SError> {
277 fs::create_dir_all(&sessions_root).map_err(|err| {
278 session_error("io_error", "failed to create sessions directory")
279 .with_string_field("path", sessions_root.to_string_lossy().as_ref())
280 .with_string_field("cause", &err.to_string())
281 })?;
282
283 for _ in 0..8 {
284 let timestamp = now_session_timestamp();
285 let root = sessions_root.join(×tamp.id);
286 match fs::create_dir(&root) {
287 Ok(()) => {
288 return Self::from_created_root(
289 timestamp,
290 sessions_root,
291 root,
292 provenance.clone(),
293 );
294 }
295 Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue,
296 Err(err) => {
297 return Err(
298 session_error("io_error", "failed to create session directory")
299 .with_string_field("path", root.to_string_lossy().as_ref())
300 .with_string_field("cause", &err.to_string()),
301 );
302 }
303 }
304 }
305
306 Err(session_error(
307 "session_id_collision",
308 "failed to allocate a unique timestamp session id",
309 ))
310 }
311
312 #[cfg(test)]
313 pub(crate) fn resume_in(sessions_root: PathBuf, spec: &str) -> Result<Self, SError> {
314 let root = resolve_existing_session_root(&sessions_root, spec)?;
315 let sessions_root = root
316 .parent()
317 .map(StdPath::to_path_buf)
318 .unwrap_or(sessions_root);
319 Self::resume_from_root(sessions_root, root)
320 }
321
322 fn from_created_root(
323 timestamp: SessionTimestamp,
324 sessions_root: PathBuf,
325 root: PathBuf,
326 compaction_provenance: Option<CompactionProvenance>,
327 ) -> Result<Self, SError> {
328 let tmp_dir = root.join("tmp");
329 let bash_tmp_dir = tmp_dir.join("bash");
330 fs::create_dir_all(&bash_tmp_dir).map_err(|err| {
331 session_error(
332 "io_error",
333 "failed to create session runtime temporary directory",
334 )
335 .with_string_field("path", bash_tmp_dir.to_string_lossy().as_ref())
336 .with_string_field("cause", &err.to_string())
337 })?;
338
339 let session = Self {
340 id: timestamp.id,
341 sessions_root,
342 root: root.clone(),
343 tmp_dir,
344 bash_tmp_dir,
345 events_path: root.join(EVENTS_JOURNAL_FILE),
346 api_path: root.join(API_JOURNAL_FILE),
347 tool_streams_path: root.join(TOOL_STREAMS_JOURNAL_FILE),
348 bash_state_path: root.join(BASH_STATE_FILE),
349 compaction_provenance,
350 counters: SessionCounters::default(),
351 journal_lock: StdMutex::new(()),
352 stream_lock: Arc::new(StdMutex::new(())),
353 };
354 session.write_metadata(×tamp.created_at, timestamp.created_unix_micros)?;
355 session.log_session_start()?;
356 Ok(session)
357 }
358
359 fn resume_from_root(sessions_root: PathBuf, root: PathBuf) -> Result<Self, SError> {
360 let metadata = read_session_metadata(&root)?;
361 let tmp_dir = root.join("tmp");
362 let bash_tmp_dir = tmp_dir.join("bash");
363 fs::create_dir_all(&bash_tmp_dir).map_err(|err| {
364 session_error(
365 "io_error",
366 "failed to create session runtime temporary directory",
367 )
368 .with_string_field("path", bash_tmp_dir.to_string_lossy().as_ref())
369 .with_string_field("cause", &err.to_string())
370 })?;
371
372 let counters = load_session_counter_state(&root, &tmp_dir)?;
373 let session = Self {
374 id: metadata.id,
375 sessions_root,
376 root: root.clone(),
377 tmp_dir,
378 bash_tmp_dir,
379 events_path: root.join(EVENTS_JOURNAL_FILE),
380 api_path: root.join(API_JOURNAL_FILE),
381 tool_streams_path: root.join(TOOL_STREAMS_JOURNAL_FILE),
382 bash_state_path: root.join(BASH_STATE_FILE),
383 compaction_provenance: metadata.compacted_from,
384 counters: SessionCounters::from_state(counters),
385 journal_lock: StdMutex::new(()),
386 stream_lock: Arc::new(StdMutex::new(())),
387 };
388 session.log_session_resume()?;
389 Ok(session)
390 }
391
392 pub fn id(&self) -> &str {
394 &self.id
395 }
396
397 pub fn sessions_root(&self) -> &PathBuf {
399 &self.sessions_root
400 }
401
402 pub fn root(&self) -> &PathBuf {
404 &self.root
405 }
406
407 pub fn compaction_provenance(&self) -> Option<&CompactionProvenance> {
409 self.compaction_provenance.as_ref()
410 }
411
412 pub fn transcript_path(&self) -> PathBuf {
414 self.root.join(TRANSCRIPT_FILE)
415 }
416
417 pub(crate) fn bash_tmp_dir(&self) -> &PathBuf {
418 &self.bash_tmp_dir
419 }
420
421 pub(crate) fn bash_state_path(&self) -> &PathBuf {
422 &self.bash_state_path
423 }
424
425 pub fn read_bash_state(&self) -> Result<Option<String>, SError> {
433 match fs::read_to_string(&self.bash_state_path) {
434 Ok(state) if state.trim().is_empty() => Ok(None),
435 Ok(state) => Ok(Some(state)),
436 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
437 Err(err) => Err(session_error("io_error", "failed to read bash state")
438 .with_string_field("path", self.bash_state_path.to_string_lossy().as_ref())
439 .with_string_field("cause", &err.to_string())),
440 }
441 }
442
443 pub fn write_bash_state(&self, state: &str) -> Result<(), SError> {
449 fs::write(&self.bash_state_path, state).map_err(|err| {
450 session_error("io_error", "failed to write bash state")
451 .with_string_field("path", self.bash_state_path.to_string_lossy().as_ref())
452 .with_string_field("cause", &err.to_string())
453 })
454 }
455
456 pub(crate) fn clear_bash_state(&self) -> Result<(), SError> {
457 match fs::remove_file(&self.bash_state_path) {
458 Ok(()) => Ok(()),
459 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
460 Err(err) => Err(session_error("io_error", "failed to clear bash state")
461 .with_string_field("path", self.bash_state_path.to_string_lossy().as_ref())
462 .with_string_field("cause", &err.to_string())),
463 }
464 }
465
466 pub(crate) fn tool_stream_journal(&self) -> ToolStreamJournal {
467 ToolStreamJournal {
468 path: self.tool_streams_path.clone(),
469 lock: self.stream_lock.clone(),
470 }
471 }
472
473 pub(crate) fn create_tool_invocation_dirs(
474 &self,
475 request_id: &str,
476 ) -> Result<ToolInvocationDirs, SError> {
477 let sequence = self.counters.next_tool_invocation();
478 let root = self.tmp_dir.join(format!("tool-{sequence:06}"));
479 let scratch_dir = root.clone();
480 let temp_dir = root.join("tmp");
481 fs::create_dir(&root).map_err(|err| {
482 session_error("io_error", "failed to create tool scratch directory")
483 .with_string_field("path", root.to_string_lossy().as_ref())
484 .with_string_field("request_id", request_id)
485 .with_string_field("cause", &err.to_string())
486 })?;
487 fs::create_dir_all(&temp_dir).map_err(|err| {
488 session_error("io_error", "failed to create tool temporary directory")
489 .with_string_field("path", temp_dir.to_string_lossy().as_ref())
490 .with_string_field("request_id", request_id)
491 .with_string_field("cause", &err.to_string())
492 })?;
493 Ok(ToolInvocationDirs {
494 sequence,
495 root,
496 scratch_dir,
497 temp_dir,
498 })
499 }
500
501 pub(crate) fn log_api_request(&self, value: &impl Serialize) -> Result<(), SError> {
502 let api_seq = self.counters.start_api_call();
503 self.log_api_payload("request", api_seq, value)
504 }
505
506 pub(crate) fn log_api_response(&self, value: &impl Serialize) -> Result<(), SError> {
507 let api_seq = self.counters.current_api_call();
508 self.log_api_payload("response", api_seq, value)
509 }
510
511 pub(crate) fn log_tool_start(&self, event: ToolStartEvent<'_>) -> Result<(), SError> {
512 #[derive(Serialize)]
513 struct ToolStartRecord {
514 seq: u64,
515 ts: String,
516 kind: &'static str,
517 tool_seq: u64,
518 request_id: String,
519 tool: String,
520 canonical_tool: String,
521 tool_use_id: String,
522 agent: String,
523 scratch_dir: String,
524 }
525
526 let record = ToolStartRecord {
527 seq: self.next_event_seq(),
528 ts: now_created_at(),
529 kind: "tool_start",
530 tool_seq: event.tool_seq,
531 request_id: event.request_id.to_string(),
532 tool: event.tool.to_string(),
533 canonical_tool: event.canonical_tool.to_string(),
534 tool_use_id: event.tool_use_id.to_string(),
535 agent: event.agent.to_string(),
536 scratch_dir: event.scratch_dir.to_string_lossy().into_owned(),
537 };
538 self.append_journal(&self.events_path, &record)
539 }
540
541 pub(crate) fn log_tool_finish(&self, event: ToolFinishEvent<'_>) -> Result<(), SError> {
542 #[derive(Serialize)]
543 struct ToolFinishRecord {
544 seq: u64,
545 ts: String,
546 kind: &'static str,
547 tool_seq: u64,
548 request_id: String,
549 #[serde(skip_serializing_if = "Option::is_none")]
550 status: Option<String>,
551 #[serde(skip_serializing_if = "Option::is_none")]
552 exit_code: Option<i32>,
553 success: bool,
554 #[serde(skip_serializing_if = "Option::is_none")]
555 result_ok: Option<bool>,
556 #[serde(skip_serializing_if = "Option::is_none")]
557 output_len: Option<usize>,
558 #[serde(skip_serializing_if = "Option::is_none")]
559 error: Option<String>,
560 scratch_preserved: bool,
561 #[serde(skip_serializing_if = "Option::is_none")]
562 scratch_dir: Option<String>,
563 #[serde(skip_serializing_if = "Option::is_none")]
564 cleanup_error: Option<String>,
565 }
566
567 let record = ToolFinishRecord {
568 seq: self.next_event_seq(),
569 ts: now_created_at(),
570 kind: "tool_finish",
571 tool_seq: event.tool_seq,
572 request_id: event.request_id.to_string(),
573 status: event.status.map(str::to_string),
574 exit_code: event.exit_code,
575 success: event.success,
576 result_ok: event.result_ok,
577 output_len: event.output_len,
578 error: event.error.map(str::to_string),
579 scratch_preserved: event.scratch_preserved,
580 scratch_dir: event
581 .scratch_dir
582 .map(|path| path.to_string_lossy().into_owned()),
583 cleanup_error: event.cleanup_error.map(str::to_string),
584 };
585 self.append_journal(&self.events_path, &record)
586 }
587
588 fn log_session_start(&self) -> Result<(), SError> {
589 #[derive(Serialize)]
590 struct SessionStartRecord<'a> {
591 seq: u64,
592 ts: String,
593 kind: &'static str,
594 session_id: &'a str,
595 }
596
597 let record = SessionStartRecord {
598 seq: self.next_event_seq(),
599 ts: now_created_at(),
600 kind: "session_start",
601 session_id: &self.id,
602 };
603 self.append_journal(&self.events_path, &record)
604 }
605
606 fn log_session_resume(&self) -> Result<(), SError> {
607 #[derive(Serialize)]
608 struct SessionResumeRecord<'a> {
609 seq: u64,
610 ts: String,
611 kind: &'static str,
612 session_id: &'a str,
613 }
614
615 let record = SessionResumeRecord {
616 seq: self.next_event_seq(),
617 ts: now_created_at(),
618 kind: "session_resume",
619 session_id: &self.id,
620 };
621 self.append_journal(&self.events_path, &record)
622 }
623
624 fn log_api_payload(
625 &self,
626 kind: &'static str,
627 api_seq: u64,
628 value: &impl Serialize,
629 ) -> Result<(), SError> {
630 #[derive(Serialize)]
631 struct ApiRecord<'a, T: Serialize + ?Sized> {
632 seq: u64,
633 ts: String,
634 kind: &'static str,
635 api_seq: u64,
636 payload: &'a T,
637 }
638
639 let record = ApiRecord {
640 seq: self.counters.next_api_entry(),
641 ts: now_created_at(),
642 kind,
643 api_seq,
644 payload: value,
645 };
646 self.append_journal(&self.api_path, &record)
647 }
648
649 fn write_metadata(&self, created_at: &str, created_unix_micros: i128) -> Result<(), SError> {
650 #[derive(Serialize)]
651 struct Metadata<'a> {
652 id: &'a str,
653 created_at: &'a str,
654 created_unix_micros: i128,
655 uuid: Option<&'a str>,
656 pid: u32,
657 sessions_root: String,
658 session_dir: String,
659 #[serde(skip_serializing_if = "Option::is_none")]
660 compacted_from: Option<&'a CompactionProvenance>,
661 }
662
663 let metadata = Metadata {
664 id: &self.id,
665 created_at,
666 created_unix_micros,
667 uuid: None,
668 pid: std::process::id(),
669 sessions_root: self.sessions_root.to_string_lossy().into_owned(),
670 session_dir: self.root.to_string_lossy().into_owned(),
671 compacted_from: self.compaction_provenance.as_ref(),
672 };
673 self.write_json(self.root.join(SESSION_METADATA_FILE), &metadata)
674 }
675
676 fn write_json(&self, path: PathBuf, value: &impl Serialize) -> Result<(), SError> {
677 let payload = serde_json::to_vec_pretty(value).map_err(|err| {
678 session_error("json_serialize_error", "failed to serialize session log")
679 .with_string_field("path", path.to_string_lossy().as_ref())
680 .with_string_field("cause", &err.to_string())
681 })?;
682 fs::write(&path, payload).map_err(|err| {
683 session_error("io_error", "failed to write session log")
684 .with_string_field("path", path.to_string_lossy().as_ref())
685 .with_string_field("cause", &err.to_string())
686 })
687 }
688
689 fn append_journal(&self, path: &StdPath, value: &impl Serialize) -> Result<(), SError> {
690 let _guard = self.journal_lock.lock().map_err(|_| {
691 session_error("lock_poisoned", "session journal lock was poisoned")
692 .with_string_field("path", path.to_string_lossy().as_ref())
693 })?;
694 append_jsonl(path, value)
695 }
696
697 fn next_event_seq(&self) -> u64 {
698 self.counters.next_event_entry()
699 }
700}
701
702impl Drop for SidSession {
703 fn drop(&mut self) {
704 if keep_any_tool_scratch() {
705 return;
706 }
707 let _ = fs::remove_dir_all(&self.tmp_dir);
708 }
709}
710
711impl ToolStreamJournal {
712 pub(crate) fn append(&self, tool_seq: u64, stream: &str, bytes: &[u8]) -> Result<(), SError> {
713 if bytes.is_empty() {
714 return Ok(());
715 }
716
717 #[derive(Serialize)]
718 struct ToolStreamRecord<'a> {
719 tool_seq: u64,
720 ts: String,
721 stream: &'a str,
722 #[serde(skip_serializing_if = "Option::is_none")]
723 text: Option<&'a str>,
724 #[serde(skip_serializing_if = "Option::is_none")]
725 data_b64: Option<String>,
726 }
727
728 let (text, data_b64) = match std::str::from_utf8(bytes) {
729 Ok(text) => (Some(text), None),
730 Err(_) => (None, Some(BASE64_STANDARD.encode(bytes))),
731 };
732 let record = ToolStreamRecord {
733 tool_seq,
734 ts: now_created_at(),
735 stream,
736 text,
737 data_b64,
738 };
739
740 let _guard = self.lock.lock().map_err(|_| {
741 session_error("lock_poisoned", "session tool stream lock was poisoned")
742 .with_string_field("path", self.path.to_string_lossy().as_ref())
743 })?;
744 append_jsonl(&self.path, &record)
745 }
746}
747
748pub(crate) fn should_keep_tool_scratch(failed: bool) -> bool {
749 env_truthy(SID_KEEP_TOOL_SCRATCH_ENV)
750 || (failed && env_truthy(SID_KEEP_FAILED_TOOL_SCRATCH_ENV))
751}
752
753fn keep_any_tool_scratch() -> bool {
754 env_truthy(SID_KEEP_TOOL_SCRATCH_ENV) || env_truthy(SID_KEEP_FAILED_TOOL_SCRATCH_ENV)
755}
756
757fn env_truthy(name: &str) -> bool {
758 match std::env::var(name) {
759 Ok(value) => matches!(
760 value.trim().to_ascii_lowercase().as_str(),
761 "1" | "true" | "yes" | "on"
762 ),
763 Err(_) => false,
764 }
765}
766
767pub fn transcript_path_for_session_dir(root: &StdPath) -> PathBuf {
769 root.join(TRANSCRIPT_FILE)
770}
771
772pub fn read_compaction_provenance_from_dir(
780 root: &StdPath,
781) -> Result<Option<CompactionProvenance>, SError> {
782 Ok(read_session_metadata(root)?.compacted_from)
783}
784
785fn resolve_sessions_root(config_root: &Path) -> Result<PathBuf, SError> {
786 match std::env::var(SID_SESSIONS_ENV) {
787 Ok(path) if !path.is_empty() => Ok(PathBuf::from(path)),
788 Ok(_) | Err(std::env::VarError::NotPresent) => {
789 Ok(PathBuf::from(config_root.as_str()).join(SESSIONS_DIR))
790 }
791 Err(std::env::VarError::NotUnicode(_)) => Err(session_error(
792 "invalid_sid_sessions",
793 "SID_SESSIONS is not valid UTF-8",
794 )),
795 }
796}
797
798fn resolve_existing_session_root(sessions_root: &StdPath, spec: &str) -> Result<PathBuf, SError> {
799 let direct = PathBuf::from(spec);
800 if direct.exists() {
801 return normalize_existing_session_root(direct, spec);
802 }
803
804 normalize_existing_session_root(sessions_root.join(spec), spec)
805}
806
807fn normalize_existing_session_root(candidate: PathBuf, spec: &str) -> Result<PathBuf, SError> {
808 let root = if candidate.is_file() {
809 if candidate.file_name().and_then(|name| name.to_str()) == Some(SESSION_METADATA_FILE) {
810 candidate
811 .parent()
812 .map(StdPath::to_path_buf)
813 .ok_or_else(|| {
814 session_error(
815 "invalid_session_path",
816 "session metadata has no parent directory",
817 )
818 .with_string_field("path", candidate.to_string_lossy().as_ref())
819 })?
820 } else {
821 return Err(session_error(
822 "invalid_session_path",
823 "resume target must be a session directory",
824 )
825 .with_string_field("path", candidate.to_string_lossy().as_ref()));
826 }
827 } else {
828 candidate
829 };
830
831 if root.is_dir() {
832 Ok(root)
833 } else {
834 Err(
835 session_error("session_not_found", "failed to locate session to resume")
836 .with_string_field("session", spec),
837 )
838 }
839}
840
841fn read_session_metadata(root: &StdPath) -> Result<SessionMetadata, SError> {
842 let path = root.join(SESSION_METADATA_FILE);
843 let payload = fs::read_to_string(&path).map_err(|err| {
844 session_error("io_error", "failed to read session metadata")
845 .with_string_field("path", path.to_string_lossy().as_ref())
846 .with_string_field("cause", &err.to_string())
847 })?;
848 serde_json::from_str(&payload).map_err(|err| {
849 session_error("json_parse_error", "failed to parse session metadata")
850 .with_string_field("path", path.to_string_lossy().as_ref())
851 .with_string_field("cause", &err.to_string())
852 })
853}
854
855fn load_session_counter_state(
856 root: &StdPath,
857 tmp_dir: &StdPath,
858) -> Result<SessionCounterState, SError> {
859 let events = inspect_event_journal(&root.join(EVENTS_JOURNAL_FILE))?;
860 let api = inspect_api_journal(&root.join(API_JOURNAL_FILE))?;
861 let tmp_tool_seq = max_tool_sequence_in_tmp(tmp_dir)?;
862 Ok(SessionCounterState {
863 event_entry: events.max_seq,
864 api_entry: api.max_seq,
865 api_call: api.max_api_seq,
866 tool_invocation: events.max_tool_seq.max(tmp_tool_seq),
867 })
868}
869
870fn inspect_event_journal(path: &StdPath) -> Result<EventJournalState, SError> {
871 let mut state = EventJournalState::default();
872 scan_jsonl(path, |entry| {
873 state.max_seq = state.max_seq.max(u64_field(entry, "seq")?);
874 if let Some(tool_seq) = optional_u64_field(entry, "tool_seq")? {
875 state.max_tool_seq = state.max_tool_seq.max(tool_seq);
876 }
877 Ok(())
878 })?;
879 Ok(state)
880}
881
882fn inspect_api_journal(path: &StdPath) -> Result<ApiJournalState, SError> {
883 let mut state = ApiJournalState::default();
884 scan_jsonl(path, |entry| {
885 state.max_seq = state.max_seq.max(u64_field(entry, "seq")?);
886 if let Some(api_seq) = optional_u64_field(entry, "api_seq")? {
887 state.max_api_seq = state.max_api_seq.max(api_seq);
888 }
889 Ok(())
890 })?;
891 Ok(state)
892}
893
894fn scan_jsonl(
895 path: &StdPath,
896 mut visit: impl FnMut(&serde_json::Value) -> Result<(), SError>,
897) -> Result<(), SError> {
898 let file = match fs::File::open(path) {
899 Ok(file) => file,
900 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
901 Err(err) => {
902 return Err(session_error("io_error", "failed to open session journal")
903 .with_string_field("path", path.to_string_lossy().as_ref())
904 .with_string_field("cause", &err.to_string()));
905 }
906 };
907 let reader = BufReader::new(file);
908 for (line_no, line) in reader.lines().enumerate() {
909 let line = line.map_err(|err| {
910 session_error("io_error", "failed to read session journal")
911 .with_string_field("path", path.to_string_lossy().as_ref())
912 .with_string_field("line", &(line_no + 1).to_string())
913 .with_string_field("cause", &err.to_string())
914 })?;
915 if line.trim().is_empty() {
916 continue;
917 }
918 let entry = serde_json::from_str::<serde_json::Value>(&line).map_err(|err| {
919 session_error("json_parse_error", "failed to parse session journal entry")
920 .with_string_field("path", path.to_string_lossy().as_ref())
921 .with_string_field("line", &(line_no + 1).to_string())
922 .with_string_field("cause", &err.to_string())
923 })?;
924 visit(&entry)?;
925 }
926 Ok(())
927}
928
929fn u64_field(entry: &serde_json::Value, field: &str) -> Result<u64, SError> {
930 entry
931 .get(field)
932 .and_then(serde_json::Value::as_u64)
933 .ok_or_else(|| {
934 session_error("invalid_journal_entry", "missing numeric journal field")
935 .with_string_field("field", field)
936 })
937}
938
939fn optional_u64_field(entry: &serde_json::Value, field: &str) -> Result<Option<u64>, SError> {
940 match entry.get(field) {
941 Some(value) => value.as_u64().map(Some).ok_or_else(|| {
942 session_error("invalid_journal_entry", "journal field must be numeric")
943 .with_string_field("field", field)
944 }),
945 None => Ok(None),
946 }
947}
948
949fn max_tool_sequence_in_tmp(tmp_dir: &StdPath) -> Result<u64, SError> {
950 let mut max_seq = 0;
951 let entries = match fs::read_dir(tmp_dir) {
952 Ok(entries) => entries,
953 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(0),
954 Err(err) => {
955 return Err(
956 session_error("io_error", "failed to read session tmp directory")
957 .with_string_field("path", tmp_dir.to_string_lossy().as_ref())
958 .with_string_field("cause", &err.to_string()),
959 );
960 }
961 };
962 for entry in entries {
963 let entry = entry.map_err(|err| {
964 session_error("io_error", "failed to read session tmp directory entry")
965 .with_string_field("path", tmp_dir.to_string_lossy().as_ref())
966 .with_string_field("cause", &err.to_string())
967 })?;
968 let Some(name) = entry.file_name().to_str().map(str::to_string) else {
969 continue;
970 };
971 let Some(suffix) = name.strip_prefix("tool-") else {
972 continue;
973 };
974 let Ok(sequence) = suffix.parse::<u64>() else {
975 continue;
976 };
977 max_seq = max_seq.max(sequence);
978 }
979 Ok(max_seq)
980}
981
982fn append_jsonl(path: &StdPath, value: &impl Serialize) -> Result<(), SError> {
983 let mut payload = serde_json::to_vec(value).map_err(|err| {
984 session_error(
985 "json_serialize_error",
986 "failed to serialize session journal entry",
987 )
988 .with_string_field("path", path.to_string_lossy().as_ref())
989 .with_string_field("cause", &err.to_string())
990 })?;
991 payload.push(b'\n');
992
993 let mut file = OpenOptions::new()
994 .create(true)
995 .append(true)
996 .open(path)
997 .map_err(|err| {
998 session_error("io_error", "failed to open session journal")
999 .with_string_field("path", path.to_string_lossy().as_ref())
1000 .with_string_field("cause", &err.to_string())
1001 })?;
1002 file.write_all(&payload).map_err(|err| {
1003 session_error("io_error", "failed to append session journal")
1004 .with_string_field("path", path.to_string_lossy().as_ref())
1005 .with_string_field("cause", &err.to_string())
1006 })
1007}
1008
1009fn now_session_timestamp() -> SessionTimestamp {
1010 let now = OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc());
1011 format_session_timestamp(now)
1012}
1013
1014fn now_created_at() -> String {
1015 let now = OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc());
1016 format_session_timestamp(now).created_at
1017}
1018
1019fn format_session_timestamp(now: OffsetDateTime) -> SessionTimestamp {
1020 let offset_seconds = now.offset().whole_seconds();
1021 let offset_sign = if offset_seconds < 0 { '-' } else { '+' };
1022 let offset_abs = offset_seconds.abs();
1023 let offset_hours = offset_abs / 3600;
1024 let offset_minutes = (offset_abs % 3600) / 60;
1025 let micros = now.microsecond();
1026 let month = u8::from(now.month());
1027
1028 let id = format!(
1029 "{:04}-{:02}-{:02}T{:02}-{:02}-{:02}.{:06}{}{:02}{:02}",
1030 now.year(),
1031 month,
1032 now.day(),
1033 now.hour(),
1034 now.minute(),
1035 now.second(),
1036 micros,
1037 offset_sign,
1038 offset_hours,
1039 offset_minutes,
1040 );
1041 let created_at = format!(
1042 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:06}{}{:02}:{:02}",
1043 now.year(),
1044 month,
1045 now.day(),
1046 now.hour(),
1047 now.minute(),
1048 now.second(),
1049 micros,
1050 offset_sign,
1051 offset_hours,
1052 offset_minutes,
1053 );
1054 let created_unix_micros = now.unix_timestamp_nanos() / 1_000;
1055 SessionTimestamp {
1056 id,
1057 created_at,
1058 created_unix_micros,
1059 }
1060}
1061
1062fn session_error(code: &str, message: &str) -> SError {
1063 SError::new("sid-session")
1064 .with_code(code)
1065 .with_message(message)
1066}
1067
1068#[cfg(test)]
1069mod tests {
1070 use super::*;
1071 use crate::test_support::unique_temp_dir;
1072 use serde_json::json;
1073
1074 #[test]
1075 fn session_create_allocates_timestamp_directory() {
1076 let sessions_root = PathBuf::from(unique_temp_dir("sessions").as_str());
1077 let session = SidSession::create_in(sessions_root.clone()).unwrap();
1078
1079 assert_timestamp_session_id(session.id());
1080 assert_eq!(session.root(), &sessions_root.join(session.id()));
1081 assert!(session.root().join(SESSION_METADATA_FILE).is_file());
1082 assert!(session.root().join(EVENTS_JOURNAL_FILE).is_file());
1083 assert!(!session.root().join("api").exists());
1084 assert!(!session.root().join("tools").exists());
1085 assert!(session.root().join("tmp/bash").is_dir());
1086
1087 let metadata: serde_json::Value = serde_json::from_str(
1088 &fs::read_to_string(session.root().join(SESSION_METADATA_FILE)).unwrap(),
1089 )
1090 .unwrap();
1091 assert_eq!(metadata["id"], json!(session.id()));
1092 assert_timestamp_created_at(metadata["created_at"].as_str().unwrap());
1093 assert!(metadata["created_unix_micros"].as_i64().unwrap() > 0);
1094 assert_eq!(metadata["uuid"], serde_json::Value::Null);
1095 assert_eq!(metadata["pid"], json!(std::process::id()));
1096 assert!(metadata.get("compacted_from").is_none());
1097
1098 fs::remove_dir_all(sessions_root).unwrap();
1099 }
1100
1101 #[test]
1102 fn compacted_session_persists_provenance_and_reload() {
1103 let sessions_root = PathBuf::from(unique_temp_dir("sessions").as_str());
1104 let parent = SidSession::create_in(sessions_root.clone()).unwrap();
1105 let provenance = CompactionProvenance {
1106 session_id: parent.id().to_string(),
1107 session_dir: parent.root().to_string_lossy().into_owned(),
1108 expert: CompactionExpertConfig {
1109 agent_id: Some("compact".to_string()),
1110 model: "claude-sonnet-4-5".to_string(),
1111 system_prompt: Some("Summarize carefully.".to_string()),
1112 memory_expert_prompt: Some("Answer from memory only.".to_string()),
1113 },
1114 };
1115
1116 let child =
1117 SidSession::create_compacted_in(sessions_root.clone(), provenance.clone()).unwrap();
1118 assert_eq!(child.compaction_provenance(), Some(&provenance));
1119
1120 let metadata: serde_json::Value = serde_json::from_str(
1121 &fs::read_to_string(child.root().join(SESSION_METADATA_FILE)).unwrap(),
1122 )
1123 .unwrap();
1124 assert_eq!(metadata["compacted_from"]["session_id"], json!(parent.id()));
1125 assert_eq!(
1126 metadata["compacted_from"]["session_dir"],
1127 json!(parent.root().to_string_lossy().as_ref())
1128 );
1129 assert_eq!(
1130 metadata["compacted_from"]["expert"]["agent_id"],
1131 json!("compact")
1132 );
1133
1134 let resumed = SidSession::resume_in(sessions_root.clone(), child.id()).unwrap();
1135 assert_eq!(resumed.compaction_provenance(), Some(&provenance));
1136 assert_eq!(
1137 read_compaction_provenance_from_dir(child.root()).unwrap(),
1138 Some(provenance.clone())
1139 );
1140 assert_eq!(
1141 transcript_path_for_session_dir(child.root()),
1142 child.root().join(TRANSCRIPT_FILE)
1143 );
1144
1145 fs::remove_dir_all(sessions_root).unwrap();
1146 }
1147
1148 #[test]
1149 fn api_logging_uses_single_jsonl_journal() {
1150 let sessions_root = PathBuf::from(unique_temp_dir("sessions").as_str());
1151 let session = SidSession::create_in(sessions_root.clone()).unwrap();
1152
1153 session
1154 .log_api_request(&json!({ "messages": ["hello"] }))
1155 .unwrap();
1156 session
1157 .log_api_response(&json!({ "id": "msg_123" }))
1158 .unwrap();
1159 session
1160 .log_api_request(&json!({ "messages": ["retry"] }))
1161 .unwrap();
1162 session
1163 .log_api_response(&json!({ "id": "msg_456" }))
1164 .unwrap();
1165
1166 let lines = fs::read_to_string(session.root().join(API_JOURNAL_FILE)).unwrap();
1167 let entries = lines
1168 .lines()
1169 .map(|line| serde_json::from_str::<serde_json::Value>(line).unwrap())
1170 .collect::<Vec<_>>();
1171 assert_eq!(entries.len(), 4);
1172 assert_eq!(entries[0]["seq"], json!(1));
1173 assert_eq!(entries[0]["kind"], json!("request"));
1174 assert_eq!(entries[0]["api_seq"], json!(1));
1175 assert_eq!(entries[0]["payload"], json!({ "messages": ["hello"] }));
1176 assert_eq!(entries[1]["seq"], json!(2));
1177 assert_eq!(entries[1]["kind"], json!("response"));
1178 assert_eq!(entries[1]["api_seq"], json!(1));
1179 assert_eq!(entries[1]["payload"], json!({ "id": "msg_123" }));
1180 assert_eq!(entries[2]["seq"], json!(3));
1181 assert_eq!(entries[2]["kind"], json!("request"));
1182 assert_eq!(entries[2]["api_seq"], json!(2));
1183 assert_eq!(entries[2]["payload"], json!({ "messages": ["retry"] }));
1184 assert_eq!(entries[3]["seq"], json!(4));
1185 assert_eq!(entries[3]["kind"], json!("response"));
1186 assert_eq!(entries[3]["api_seq"], json!(2));
1187 assert_eq!(entries[3]["payload"], json!({ "id": "msg_456" }));
1188
1189 fs::remove_dir_all(sessions_root).unwrap();
1190 }
1191
1192 #[test]
1193 fn tool_invocation_dirs_are_ordered_under_session_tmp() {
1194 let sessions_root = PathBuf::from(unique_temp_dir("sessions").as_str());
1195 let session = SidSession::create_in(sessions_root.clone()).unwrap();
1196
1197 let first = session.create_tool_invocation_dirs("sidreq_first").unwrap();
1198 let second = session
1199 .create_tool_invocation_dirs("sidreq_second")
1200 .unwrap();
1201
1202 assert_eq!(first.sequence, 1);
1203 assert_eq!(second.sequence, 2);
1204 assert!(first.root.ends_with("tmp/tool-000001"));
1205 assert!(second.root.ends_with("tmp/tool-000002"));
1206 assert_eq!(first.scratch_dir, first.root);
1207 assert_eq!(first.temp_dir, first.root.join("tmp"));
1208
1209 fs::remove_dir_all(sessions_root).unwrap();
1210 }
1211
1212 #[test]
1213 fn tool_stream_journal_preserves_utf8_and_binary_chunks() {
1214 let sessions_root = PathBuf::from(unique_temp_dir("sessions").as_str());
1215 let session = SidSession::create_in(sessions_root.clone()).unwrap();
1216 let journal = session.tool_stream_journal();
1217
1218 journal.append(1, "stdout", b"hello\n").unwrap();
1219 journal.append(1, "stderr", &[0, 159, 146, 150]).unwrap();
1220
1221 let lines = fs::read_to_string(session.root().join(TOOL_STREAMS_JOURNAL_FILE)).unwrap();
1222 let entries = lines
1223 .lines()
1224 .map(|line| serde_json::from_str::<serde_json::Value>(line).unwrap())
1225 .collect::<Vec<_>>();
1226 assert_eq!(entries[0]["tool_seq"], json!(1));
1227 assert_eq!(entries[0]["stream"], json!("stdout"));
1228 assert_eq!(entries[0]["text"], json!("hello\n"));
1229 assert_eq!(entries[1]["stream"], json!("stderr"));
1230 assert_eq!(entries[1]["data_b64"], json!("AJ+Slg=="));
1231
1232 fs::remove_dir_all(sessions_root).unwrap();
1233 }
1234
1235 #[test]
1236 fn session_resume_reuses_session_directory_and_continues_counters() {
1237 let sessions_root = PathBuf::from(unique_temp_dir("sessions").as_str());
1238 let session = SidSession::create_in(sessions_root.clone()).unwrap();
1239
1240 session
1241 .log_api_request(&json!({ "messages": ["hello"] }))
1242 .unwrap();
1243 session.log_api_response(&json!({ "id": "msg_1" })).unwrap();
1244 session.create_tool_invocation_dirs("sidreq_first").unwrap();
1245 session.write_bash_state("export FOO=bar\n").unwrap();
1246
1247 let resumed = SidSession::resume_in(sessions_root.clone(), session.id()).unwrap();
1248 assert_eq!(resumed.root(), session.root());
1249 assert_eq!(
1250 resumed.read_bash_state().unwrap(),
1251 Some("export FOO=bar\n".to_string())
1252 );
1253
1254 resumed
1255 .log_api_request(&json!({ "messages": ["again"] }))
1256 .unwrap();
1257 resumed.log_api_response(&json!({ "id": "msg_2" })).unwrap();
1258 let next_tool = resumed
1259 .create_tool_invocation_dirs("sidreq_second")
1260 .unwrap();
1261 assert_eq!(next_tool.sequence, 2);
1262
1263 let events = fs::read_to_string(resumed.root().join(EVENTS_JOURNAL_FILE)).unwrap();
1264 let events = events
1265 .lines()
1266 .map(|line| serde_json::from_str::<serde_json::Value>(line).unwrap())
1267 .collect::<Vec<_>>();
1268 assert_eq!(
1269 events,
1270 vec![
1271 json!({
1272 "seq": 1,
1273 "kind": "session_start",
1274 "session_id": session.id(),
1275 "ts": events[0]["ts"].clone(),
1276 }),
1277 json!({
1278 "seq": 2,
1279 "kind": "session_resume",
1280 "session_id": session.id(),
1281 "ts": events[1]["ts"].clone(),
1282 }),
1283 ]
1284 );
1285
1286 let api = fs::read_to_string(resumed.root().join(API_JOURNAL_FILE)).unwrap();
1287 let api = api
1288 .lines()
1289 .map(|line| serde_json::from_str::<serde_json::Value>(line).unwrap())
1290 .collect::<Vec<_>>();
1291 assert_eq!(api[0]["api_seq"], json!(1));
1292 assert_eq!(api[1]["api_seq"], json!(1));
1293 assert_eq!(api[2]["api_seq"], json!(2));
1294 assert_eq!(api[3]["api_seq"], json!(2));
1295 assert_eq!(api[2]["seq"], json!(3));
1296 assert_eq!(api[3]["seq"], json!(4));
1297
1298 fs::remove_dir_all(sessions_root).unwrap();
1299 }
1300
1301 #[test]
1302 fn session_resume_advances_past_stale_tool_directories() {
1303 let sessions_root = PathBuf::from(unique_temp_dir("sessions").as_str());
1304 let session = SidSession::create_in(sessions_root.clone()).unwrap();
1305
1306 let first = session.create_tool_invocation_dirs("sidreq_first").unwrap();
1307 assert_eq!(first.sequence, 1);
1308
1309 let resumed = SidSession::resume_in(sessions_root.clone(), session.id()).unwrap();
1310 let second = resumed
1311 .create_tool_invocation_dirs("sidreq_second")
1312 .unwrap();
1313 assert_eq!(second.sequence, 2);
1314
1315 fs::remove_dir_all(sessions_root).unwrap();
1316 }
1317
1318 #[test]
1319 fn clear_bash_state_removes_snapshot_file() {
1320 let sessions_root = PathBuf::from(unique_temp_dir("sessions").as_str());
1321 let session = SidSession::create_in(sessions_root.clone()).unwrap();
1322
1323 session.write_bash_state("export FOO=bar\n").unwrap();
1324 assert_eq!(
1325 session.read_bash_state().unwrap(),
1326 Some("export FOO=bar\n".to_string())
1327 );
1328
1329 session.clear_bash_state().unwrap();
1330 assert_eq!(session.read_bash_state().unwrap(), None);
1331
1332 fs::remove_dir_all(sessions_root).unwrap();
1333 }
1334
1335 fn assert_timestamp_session_id(id: &str) {
1336 assert_eq!(id.len(), 31);
1337 assert_eq!(&id[4..5], "-");
1338 assert_eq!(&id[7..8], "-");
1339 assert_eq!(&id[10..11], "T");
1340 assert_eq!(&id[13..14], "-");
1341 assert_eq!(&id[16..17], "-");
1342 assert_eq!(&id[19..20], ".");
1343 assert!(matches!(&id[26..27], "+" | "-"));
1344 assert!(id[..4].chars().all(|ch| ch.is_ascii_digit()));
1345 assert!(id[27..31].chars().all(|ch| ch.is_ascii_digit()));
1346 }
1347
1348 fn assert_timestamp_created_at(value: &str) {
1349 assert_eq!(value.len(), 32);
1350 assert_eq!(&value[4..5], "-");
1351 assert_eq!(&value[7..8], "-");
1352 assert_eq!(&value[10..11], "T");
1353 assert_eq!(&value[13..14], ":");
1354 assert_eq!(&value[16..17], ":");
1355 assert_eq!(&value[19..20], ".");
1356 assert!(matches!(&value[26..27], "+" | "-"));
1357 assert_eq!(&value[29..30], ":");
1358 }
1359}