1use std::io::{self, IsTerminal, Write};
18use std::path::{Path, PathBuf};
19
20use anyhow::{Context, Result};
21
22use crate::constants::paths::SESSION_FILENAME;
23use crate::contracts::{QueueFile, SessionState, TaskStatus};
24use crate::fsutil;
25use crate::timeutil;
26
27pub fn session_path(cache_dir: &Path) -> PathBuf {
29 cache_dir.join(SESSION_FILENAME)
30}
31
32pub fn session_exists(cache_dir: &Path) -> bool {
34 session_path(cache_dir).exists()
35}
36
37pub fn save_session(cache_dir: &Path, session: &SessionState) -> Result<()> {
39 let path = session_path(cache_dir);
40 let json = serde_json::to_string_pretty(session).context("serialize session state")?;
41 fsutil::write_atomic(&path, json.as_bytes()).context("write session file")?;
42 log::debug!("Session saved: task_id={}", session.task_id);
43 Ok(())
44}
45
46pub fn load_session(cache_dir: &Path) -> Result<Option<SessionState>> {
48 let path = session_path(cache_dir);
49 if !path.exists() {
50 return Ok(None);
51 }
52
53 let content = std::fs::read_to_string(&path).context("read session file")?;
54 let session: SessionState = serde_json::from_str(&content).context("parse session file")?;
55
56 if session.version > crate::contracts::SESSION_STATE_VERSION {
58 log::warn!(
59 "Session file version {} is newer than supported version {}. \
60 Attempting to load anyway.",
61 session.version,
62 crate::contracts::SESSION_STATE_VERSION
63 );
64 }
65
66 Ok(Some(session))
67}
68
69pub fn clear_session(cache_dir: &Path) -> Result<()> {
71 let path = session_path(cache_dir);
72 if path.exists() {
73 std::fs::remove_file(&path).context("remove session file")?;
74 log::debug!("Session cleared");
75 }
76 Ok(())
77}
78
79pub fn increment_session_progress(cache_dir: &Path) -> Result<()> {
85 let mut session = match load_session(cache_dir)? {
86 Some(s) => s,
87 None => {
88 log::debug!("No session to increment progress for");
89 return Ok(());
90 }
91 };
92
93 let now = crate::timeutil::now_utc_rfc3339_or_fallback();
94 session.mark_task_complete(now);
95 save_session(cache_dir, &session)
96}
97
98#[allow(clippy::large_enum_variant)]
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub enum SessionValidationResult {
104 Valid(SessionState),
106 NoSession,
108 Stale { reason: String },
110 Timeout { hours: u64, session: SessionState },
112}
113
114fn validate_session_with_now(
119 session: &SessionState,
120 queue: &QueueFile,
121 timeout_hours: Option<u64>,
122 now: time::OffsetDateTime,
123) -> SessionValidationResult {
124 let task = match queue.tasks.iter().find(|t| t.id.trim() == session.task_id) {
126 Some(t) => t,
127 None => {
128 return SessionValidationResult::Stale {
129 reason: format!("Task {} no longer exists in queue", session.task_id),
130 };
131 }
132 };
133
134 if task.status != TaskStatus::Doing {
135 return SessionValidationResult::Stale {
136 reason: format!(
137 "Task {} is not in Doing status (current: {})",
138 session.task_id, task.status
139 ),
140 };
141 }
142
143 if let Some(timeout) = timeout_hours
145 && let Ok(session_time) = timeutil::parse_rfc3339(&session.last_updated_at)
146 {
147 if now > session_time {
149 let elapsed = now - session_time;
150 let hours = elapsed.whole_hours() as u64;
151 if hours >= timeout {
152 return SessionValidationResult::Timeout {
153 hours,
154 session: session.clone(),
155 };
156 }
157 }
158 }
159
160 SessionValidationResult::Valid(session.clone())
161}
162
163pub fn validate_session(
168 session: &SessionState,
169 queue: &QueueFile,
170 timeout_hours: Option<u64>,
171) -> SessionValidationResult {
172 validate_session_with_now(
173 session,
174 queue,
175 timeout_hours,
176 time::OffsetDateTime::now_utc(),
177 )
178}
179
180pub fn check_session(
182 cache_dir: &Path,
183 queue: &QueueFile,
184 timeout_hours: Option<u64>,
185) -> Result<SessionValidationResult> {
186 let session = match load_session(cache_dir)? {
187 Some(s) => s,
188 None => return Ok(SessionValidationResult::NoSession),
189 };
190
191 Ok(validate_session(&session, queue, timeout_hours))
192}
193
194pub fn prompt_session_recovery(session: &SessionState, non_interactive: bool) -> Result<bool> {
199 if non_interactive || !std::io::stdin().is_terminal() {
200 log::info!(
201 "Non-interactive environment detected; skipping session resume for {}",
202 session.task_id
203 );
204 return Ok(false); }
206
207 println!();
208 println!("╔══════════════════════════════════════════════════════════════╗");
209 println!("║ Incomplete session detected ║");
210 println!("╠══════════════════════════════════════════════════════════════╣");
211 println!("║ Task: {}", pad_right(&session.task_id, 45));
212 println!("║ Started: {}", pad_right(&session.run_started_at, 45));
213 println!(
214 "║ Iterations: {}/{}",
215 session.iterations_completed, session.iterations_planned
216 );
217 println!(
218 "║ Phase: {}",
219 pad_right(&format!("{}", session.current_phase), 45)
220 );
221
222 if session.phase1_settings.is_some()
224 || session.phase2_settings.is_some()
225 || session.phase3_settings.is_some()
226 {
227 println!("╠══════════════════════════════════════════════════════════════╣");
228 println!("║ Phase Settings: ║");
229
230 if let Some(ref p1) = session.phase1_settings {
231 let effort_str = p1
232 .reasoning_effort
233 .map(|e| format!(", effort={:?}", e))
234 .unwrap_or_default();
235 let settings_str = format!("{:?}/{}{}", p1.runner, p1.model, effort_str);
236 println!("║ Phase 1: {}", pad_right(&settings_str, 41));
237 }
238
239 if let Some(ref p2) = session.phase2_settings {
240 let effort_str = p2
241 .reasoning_effort
242 .map(|e| format!(", effort={:?}", e))
243 .unwrap_or_default();
244 let settings_str = format!("{:?}/{}{}", p2.runner, p2.model, effort_str);
245 println!("║ Phase 2: {}", pad_right(&settings_str, 41));
246 }
247
248 if let Some(ref p3) = session.phase3_settings {
249 let effort_str = p3
250 .reasoning_effort
251 .map(|e| format!(", effort={:?}", e))
252 .unwrap_or_default();
253 let settings_str = format!("{:?}/{}{}", p3.runner, p3.model, effort_str);
254 println!("║ Phase 3: {}", pad_right(&settings_str, 41));
255 }
256 }
257
258 println!("╚══════════════════════════════════════════════════════════════╝");
259 println!();
260 print!("Resume this session? [Y/n]: ");
261 io::stdout().flush().context("flush stdout")?;
262
263 let mut input = String::new();
264 io::stdin().read_line(&mut input).context("read stdin")?;
265
266 let input = input.trim().to_lowercase();
267 Ok(input.is_empty() || input == "y" || input == "yes")
268}
269
270pub fn prompt_session_recovery_timeout(
281 session: &SessionState,
282 hours: u64,
283 threshold_hours: u64,
284 non_interactive: bool,
285) -> Result<bool> {
286 if non_interactive || !std::io::stdin().is_terminal() {
287 log::info!(
288 "Non-interactive environment detected; skipping stale session resume for {} ({} hours old)",
289 session.task_id,
290 hours
291 );
292 return Ok(false); }
294
295 println!();
296 println!("╔══════════════════════════════════════════════════════════════╗");
297 println!(
298 "║ STALE session detected ({} hours old)",
299 pad_right(&hours.to_string(), 27)
300 );
301 println!("╠══════════════════════════════════════════════════════════════╣");
302 println!("║ Task: {}", pad_right(&session.task_id, 45));
303 println!("║ Started: {}", pad_right(&session.run_started_at, 45));
304 println!(
305 "║ Last update: {}",
306 pad_right(&session.last_updated_at, 45)
307 );
308 println!(
309 "║ Iterations: {}/{}",
310 session.iterations_completed, session.iterations_planned
311 );
312
313 if session.phase1_settings.is_some()
315 || session.phase2_settings.is_some()
316 || session.phase3_settings.is_some()
317 {
318 println!("╠══════════════════════════════════════════════════════════════╣");
319 println!("║ Phase Settings: ║");
320
321 if let Some(ref p1) = session.phase1_settings {
322 let effort_str = p1
323 .reasoning_effort
324 .map(|e| format!(", effort={:?}", e))
325 .unwrap_or_default();
326 let settings_str = format!("{:?}/{}{}", p1.runner, p1.model, effort_str);
327 println!("║ Phase 1: {}", pad_right(&settings_str, 41));
328 }
329
330 if let Some(ref p2) = session.phase2_settings {
331 let effort_str = p2
332 .reasoning_effort
333 .map(|e| format!(", effort={:?}", e))
334 .unwrap_or_default();
335 let settings_str = format!("{:?}/{}{}", p2.runner, p2.model, effort_str);
336 println!("║ Phase 2: {}", pad_right(&settings_str, 41));
337 }
338
339 if let Some(ref p3) = session.phase3_settings {
340 let effort_str = p3
341 .reasoning_effort
342 .map(|e| format!(", effort={:?}", e))
343 .unwrap_or_default();
344 let settings_str = format!("{:?}/{}{}", p3.runner, p3.model, effort_str);
345 println!("║ Phase 3: {}", pad_right(&settings_str, 41));
346 }
347 }
348
349 println!("╚══════════════════════════════════════════════════════════════╝");
350 println!();
351 println!(
352 "Warning: This session is older than {} hour{}.",
353 threshold_hours,
354 if threshold_hours == 1 { "" } else { "s" }
355 );
356 print!("Resume anyway? [y/N]: ");
357 io::stdout().flush().context("flush stdout")?;
358
359 let mut input = String::new();
360 io::stdin().read_line(&mut input).context("read stdin")?;
361
362 let input = input.trim().to_lowercase();
363 Ok(input == "y" || input == "yes")
364}
365
366fn pad_right(s: &str, width: usize) -> String {
367 if s.len() >= width {
368 s.to_string()
369 } else {
370 format!("{}{}", s, " ".repeat(width - s.len()))
371 }
372}
373
374pub fn get_git_head_commit(repo_root: &Path) -> Option<String> {
376 let output = std::process::Command::new("git")
377 .arg("-C")
378 .arg(repo_root)
379 .arg("rev-parse")
380 .arg("HEAD")
381 .output()
382 .ok()?;
383
384 if output.status.success() {
385 Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
386 } else {
387 None
388 }
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394 use crate::contracts::{Task, TaskPriority};
395 use tempfile::TempDir;
396 use time::Duration;
397
398 fn test_task(id: &str, status: TaskStatus) -> Task {
399 Task {
400 id: id.to_string(),
401 status,
402 title: "Test".to_string(),
403 description: None,
404 priority: TaskPriority::Medium,
405 tags: vec![],
406 scope: vec![],
407 evidence: vec![],
408 plan: vec![],
409 notes: vec![],
410 request: None,
411 agent: None,
412 created_at: None,
413 updated_at: None,
414 completed_at: None,
415 started_at: None,
416 scheduled_start: None,
417 depends_on: vec![],
418 blocks: vec![],
419 relates_to: vec![],
420 duplicates: None,
421 custom_fields: Default::default(),
422 parent_id: None,
423 estimated_minutes: None,
424 actual_minutes: None,
425 }
426 }
427
428 const TEST_NOW: &str = "2026-02-07T12:00:00.000000000Z";
430
431 fn test_now() -> time::OffsetDateTime {
432 timeutil::parse_rfc3339(TEST_NOW).unwrap()
433 }
434
435 fn test_session_with_time(task_id: &str, last_updated_at: &str) -> SessionState {
436 SessionState::new(
437 "test-session-id".to_string(),
438 task_id.to_string(),
439 last_updated_at.to_string(),
440 1,
441 crate::contracts::Runner::Claude,
442 "sonnet".to_string(),
443 0,
444 None,
445 None, )
447 }
448
449 fn test_session(task_id: &str) -> SessionState {
450 test_session_with_time(task_id, TEST_NOW)
451 }
452
453 #[test]
454 fn save_and_load_session_roundtrip() {
455 let temp_dir = TempDir::new().unwrap();
456 let session = test_session("RQ-0001");
457
458 save_session(temp_dir.path(), &session).unwrap();
459 let loaded = load_session(temp_dir.path()).unwrap().unwrap();
460
461 assert_eq!(loaded.session_id, session.session_id);
462 assert_eq!(loaded.task_id, session.task_id);
463 assert_eq!(loaded.iterations_planned, session.iterations_planned);
464 }
465
466 #[test]
467 fn clear_session_removes_file() {
468 let temp_dir = TempDir::new().unwrap();
469 let session = test_session("RQ-0001");
470
471 save_session(temp_dir.path(), &session).unwrap();
472 assert!(session_exists(temp_dir.path()));
473
474 clear_session(temp_dir.path()).unwrap();
475 assert!(!session_exists(temp_dir.path()));
476 }
477
478 #[test]
479 fn validate_session_valid_when_task_doing() {
480 let session = test_session("RQ-0001");
481 let queue = QueueFile {
482 version: 1,
483 tasks: vec![test_task("RQ-0001", TaskStatus::Doing)],
484 };
485
486 let result = validate_session(&session, &queue, None);
487 assert!(matches!(result, SessionValidationResult::Valid(_)));
488 }
489
490 #[test]
491 fn validate_session_stale_when_task_not_doing() {
492 let session = test_session("RQ-0001");
493 let queue = QueueFile {
494 version: 1,
495 tasks: vec![test_task("RQ-0001", TaskStatus::Todo)],
496 };
497
498 let result = validate_session(&session, &queue, None);
499 assert!(matches!(result, SessionValidationResult::Stale { .. }));
500 }
501
502 #[test]
503 fn validate_session_stale_when_task_missing() {
504 let session = test_session("RQ-0001");
505 let queue = QueueFile {
506 version: 1,
507 tasks: vec![test_task("RQ-0002", TaskStatus::Doing)],
508 };
509
510 let result = validate_session(&session, &queue, None);
511 assert!(matches!(result, SessionValidationResult::Stale { .. }));
512 }
513
514 #[test]
515 fn check_session_returns_no_session_when_file_missing() {
516 let temp_dir = TempDir::new().unwrap();
517 let queue = QueueFile {
518 version: 1,
519 tasks: vec![],
520 };
521
522 let result = check_session(temp_dir.path(), &queue, None).unwrap();
523 assert_eq!(result, SessionValidationResult::NoSession);
524 }
525
526 #[test]
527 fn session_path_returns_correct_path() {
528 let temp_dir = TempDir::new().unwrap();
529 let path = session_path(temp_dir.path());
530 assert_eq!(path, temp_dir.path().join("session.jsonc"));
531 }
532
533 #[test]
534 fn prompt_session_recovery_returns_false_when_non_interactive() {
535 let session = test_session("RQ-0001");
536 let result = prompt_session_recovery(&session, true).unwrap();
538 assert!(
539 !result,
540 "non_interactive=true should return false (do not resume)"
541 );
542 }
543
544 #[test]
545 fn prompt_session_recovery_timeout_returns_false_when_non_interactive() {
546 let session = test_session("RQ-0001");
547 let result = prompt_session_recovery_timeout(&session, 48, 24, true).unwrap();
549 assert!(
550 !result,
551 "non_interactive=true should return false (do not resume)"
552 );
553 }
554
555 #[test]
556 fn validate_session_returns_timeout_when_older_than_threshold() {
557 let now = test_now();
559 let session_time = now - Duration::hours(48);
560 let session =
561 test_session_with_time("RQ-0001", &timeutil::format_rfc3339(session_time).unwrap());
562 let queue = QueueFile {
563 version: 1,
564 tasks: vec![test_task("RQ-0001", TaskStatus::Doing)],
565 };
566
567 let result = validate_session_with_now(&session, &queue, Some(24), now);
569 match result {
570 SessionValidationResult::Timeout {
571 hours,
572 session: timed_out,
573 } => {
574 assert_eq!(hours, 48, "Expected exactly 48 hours, got {hours}");
575 assert_eq!(timed_out.task_id, session.task_id);
576 assert_eq!(timed_out.session_id, session.session_id);
577 }
578 other => panic!("expected Timeout, got {other:?}"),
579 }
580 }
581
582 #[test]
589 fn check_session_returns_timeout_and_includes_loaded_session() {
590 let temp_dir = TempDir::new().unwrap();
591
592 let session_time = time::OffsetDateTime::now_utc() - Duration::days(365);
595 let session =
596 test_session_with_time("RQ-0001", &timeutil::format_rfc3339(session_time).unwrap());
597
598 save_session(temp_dir.path(), &session).unwrap();
599
600 let queue = QueueFile {
601 version: 1,
602 tasks: vec![test_task("RQ-0001", TaskStatus::Doing)],
603 };
604
605 let result = check_session(temp_dir.path(), &queue, Some(24)).unwrap();
606
607 match result {
608 SessionValidationResult::Timeout {
609 hours,
610 session: timed_out,
611 } => {
612 assert!(hours >= 24, "Expected at least 24 hours, got {hours}");
614 assert_eq!(timed_out.task_id, session.task_id);
615 assert_eq!(timed_out.session_id, session.session_id);
616 assert_eq!(timed_out.last_updated_at, session.last_updated_at);
617 }
618 other => panic!("expected Timeout, got {other:?}"),
619 }
620 }
621
622 #[test]
623 fn validate_session_returns_valid_when_within_custom_threshold() {
624 let now = test_now();
626 let session_time = now - Duration::hours(12);
627 let session =
628 test_session_with_time("RQ-0001", &timeutil::format_rfc3339(session_time).unwrap());
629 let queue = QueueFile {
630 version: 1,
631 tasks: vec![test_task("RQ-0001", TaskStatus::Doing)],
632 };
633
634 let result = validate_session_with_now(&session, &queue, Some(48), now);
636 assert!(
637 matches!(result, SessionValidationResult::Valid(_)),
638 "Session within custom threshold should return Valid"
639 );
640 }
641
642 #[test]
643 fn validate_session_returns_valid_when_within_default_threshold() {
644 let now = test_now();
646 let session_time = now - Duration::hours(1);
647 let session =
648 test_session_with_time("RQ-0001", &timeutil::format_rfc3339(session_time).unwrap());
649 let queue = QueueFile {
650 version: 1,
651 tasks: vec![test_task("RQ-0001", TaskStatus::Doing)],
652 };
653
654 let result = validate_session_with_now(&session, &queue, Some(24), now);
656 assert!(
657 matches!(result, SessionValidationResult::Valid(_)),
658 "Session within default threshold should return Valid"
659 );
660 }
661
662 #[test]
663 fn validate_session_returns_valid_when_no_timeout_configured() {
664 let session = test_session("RQ-0001");
665 let queue = QueueFile {
666 version: 1,
667 tasks: vec![test_task("RQ-0001", TaskStatus::Doing)],
668 };
669
670 let result = validate_session(&session, &queue, None);
672 assert!(
673 matches!(result, SessionValidationResult::Valid(_)),
674 "Session should be Valid when no timeout is configured"
675 );
676 }
677
678 #[test]
679 fn validate_session_invalid_last_updated_does_not_timeout() {
680 let session = test_session_with_time("RQ-0001", "not-a-valid-timestamp");
682 let queue = QueueFile {
683 version: 1,
684 tasks: vec![test_task("RQ-0001", TaskStatus::Doing)],
685 };
686
687 let result = validate_session_with_now(
689 &session,
690 &queue,
691 Some(1), test_now(),
693 );
694 assert!(
695 matches!(result, SessionValidationResult::Valid(_)),
696 "Session with invalid timestamp should be Valid (can't compute timeout)"
697 );
698 }
699
700 #[test]
701 fn validate_session_exact_boundary_returns_timeout() {
702 let now = test_now();
704 let session_time = now - Duration::hours(24); let session =
706 test_session_with_time("RQ-0001", &timeutil::format_rfc3339(session_time).unwrap());
707 let queue = QueueFile {
708 version: 1,
709 tasks: vec![test_task("RQ-0001", TaskStatus::Doing)],
710 };
711
712 let result = validate_session_with_now(&session, &queue, Some(24), now);
714 assert!(
715 matches!(result, SessionValidationResult::Timeout { .. }),
716 "Session exactly at threshold should timeout"
717 );
718 }
719
720 #[test]
721 fn validate_session_future_timestamp_no_timeout() {
722 let now = test_now();
724 let session_time = now + Duration::hours(1); let session =
726 test_session_with_time("RQ-0001", &timeutil::format_rfc3339(session_time).unwrap());
727 let queue = QueueFile {
728 version: 1,
729 tasks: vec![test_task("RQ-0001", TaskStatus::Doing)],
730 };
731
732 let result = validate_session_with_now(&session, &queue, Some(1), now);
733 assert!(
734 matches!(result, SessionValidationResult::Valid(_)),
735 "Session with future timestamp should be Valid (no timeout)"
736 );
737 }
738
739 #[test]
740 fn increment_session_progress_updates_and_persists() {
741 let temp_dir = TempDir::new().unwrap();
742 let cache_dir = temp_dir.path().join("cache");
743 std::fs::create_dir_all(&cache_dir).unwrap();
744
745 let session = test_session("RQ-0001");
747 save_session(&cache_dir, &session).unwrap();
748
749 assert_eq!(session.tasks_completed_in_loop, 0);
750
751 increment_session_progress(&cache_dir).unwrap();
753 let loaded = load_session(&cache_dir).unwrap().unwrap();
754 assert_eq!(loaded.tasks_completed_in_loop, 1);
755
756 increment_session_progress(&cache_dir).unwrap();
758 let loaded = load_session(&cache_dir).unwrap().unwrap();
759 assert_eq!(loaded.tasks_completed_in_loop, 2);
760 }
761
762 #[test]
763 fn increment_session_progress_handles_missing_session() {
764 let temp_dir = TempDir::new().unwrap();
765 let cache_dir = temp_dir.path().join("cache");
766 std::fs::create_dir_all(&cache_dir).unwrap();
767
768 increment_session_progress(&cache_dir).unwrap();
770 }
771}