1use std::collections::HashSet;
7use std::fs;
8use std::path::PathBuf;
9
10use serde::{Deserialize, Serialize};
11
12use super::types::{CheckpointID, Phase, PromptAttribution, TokenUsage};
13use crate::team::project::find_project_root;
14use crate::util::now_iso8601;
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
23#[serde(rename_all = "camelCase")]
24pub struct SessionState {
25 #[serde(rename = "sessionID")]
27 pub session_id: String,
28 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub cli_version: Option<String>,
30 #[serde(default)]
31 pub base_commit: String,
32 #[serde(default, skip_serializing_if = "Option::is_none")]
33 pub attribution_base_commit: Option<String>,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub worktree_path: Option<String>,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
37 #[serde(rename = "worktreeID")]
38 pub worktree_id: Option<String>,
39 pub started_at: String,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub ended_at: Option<String>,
42 #[serde(default)]
43 pub phase: Phase,
44 #[serde(default, skip_serializing_if = "Option::is_none")]
45 #[serde(rename = "turnID")]
46 pub turn_id: Option<String>,
47 #[serde(default, skip_serializing_if = "Vec::is_empty")]
48 #[serde(rename = "turnCheckpointIDs")]
49 pub turn_checkpoint_ids: Vec<String>,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub last_interaction_time: Option<String>,
52 #[serde(default)]
53 pub step_count: i32,
54 #[serde(default)]
55 pub checkpoint_transcript_start: i64,
56 #[serde(default, skip_serializing_if = "Vec::is_empty")]
57 pub untracked_files_at_start: Vec<String>,
58 #[serde(default, skip_serializing_if = "Vec::is_empty")]
59 pub files_touched: Vec<String>,
60 #[serde(default, skip_serializing_if = "Option::is_none")]
61 #[serde(rename = "lastCheckpointID")]
62 pub last_checkpoint_id: Option<CheckpointID>,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub agent_type: Option<String>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub token_usage: Option<TokenUsage>,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub transcript_identifier_at_start: Option<String>,
69 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub transcript_path: Option<String>,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub first_prompt: Option<String>,
73 #[serde(default, skip_serializing_if = "Vec::is_empty")]
74 pub prompt_attributions: Vec<PromptAttribution>,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub pending_prompt_attribution: Option<PromptAttribution>,
77
78 #[serde(default, skip_serializing_if = "HashSet::is_empty")]
80 pub tools_used: HashSet<String>,
81 #[serde(default, skip_serializing_if = "is_zero_i32")]
82 pub tool_calls: i32,
83 #[serde(default, skip_serializing_if = "Vec::is_empty")]
84 pub commits: Vec<String>,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub est_cost_usd: Option<f64>,
87}
88
89fn is_zero_i32(v: &i32) -> bool {
90 *v == 0
91}
92
93impl SessionState {
94 pub fn new(agent: &str, _model: Option<&str>) -> Self {
96 let base_commit = get_head_commit().unwrap_or_default();
97 Self {
98 session_id: generate_session_id(),
99 cli_version: Some(env!("CARGO_PKG_VERSION").to_string()),
100 base_commit: base_commit.clone(),
101 attribution_base_commit: Some(base_commit),
102 worktree_path: None,
103 worktree_id: None,
104 started_at: now_iso8601(),
105 ended_at: None,
106 phase: Phase::Active,
107 turn_id: None,
108 turn_checkpoint_ids: Vec::new(),
109 last_interaction_time: Some(now_iso8601()),
110 step_count: 0,
111 checkpoint_transcript_start: 0,
112 untracked_files_at_start: Vec::new(),
113 files_touched: Vec::new(),
114 last_checkpoint_id: None,
115 agent_type: Some(agent_type_display(agent)),
116 token_usage: Some(TokenUsage::default()),
117 transcript_identifier_at_start: None,
118 transcript_path: None,
119 first_prompt: None,
120 prompt_attributions: Vec::new(),
121 pending_prompt_attribution: None,
122 tools_used: HashSet::new(),
123 tool_calls: 0,
124 commits: Vec::new(),
125 est_cost_usd: None,
126 }
127 }
128}
129
130#[derive(Debug, Clone)]
136pub enum SessionEvent {
137 SessionStart,
138 TurnStart,
139 TurnEnd,
140 GitCommit,
141 SessionStop,
142 Compaction,
143}
144
145impl SessionState {
146 pub fn apply_event(&mut self, event: SessionEvent) -> bool {
148 let now = now_iso8601();
149 match (&self.phase, event) {
150 (Phase::Idle, SessionEvent::TurnStart) => {
152 self.phase = Phase::Active;
153 self.last_interaction_time = Some(now);
154 self.step_count += 1;
155 true
156 }
157 (Phase::Idle, SessionEvent::SessionStop) => {
159 self.phase = Phase::Ended;
160 self.ended_at = Some(now.clone());
161 self.last_interaction_time = Some(now);
162 true
163 }
164 (Phase::Idle, SessionEvent::GitCommit) => {
166 self.last_interaction_time = Some(now);
167 true
168 }
169 (Phase::Active, SessionEvent::TurnEnd) => {
171 self.phase = Phase::Idle;
172 self.last_interaction_time = Some(now);
173 true
174 }
175 (Phase::Active, SessionEvent::GitCommit) => {
177 self.last_interaction_time = Some(now);
178 true
179 }
180 (Phase::Active, SessionEvent::Compaction) => {
182 self.last_interaction_time = Some(now);
183 true
184 }
185 (Phase::Active, SessionEvent::SessionStop) => {
187 self.phase = Phase::Ended;
188 self.ended_at = Some(now.clone());
189 self.last_interaction_time = Some(now);
190 true
191 }
192 (Phase::Ended, SessionEvent::TurnStart) => {
194 self.phase = Phase::Active;
195 self.ended_at = None;
196 self.last_interaction_time = Some(now);
197 self.step_count += 1;
198 true
199 }
200 (Phase::Ended, SessionEvent::GitCommit) => {
202 if !self.files_touched.is_empty() {
203 self.last_interaction_time = Some(now);
204 }
205 true
206 }
207 _ => false,
208 }
209 }
210
211 pub fn touch_file(&mut self, path: &str) {
213 let relative = crate::team::hooks::relativize_path(path);
214 let normalized = relative.replace('\\', "/");
215 if !self.files_touched.contains(&normalized) {
216 self.files_touched.push(normalized);
217 }
218 }
219
220 pub fn add_tokens(&mut self, tokens: &TokenUsage) {
222 let usage = self.token_usage.get_or_insert_with(TokenUsage::default);
223 usage.add(tokens);
224 }
225}
226
227fn sessions_dir() -> Option<PathBuf> {
234 let project_root = find_project_root(None)?;
235 let git_dir = project_root.join(".git");
236 if git_dir.is_dir() {
237 Some(git_dir.join("entire-sessions"))
239 } else {
240 None
241 }
242}
243
244pub fn save_state(state: &SessionState) -> bool {
246 let dir = match sessions_dir() {
247 Some(d) => d,
248 None => return false,
249 };
250 let _ = fs::create_dir_all(&dir);
251 let path = dir.join(format!("{}.json", state.session_id));
252 let json = match serde_json::to_string_pretty(state) {
253 Ok(j) => j + "\n", Err(_) => return false,
255 };
256 crate::util::atomic_write(&path, json.as_bytes()).is_ok()
257}
258
259pub fn load_state(session_id: &str) -> Option<SessionState> {
261 let dir = sessions_dir()?;
262 let path = dir.join(format!("{}.json", session_id));
263 let content = fs::read_to_string(&path).ok()?;
264 serde_json::from_str(&content).ok()
265}
266
267pub fn delete_state(session_id: &str) -> bool {
269 let dir = match sessions_dir() {
270 Some(d) => d,
271 None => return false,
272 };
273 let path = dir.join(format!("{}.json", session_id));
274 fs::remove_file(&path).is_ok()
275}
276
277pub fn list_states() -> Vec<SessionState> {
279 let dir = match sessions_dir() {
280 Some(d) => d,
281 None => return vec![],
282 };
283 if !dir.is_dir() {
284 return vec![];
285 }
286
287 let now_secs = std::time::SystemTime::now()
288 .duration_since(std::time::UNIX_EPOCH)
289 .unwrap_or_default()
290 .as_secs();
291 let stale_threshold = 7 * 86400; let mut states = Vec::new();
294 let mut stale_ids = Vec::new();
295
296 if let Ok(entries) = fs::read_dir(&dir) {
297 for entry in entries.flatten() {
298 let path = entry.path();
299 if path.extension().map(|e| e == "json").unwrap_or(false) {
300 if let Ok(content) = fs::read_to_string(&path) {
301 if let Ok(state) = serde_json::from_str::<SessionState>(&content) {
302 let last_time = state
304 .last_interaction_time
305 .as_deref()
306 .unwrap_or(&state.started_at);
307 if is_stale(last_time, now_secs, stale_threshold) {
308 stale_ids.push(state.session_id.clone());
309 continue;
310 }
311 states.push(state);
312 }
313 }
314 }
315 }
316 }
317
318 for id in stale_ids {
320 delete_state(&id);
321 }
322
323 states.sort_by(|a, b| b.started_at.cmp(&a.started_at));
324 states
325}
326
327pub fn get_active_state() -> Option<SessionState> {
329 list_states().into_iter().find(|s| s.phase != Phase::Ended)
330}
331
332fn generate_session_id() -> String {
338 let now = now_iso8601();
339 let date = now.get(..10).unwrap_or("0000-00-00");
340 let hex = random_hex(8);
341 format!("{}-{}", date, hex)
342}
343
344fn random_hex(len: usize) -> String {
345 use std::collections::hash_map::DefaultHasher;
346 use std::hash::{Hash, Hasher};
347
348 let mut hasher = DefaultHasher::new();
349 std::time::SystemTime::now().hash(&mut hasher);
350 std::process::id().hash(&mut hasher);
351 std::thread::current().id().hash(&mut hasher);
352 let hash = hasher.finish();
353 let hex = format!("{:016x}", hash);
354 hex[..len.min(16)].to_string()
355}
356
357fn agent_type_display(agent: &str) -> String {
360 match agent.to_lowercase().as_str() {
361 "claude-code" | "claude" | "claudecode" => "Claude Code".to_string(),
362 "cursor" => "Cursor IDE".to_string(),
363 "gemini-cli" | "gemini" => "Gemini CLI".to_string(),
364 "copilot" | "github-copilot" => "GitHub Copilot".to_string(),
365 "opencode" => "OpenCode".to_string(),
366 "aider" => "Aider".to_string(),
367 "codex" => "Codex".to_string(),
368 "windsurf" => "Windsurf".to_string(),
369 "cline" => "Cline".to_string(),
370 _ => "Agent".to_string(),
371 }
372}
373
374fn get_head_commit() -> Option<String> {
375 std::process::Command::new("git")
376 .args(["rev-parse", "HEAD"])
377 .output()
378 .ok()
379 .and_then(|o| {
380 let s = String::from_utf8_lossy(&o.stdout).trim().to_string();
381 if s.is_empty() {
382 None
383 } else {
384 Some(s)
385 }
386 })
387}
388
389fn is_stale(iso_time: &str, now_secs: u64, threshold_secs: u64) -> bool {
390 let parts: Vec<&str> = iso_time.split('T').collect();
392 if parts.is_empty() {
393 return false;
394 }
395 let date_parts: Vec<u64> = parts[0].split('-').filter_map(|p| p.parse().ok()).collect();
396 if date_parts.len() != 3 {
397 return false;
398 }
399 let (y, m, d) = (date_parts[0], date_parts[1], date_parts[2]);
400 let approx_secs = y * 365 * 86400 + m * 30 * 86400 + d * 86400;
401
402 let now_y = now_secs / (365 * 86400);
403 let now_approx = now_y * 365 * 86400
404 + ((now_secs % (365 * 86400)) / (30 * 86400)) * 30 * 86400
405 + ((now_secs % (30 * 86400)) / 86400) * 86400;
406
407 now_approx.saturating_sub(approx_secs) > threshold_secs
408}
409
410#[cfg(test)]
411mod tests {
412 use super::*;
413
414 #[test]
415 fn session_state_json_roundtrip() {
416 let state = SessionState::new("claude-code", Some("claude-opus-4-6"));
417 let json = serde_json::to_string_pretty(&state).unwrap();
418 let parsed: SessionState = serde_json::from_str(&json).unwrap();
419 assert_eq!(parsed.session_id, state.session_id);
420 assert_eq!(parsed.phase, Phase::Active);
421 assert!(json.contains("\"sessionID\"")); assert!(json.contains("\"stepCount\"")); assert!(json.contains("\"startedAt\""));
424 assert!(json.contains("\"agentType\""));
425 }
426
427 #[test]
428 fn phase_transitions() {
429 let mut state = SessionState::new("claude-code", None);
430 assert!(state.apply_event(SessionEvent::TurnEnd));
432 assert_eq!(state.phase, Phase::Idle);
433 assert!(state.apply_event(SessionEvent::TurnStart));
435 assert_eq!(state.phase, Phase::Active);
436 assert_eq!(state.step_count, 1); assert!(state.apply_event(SessionEvent::SessionStop));
439 assert_eq!(state.phase, Phase::Ended);
440 assert!(state.apply_event(SessionEvent::TurnStart));
442 assert_eq!(state.phase, Phase::Active);
443 }
444
445 #[test]
446 fn touch_file_deduplicates() {
447 let mut state = SessionState::new("test", None);
448 state.touch_file("src/main.rs");
449 state.touch_file("src/main.rs");
450 state.touch_file("src\\lib.rs"); assert_eq!(state.files_touched.len(), 2);
452 assert!(state.files_touched.contains(&"src/lib.rs".to_string()));
453 }
454
455 #[test]
458 fn invalid_transitions_rejected() {
459 let mut state = SessionState::new("test", None);
461 state.phase = Phase::Idle;
462 assert!(!state.apply_event(SessionEvent::TurnEnd));
463 assert_eq!(state.phase, Phase::Idle, "phase should not change");
464
465 let mut state2 = SessionState::new("test", None);
467 state2.phase = Phase::Idle;
468 assert!(!state2.apply_event(SessionEvent::Compaction));
469
470 let mut state3 = SessionState::new("test", None);
472 state3.phase = Phase::Ended;
473 assert!(!state3.apply_event(SessionEvent::TurnEnd));
474
475 let mut state4 = SessionState::new("test", None);
477 state4.phase = Phase::Ended;
478 assert!(!state4.apply_event(SessionEvent::SessionStop));
479
480 let mut state5 = SessionState::new("test", None);
482 state5.phase = Phase::Ended;
483 assert!(!state5.apply_event(SessionEvent::Compaction));
484 }
485
486 #[test]
487 fn idle_git_commit_stays_idle() {
488 let mut state = SessionState::new("test", None);
489 state.phase = Phase::Idle;
490 assert!(state.apply_event(SessionEvent::GitCommit));
491 assert_eq!(state.phase, Phase::Idle);
492 }
493
494 #[test]
495 fn active_git_commit_stays_active() {
496 let mut state = SessionState::new("test", None);
497 assert!(state.apply_event(SessionEvent::GitCommit));
498 assert_eq!(state.phase, Phase::Active);
499 }
500
501 #[test]
502 fn active_compaction_stays_active() {
503 let mut state = SessionState::new("test", None);
504 assert!(state.apply_event(SessionEvent::Compaction));
505 assert_eq!(state.phase, Phase::Active);
506 }
507
508 #[test]
509 fn ended_turn_start_reactivates() {
510 let mut state = SessionState::new("test", None);
511 state.apply_event(SessionEvent::SessionStop);
512 assert_eq!(state.phase, Phase::Ended);
513 assert!(state.ended_at.is_some());
514
515 assert!(state.apply_event(SessionEvent::TurnStart));
517 assert_eq!(state.phase, Phase::Active);
518 assert!(state.ended_at.is_none(), "ended_at should be cleared");
519 }
520
521 #[test]
522 fn ended_git_commit_with_files_stays_ended() {
523 let mut state = SessionState::new("test", None);
524 state.files_touched.push("src/main.rs".to_string());
525 state.apply_event(SessionEvent::SessionStop);
526
527 assert!(state.apply_event(SessionEvent::GitCommit));
529 assert_eq!(state.phase, Phase::Ended, "should stay Ended");
530 assert!(state.last_interaction_time.is_some());
532 }
533
534 #[test]
535 fn step_count_increments_on_turn_start() {
536 let mut state = SessionState::new("test", None);
537 assert_eq!(state.step_count, 0);
538
539 state.apply_event(SessionEvent::TurnEnd);
541 state.apply_event(SessionEvent::TurnStart);
542 assert_eq!(state.step_count, 1);
543
544 state.apply_event(SessionEvent::TurnEnd);
545 state.apply_event(SessionEvent::TurnStart);
546 assert_eq!(state.step_count, 2);
547 }
548
549 #[test]
552 fn touch_file_normalizes_backslashes() {
553 let mut state = SessionState::new("test", None);
554 state.touch_file("src\\nested\\deep\\file.rs");
555 assert!(state
556 .files_touched
557 .contains(&"src/nested/deep/file.rs".to_string()));
558 }
559
560 #[test]
561 fn touch_file_empty_string() {
562 let mut state = SessionState::new("test", None);
563 state.touch_file("");
564 assert_eq!(state.files_touched.len(), 1);
566 }
567
568 #[test]
571 fn add_tokens_creates_usage_if_none() {
572 let mut state = SessionState::new("test", None);
573 state.token_usage = None;
574 let tokens = super::TokenUsage {
575 input_tokens: 500,
576 output_tokens: 200,
577 ..Default::default()
578 };
579 state.add_tokens(&tokens);
580 assert!(state.token_usage.is_some());
581 assert_eq!(state.token_usage.as_ref().unwrap().input_tokens, 500);
582 }
583
584 #[test]
585 fn add_tokens_accumulates() {
586 let mut state = SessionState::new("test", None);
587 let t1 = super::TokenUsage {
588 input_tokens: 100,
589 ..Default::default()
590 };
591 let t2 = super::TokenUsage {
592 input_tokens: 200,
593 ..Default::default()
594 };
595 state.add_tokens(&t1);
596 state.add_tokens(&t2);
597 assert_eq!(state.token_usage.as_ref().unwrap().input_tokens, 300);
598 }
599
600 #[test]
603 fn is_stale_recent_date_not_stale() {
604 let now_secs = std::time::SystemTime::now()
605 .duration_since(std::time::UNIX_EPOCH)
606 .unwrap()
607 .as_secs();
608 assert!(!is_stale("2026-03-28T12:00:00.000Z", now_secs, 7 * 86400));
610 }
611
612 #[test]
613 fn is_stale_malformed_date() {
614 assert!(!is_stale("not-a-date", 1000000, 86400));
615 assert!(!is_stale("", 1000000, 86400));
616 assert!(!is_stale("2026", 1000000, 86400));
617 }
618
619 #[test]
622 fn session_state_entire_io_field_names() {
623 let mut state = SessionState::new("claude-code", None);
624 state.touch_file("src/main.rs");
626
627 let json = serde_json::to_string(&state).unwrap();
628
629 assert!(
631 json.contains("\"sessionID\""),
632 "must be sessionID not sessionId"
633 );
634 assert!(json.contains("\"baseCommit\""));
635 assert!(json.contains("\"startedAt\""));
636 assert!(json.contains("\"filesTouched\""));
637 assert!(json.contains("\"stepCount\""));
638 assert!(json.contains("\"agentType\""));
639
640 assert!(
642 !json.contains("\"endedAt\""),
643 "endedAt should be skipped when None"
644 );
645 assert!(
646 !json.contains("\"worktreeID\""),
647 "worktreeID should be skipped"
648 );
649 assert!(!json.contains("\"turnID\""), "turnID should be skipped");
650 }
651
652 #[test]
653 fn session_state_roundtrip_with_all_fields() {
654 let mut state = SessionState::new("cursor", Some("gpt-4o"));
655 state.touch_file("src/app.tsx");
656 state.tool_calls = 5;
657 state.tools_used.insert("Read".to_string());
658 state.tools_used.insert("Edit".to_string());
659 state.commits.push("abc1234".to_string());
660 state.est_cost_usd = Some(1.23);
661
662 let json = serde_json::to_string_pretty(&state).unwrap();
663 let parsed: SessionState = serde_json::from_str(&json).unwrap();
664
665 assert_eq!(parsed.session_id, state.session_id);
666 assert_eq!(parsed.tool_calls, 5);
667 assert!(parsed.tools_used.contains("Read"));
668 assert!(parsed.tools_used.contains("Edit"));
669 assert_eq!(parsed.commits, vec!["abc1234"]);
670 assert_eq!(parsed.est_cost_usd, Some(1.23));
671 assert_eq!(parsed.files_touched, vec!["src/app.tsx"]);
672 }
673}