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