1use crate::claude::{
2 extract_decisions, extract_files_context, extract_patterns, ClaudeUsage, FileContextEntry,
3};
4use crate::config::{self, Config};
5use crate::error::Result;
6use crate::git;
7use crate::knowledge::{Decision, FileChange, FileInfo, Pattern, ProjectKnowledge, StoryChanges};
8use crate::worktree::{get_current_session_id, MAIN_SESSION_ID};
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12use std::fs;
13use std::path::PathBuf;
14use uuid::Uuid;
15
16const STATE_FILE: &str = "state.json";
17const METADATA_FILE: &str = "metadata.json";
18const LIVE_FILE: &str = "live.json";
19const SESSIONS_DIR: &str = "sessions";
20const RUNS_DIR: &str = "runs";
21const SPEC_DIR: &str = "spec";
22
23const LIVE_STATE_MAX_LINES: usize = 50;
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
31#[serde(rename_all = "camelCase")]
32pub struct SessionMetadata {
33 pub session_id: String,
35 pub worktree_path: PathBuf,
37 pub branch_name: String,
39 pub created_at: DateTime<Utc>,
41 pub last_active_at: DateTime<Utc>,
43 #[serde(default)]
46 pub is_running: bool,
47 #[serde(default)]
50 pub spec_json_path: Option<PathBuf>,
51}
52
53#[derive(Debug, Clone)]
58pub struct SessionStatus {
59 pub metadata: SessionMetadata,
61 pub machine_state: Option<MachineState>,
63 pub current_story: Option<String>,
65 pub is_current: bool,
67 pub is_stale: bool,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
81#[serde(rename_all = "camelCase")]
82pub struct LiveState {
83 pub output_lines: Vec<String>,
85 pub updated_at: DateTime<Utc>,
87 pub machine_state: MachineState,
89 #[serde(default = "Utc::now")]
93 pub last_heartbeat: DateTime<Utc>,
94}
95
96pub const HEARTBEAT_STALE_THRESHOLD_SECS: i64 = 60;
101
102impl LiveState {
103 pub fn new(machine_state: MachineState) -> Self {
105 let now = Utc::now();
106 Self {
107 output_lines: Vec::new(),
108 updated_at: now,
109 machine_state,
110 last_heartbeat: now,
111 }
112 }
113
114 pub fn append_line(&mut self, line: String) {
117 self.output_lines.push(line);
118 if self.output_lines.len() > LIVE_STATE_MAX_LINES {
120 let excess = self.output_lines.len() - LIVE_STATE_MAX_LINES;
121 self.output_lines.drain(0..excess);
122 }
123 self.updated_at = Utc::now();
124 }
125
126 pub fn update_heartbeat(&mut self) {
129 self.last_heartbeat = Utc::now();
130 }
131
132 pub fn update_state(&mut self, new_state: MachineState) {
135 self.machine_state = new_state;
136 let now = Utc::now();
137 self.updated_at = now;
138 self.last_heartbeat = now;
139 }
140
141 pub fn is_heartbeat_fresh(&self) -> bool {
144 let age = Utc::now()
145 .signed_duration_since(self.last_heartbeat)
146 .num_seconds();
147 age < HEARTBEAT_STALE_THRESHOLD_SECS
148 }
149}
150
151#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
152#[serde(rename_all = "lowercase")]
153pub enum RunStatus {
154 Running,
155 Completed,
156 Failed,
157 Interrupted,
158}
159
160#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
161#[serde(rename_all = "kebab-case")]
162pub enum MachineState {
163 Idle,
164 LoadingSpec,
165 GeneratingSpec,
166 Initializing,
167 PickingStory,
168 RunningClaude,
169 Reviewing,
170 Correcting,
171 Committing,
172 #[serde(rename = "creating-pr")]
173 CreatingPR,
174 Completed,
175 Failed,
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct IterationRecord {
180 pub number: u32,
181 pub story_id: String,
182 pub started_at: DateTime<Utc>,
183 pub finished_at: Option<DateTime<Utc>>,
184 pub status: IterationStatus,
185 pub output_snippet: String,
186 #[serde(default)]
188 pub work_summary: Option<String>,
189 #[serde(default)]
191 pub usage: Option<ClaudeUsage>,
192}
193
194#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
195#[serde(rename_all = "lowercase")]
196pub enum IterationStatus {
197 Running,
198 Success,
199 Failed,
200}
201
202#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct RunState {
204 pub run_id: String,
205 pub status: RunStatus,
206 pub machine_state: MachineState,
207 pub spec_json_path: PathBuf,
208 #[serde(default)]
209 pub spec_md_path: Option<PathBuf>,
210 pub branch: String,
211 pub current_story: Option<String>,
212 pub iteration: u32,
213 #[serde(default)]
215 pub review_iteration: u32,
216 pub started_at: DateTime<Utc>,
217 pub finished_at: Option<DateTime<Utc>>,
218 pub iterations: Vec<IterationRecord>,
219 #[serde(default)]
222 pub config: Option<Config>,
223 #[serde(default)]
226 pub knowledge: ProjectKnowledge,
227 #[serde(default)]
230 pub pre_story_commit: Option<String>,
231 #[serde(default)]
234 pub session_id: Option<String>,
235 #[serde(default)]
237 pub total_usage: Option<ClaudeUsage>,
238 #[serde(default)]
245 pub phase_usage: HashMap<String, ClaudeUsage>,
246}
247
248impl RunState {
249 pub fn new(spec_json_path: PathBuf, branch: String) -> Self {
250 Self {
251 run_id: Uuid::new_v4().to_string(),
252 status: RunStatus::Running,
253 machine_state: MachineState::Initializing,
254 spec_json_path,
255 spec_md_path: None,
256 branch,
257 current_story: None,
258 iteration: 0,
259 review_iteration: 0,
260 started_at: Utc::now(),
261 finished_at: None,
262 iterations: Vec::new(),
263 config: None,
264 knowledge: ProjectKnowledge::default(),
265 pre_story_commit: None,
266 session_id: None,
267 total_usage: None,
268 phase_usage: HashMap::new(),
269 }
270 }
271
272 pub fn new_with_config(spec_json_path: PathBuf, branch: String, config: Config) -> Self {
274 Self {
275 run_id: Uuid::new_v4().to_string(),
276 status: RunStatus::Running,
277 machine_state: MachineState::Initializing,
278 spec_json_path,
279 spec_md_path: None,
280 branch,
281 current_story: None,
282 iteration: 0,
283 review_iteration: 0,
284 started_at: Utc::now(),
285 finished_at: None,
286 iterations: Vec::new(),
287 config: Some(config),
288 knowledge: ProjectKnowledge::default(),
289 pre_story_commit: None,
290 session_id: None,
291 total_usage: None,
292 phase_usage: HashMap::new(),
293 }
294 }
295
296 pub fn new_with_session(spec_json_path: PathBuf, branch: String, session_id: String) -> Self {
298 Self {
299 run_id: Uuid::new_v4().to_string(),
300 status: RunStatus::Running,
301 machine_state: MachineState::Initializing,
302 spec_json_path,
303 spec_md_path: None,
304 branch,
305 current_story: None,
306 iteration: 0,
307 review_iteration: 0,
308 started_at: Utc::now(),
309 finished_at: None,
310 iterations: Vec::new(),
311 config: None,
312 knowledge: ProjectKnowledge::default(),
313 pre_story_commit: None,
314 session_id: Some(session_id),
315 total_usage: None,
316 phase_usage: HashMap::new(),
317 }
318 }
319
320 pub fn new_with_config_and_session(
322 spec_json_path: PathBuf,
323 branch: String,
324 config: Config,
325 session_id: String,
326 ) -> Self {
327 Self {
328 run_id: Uuid::new_v4().to_string(),
329 status: RunStatus::Running,
330 machine_state: MachineState::Initializing,
331 spec_json_path,
332 spec_md_path: None,
333 branch,
334 current_story: None,
335 iteration: 0,
336 review_iteration: 0,
337 started_at: Utc::now(),
338 finished_at: None,
339 iterations: Vec::new(),
340 config: Some(config),
341 knowledge: ProjectKnowledge::default(),
342 pre_story_commit: None,
343 session_id: Some(session_id),
344 total_usage: None,
345 phase_usage: HashMap::new(),
346 }
347 }
348
349 pub fn from_spec(spec_md_path: PathBuf, spec_json_path: PathBuf) -> Self {
350 Self {
351 run_id: Uuid::new_v4().to_string(),
352 status: RunStatus::Running,
353 machine_state: MachineState::LoadingSpec,
354 spec_json_path,
355 spec_md_path: Some(spec_md_path),
356 branch: String::new(), current_story: None,
358 iteration: 0,
359 review_iteration: 0,
360 started_at: Utc::now(),
361 finished_at: None,
362 iterations: Vec::new(),
363 config: None,
364 knowledge: ProjectKnowledge::default(),
365 pre_story_commit: None,
366 session_id: None,
367 total_usage: None,
368 phase_usage: HashMap::new(),
369 }
370 }
371
372 pub fn from_spec_with_config(
374 spec_md_path: PathBuf,
375 spec_json_path: PathBuf,
376 config: Config,
377 ) -> Self {
378 Self {
379 run_id: Uuid::new_v4().to_string(),
380 status: RunStatus::Running,
381 machine_state: MachineState::LoadingSpec,
382 spec_json_path,
383 spec_md_path: Some(spec_md_path),
384 branch: String::new(), current_story: None,
386 iteration: 0,
387 review_iteration: 0,
388 started_at: Utc::now(),
389 finished_at: None,
390 iterations: Vec::new(),
391 config: Some(config),
392 knowledge: ProjectKnowledge::default(),
393 pre_story_commit: None,
394 session_id: None,
395 total_usage: None,
396 phase_usage: HashMap::new(),
397 }
398 }
399
400 pub fn from_spec_with_config_and_session(
402 spec_md_path: PathBuf,
403 spec_json_path: PathBuf,
404 config: Config,
405 session_id: String,
406 ) -> Self {
407 Self {
408 run_id: Uuid::new_v4().to_string(),
409 status: RunStatus::Running,
410 machine_state: MachineState::LoadingSpec,
411 spec_json_path,
412 spec_md_path: Some(spec_md_path),
413 branch: String::new(), current_story: None,
415 iteration: 0,
416 review_iteration: 0,
417 started_at: Utc::now(),
418 finished_at: None,
419 iterations: Vec::new(),
420 config: Some(config),
421 knowledge: ProjectKnowledge::default(),
422 pre_story_commit: None,
423 session_id: Some(session_id),
424 total_usage: None,
425 phase_usage: HashMap::new(),
426 }
427 }
428
429 pub fn effective_config(&self) -> Config {
432 self.config.clone().unwrap_or_default()
433 }
434
435 pub fn transition_to(&mut self, state: MachineState) {
436 self.machine_state = state;
437 match state {
438 MachineState::Completed => {
439 self.status = RunStatus::Completed;
440 self.finished_at = Some(Utc::now());
441 }
442 MachineState::Failed => {
443 self.status = RunStatus::Failed;
444 self.finished_at = Some(Utc::now());
445 }
446 _ => {}
447 }
448 }
449
450 pub fn start_iteration(&mut self, story_id: &str) {
451 self.iteration += 1;
452 self.current_story = Some(story_id.to_string());
453 self.machine_state = MachineState::RunningClaude;
454
455 self.iterations.push(IterationRecord {
456 number: self.iteration,
457 story_id: story_id.to_string(),
458 started_at: Utc::now(),
459 finished_at: None,
460 status: IterationStatus::Running,
461 output_snippet: String::new(),
462 work_summary: None,
463 usage: None,
464 });
465 }
466
467 pub fn finish_iteration(&mut self, status: IterationStatus, output_snippet: String) {
468 if let Some(iter) = self.iterations.last_mut() {
469 iter.finished_at = Some(Utc::now());
470 iter.status = status;
471 iter.output_snippet = output_snippet;
472 }
473 self.machine_state = MachineState::PickingStory;
474 }
475
476 pub fn set_work_summary(&mut self, summary: Option<String>) {
478 if let Some(iter) = self.iterations.last_mut() {
479 iter.work_summary = summary;
480 }
481 }
482
483 pub fn current_iteration_duration(&self) -> u64 {
484 if let Some(iter) = self.iterations.last() {
485 let end = iter.finished_at.unwrap_or_else(Utc::now);
486 (end - iter.started_at).num_seconds().max(0) as u64
487 } else {
488 0
489 }
490 }
491
492 pub fn run_duration_secs(&self) -> u64 {
496 let end = self.finished_at.unwrap_or_else(Utc::now);
497 (end - self.started_at).num_seconds().max(0) as u64
498 }
499
500 pub fn capture_pre_story_state(&mut self) {
509 if git::is_git_repo() {
510 if let Ok(head) = git::get_head_commit() {
511 if self.knowledge.baseline_commit.is_none() {
513 self.knowledge.baseline_commit = Some(head.clone());
514 }
515 self.pre_story_commit = Some(head);
516 }
517 }
518 }
519
520 pub fn record_story_changes(&mut self, story_id: &str, commit_hash: Option<String>) {
534 let mut files_created = Vec::new();
535 let mut files_modified = Vec::new();
536 let mut files_deleted = Vec::new();
537
538 if let Some(ref base_commit) = self.pre_story_commit {
540 if git::is_git_repo() {
541 if let Ok(entries) = git::get_diff_since(base_commit) {
542 for entry in entries {
543 let file_change = FileChange {
544 path: entry.path.clone(),
545 additions: entry.additions,
546 deletions: entry.deletions,
547 purpose: None,
548 key_symbols: Vec::new(),
549 };
550
551 match entry.status {
552 git::DiffStatus::Added => files_created.push(file_change),
553 git::DiffStatus::Modified => files_modified.push(file_change),
554 git::DiffStatus::Deleted => files_deleted.push(entry.path),
555 }
556 }
557 }
558 }
559 }
560
561 let story_changes = StoryChanges {
562 story_id: story_id.to_string(),
563 files_created,
564 files_modified,
565 files_deleted,
566 commit_hash,
567 };
568
569 self.knowledge.story_changes.push(story_changes);
570
571 self.pre_story_commit = None;
573 }
574
575 pub fn capture_story_knowledge(
596 &mut self,
597 story_id: &str,
598 agent_output: &str,
599 commit_hash: Option<String>,
600 ) {
601 let files_context = extract_files_context(agent_output);
603 let agent_decisions = extract_decisions(agent_output);
604 let agent_patterns = extract_patterns(agent_output);
605
606 let context_by_path: std::collections::HashMap<PathBuf, &FileContextEntry> = files_context
608 .iter()
609 .map(|fc| (fc.path.clone(), fc))
610 .collect();
611
612 let mut files_created = Vec::new();
613 let mut files_modified = Vec::new();
614 let mut files_deleted: Vec<PathBuf> = Vec::new();
615
616 if let Some(ref base_commit) = self.pre_story_commit {
618 if git::is_git_repo() {
619 if let Ok(all_entries) = git::get_diff_since(base_commit) {
620 let entries = self.knowledge.filter_our_changes(&all_entries);
622
623 for entry in entries {
624 let (purpose, key_symbols) = context_by_path
626 .get(&entry.path)
627 .map(|fc| (Some(fc.purpose.clone()), fc.key_symbols.clone()))
628 .unwrap_or((None, Vec::new()));
629
630 let file_change = FileChange {
631 path: entry.path.clone(),
632 additions: entry.additions,
633 deletions: entry.deletions,
634 purpose,
635 key_symbols,
636 };
637
638 match entry.status {
639 git::DiffStatus::Added => files_created.push(file_change),
640 git::DiffStatus::Modified => files_modified.push(file_change),
641 git::DiffStatus::Deleted => files_deleted.push(entry.path),
642 }
643 }
644 }
645 }
646 }
647
648 if files_created.is_empty() && files_modified.is_empty() && files_deleted.is_empty() {
650 for fc in &files_context {
652 files_modified.push(FileChange {
655 path: fc.path.clone(),
656 additions: 0,
657 deletions: 0,
658 purpose: Some(fc.purpose.clone()),
659 key_symbols: fc.key_symbols.clone(),
660 });
661 }
662 }
663
664 let story_changes = StoryChanges {
666 story_id: story_id.to_string(),
667 files_created: files_created.clone(),
668 files_modified: files_modified.clone(),
669 files_deleted: files_deleted.clone(),
670 commit_hash,
671 };
672 self.knowledge.story_changes.push(story_changes);
673
674 for change in files_created.iter().chain(files_modified.iter()) {
676 let file_info = self
677 .knowledge
678 .files
679 .entry(change.path.clone())
680 .or_insert_with(|| FileInfo {
681 purpose: change.purpose.clone().unwrap_or_default(),
682 key_symbols: Vec::new(),
683 touched_by: Vec::new(),
684 line_count: 0,
685 });
686
687 if let Some(ref purpose) = change.purpose {
689 file_info.purpose = purpose.clone();
690 }
691
692 for symbol in &change.key_symbols {
694 if !file_info.key_symbols.contains(symbol) {
695 file_info.key_symbols.push(symbol.clone());
696 }
697 }
698
699 if !file_info.touched_by.contains(&story_id.to_string()) {
701 file_info.touched_by.push(story_id.to_string());
702 }
703
704 if change.additions > 0 {
706 file_info.line_count = file_info.line_count.saturating_add(change.additions);
707 file_info.line_count = file_info.line_count.saturating_sub(change.deletions);
708 }
709 }
710
711 for deleted_path in &files_deleted {
713 self.knowledge.files.remove(deleted_path);
714 }
715
716 for agent_decision in agent_decisions {
718 self.knowledge.decisions.push(Decision {
719 story_id: story_id.to_string(),
720 topic: agent_decision.topic,
721 choice: agent_decision.choice,
722 rationale: agent_decision.rationale,
723 });
724 }
725
726 for agent_pattern in agent_patterns {
728 self.knowledge.patterns.push(Pattern {
729 story_id: story_id.to_string(),
730 description: agent_pattern.description,
731 example_file: None, });
733 }
734
735 self.pre_story_commit = None;
737 }
738
739 pub fn capture_usage(&mut self, phase_key: &str, usage: Option<ClaudeUsage>) {
751 if let Some(usage) = usage {
752 self.phase_usage
754 .entry(phase_key.to_string())
755 .and_modify(|existing| existing.add(&usage))
756 .or_insert(usage.clone());
757
758 match &mut self.total_usage {
760 Some(existing) => existing.add(&usage),
761 None => self.total_usage = Some(usage),
762 }
763 }
764 }
765
766 pub fn set_iteration_usage(&mut self, usage: Option<ClaudeUsage>) {
771 if let Some(iter) = self.iterations.last_mut() {
772 iter.usage = usage;
773 }
774 }
775}
776
777pub struct StateManager {
786 base_dir: PathBuf,
788 session_id: String,
790}
791
792impl StateManager {
793 pub fn new() -> Result<Self> {
797 let base_dir = config::project_config_dir()?;
798 let session_id = get_current_session_id()?;
799 let mut manager = Self {
800 base_dir,
801 session_id,
802 };
803 manager.migrate_legacy_state()?;
804 Ok(manager)
805 }
806
807 pub fn with_session(session_id: String) -> Result<Self> {
810 let base_dir = config::project_config_dir()?;
811 let mut manager = Self {
812 base_dir,
813 session_id,
814 };
815 manager.migrate_legacy_state()?;
816 Ok(manager)
817 }
818
819 pub fn for_project(project_name: &str) -> Result<Self> {
823 let base_dir = config::project_config_dir_for(project_name)?;
824 let session_id = get_current_session_id()?;
825 let mut manager = Self {
826 base_dir,
827 session_id,
828 };
829 manager.migrate_legacy_state()?;
830 Ok(manager)
831 }
832
833 pub fn for_project_session(project_name: &str, session_id: String) -> Result<Self> {
836 let base_dir = config::project_config_dir_for(project_name)?;
837 let mut manager = Self {
838 base_dir,
839 session_id,
840 };
841 manager.migrate_legacy_state()?;
842 Ok(manager)
843 }
844
845 pub fn with_dir(dir: PathBuf) -> Self {
847 Self {
848 base_dir: dir,
849 session_id: MAIN_SESSION_ID.to_string(),
850 }
851 }
852
853 pub fn with_dir_and_session(dir: PathBuf, session_id: String) -> Self {
855 Self {
856 base_dir: dir,
857 session_id,
858 }
859 }
860
861 pub fn session_id(&self) -> &str {
863 &self.session_id
864 }
865
866 fn sessions_dir(&self) -> PathBuf {
868 self.base_dir.join(SESSIONS_DIR)
869 }
870
871 fn session_dir(&self) -> PathBuf {
873 self.sessions_dir().join(&self.session_id)
874 }
875
876 fn state_file(&self) -> PathBuf {
878 self.session_dir().join(STATE_FILE)
879 }
880
881 fn metadata_file(&self) -> PathBuf {
883 self.session_dir().join(METADATA_FILE)
884 }
885
886 fn live_file(&self) -> PathBuf {
888 self.session_dir().join(LIVE_FILE)
889 }
890
891 fn legacy_state_file(&self) -> PathBuf {
893 self.base_dir.join(STATE_FILE)
894 }
895
896 pub fn runs_dir(&self) -> PathBuf {
898 self.base_dir.join(RUNS_DIR)
899 }
900
901 pub fn spec_dir(&self) -> PathBuf {
903 self.base_dir.join(SPEC_DIR)
904 }
905
906 fn migrate_legacy_state(&mut self) -> Result<()> {
911 let legacy_path = self.legacy_state_file();
912
913 if !legacy_path.exists() {
915 return Ok(());
916 }
917
918 let main_session_dir = self.sessions_dir().join(MAIN_SESSION_ID);
919 let main_state_file = main_session_dir.join(STATE_FILE);
920
921 if main_state_file.exists() {
923 let _ = fs::remove_file(&legacy_path);
925 return Ok(());
926 }
927
928 let content = fs::read_to_string(&legacy_path)?;
930 let mut state: RunState = serde_json::from_str(&content)?;
931
932 if state.session_id.is_none() {
934 state.session_id = Some(MAIN_SESSION_ID.to_string());
935 }
936
937 fs::create_dir_all(&main_session_dir)?;
939
940 let state_content = serde_json::to_string_pretty(&state)?;
942 fs::write(&main_state_file, state_content)?;
943
944 let worktree_path = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
946 let metadata = SessionMetadata {
947 session_id: MAIN_SESSION_ID.to_string(),
948 worktree_path,
949 branch_name: state.branch.clone(),
950 created_at: state.started_at,
951 last_active_at: state.finished_at.unwrap_or_else(Utc::now),
952 is_running: state.status == RunStatus::Running,
953 spec_json_path: Some(state.spec_json_path.clone()),
954 };
955 let metadata_content = serde_json::to_string_pretty(&metadata)?;
956 fs::write(main_session_dir.join(METADATA_FILE), metadata_content)?;
957
958 fs::remove_file(&legacy_path)?;
960
961 Ok(())
962 }
963
964 pub fn ensure_dirs(&self) -> Result<()> {
965 fs::create_dir_all(&self.base_dir)?;
966 fs::create_dir_all(self.session_dir())?;
967 fs::create_dir_all(self.runs_dir())?;
968 Ok(())
969 }
970
971 pub fn ensure_spec_dir(&self) -> Result<PathBuf> {
973 let dir = self.spec_dir();
974 fs::create_dir_all(&dir)?;
975 Ok(dir)
976 }
977
978 pub fn list_specs(&self) -> Result<Vec<PathBuf>> {
980 let spec_dir = self.spec_dir();
981 if !spec_dir.exists() {
982 return Ok(Vec::new());
983 }
984
985 let mut specs: Vec<(PathBuf, std::time::SystemTime)> = Vec::new();
986 for entry in fs::read_dir(&spec_dir)? {
987 let entry = entry?;
988 let path = entry.path();
989 if path.extension().is_some_and(|e| e == "json") {
990 if let Ok(metadata) = entry.metadata() {
991 if let Ok(mtime) = metadata.modified() {
992 specs.push((path, mtime));
993 }
994 }
995 }
996 }
997
998 specs.sort_by(|a, b| b.1.cmp(&a.1));
1000 Ok(specs.into_iter().map(|(p, _)| p).collect())
1001 }
1002
1003 pub fn load_current(&self) -> Result<Option<RunState>> {
1004 let path = self.state_file();
1005 if !path.exists() {
1006 return Ok(None);
1007 }
1008 let content = fs::read_to_string(&path)?;
1009 let state: RunState = serde_json::from_str(&content)?;
1010 Ok(Some(state))
1011 }
1012
1013 pub fn load_metadata(&self) -> Result<Option<SessionMetadata>> {
1015 let path = self.metadata_file();
1016 if !path.exists() {
1017 return Ok(None);
1018 }
1019 let content = fs::read_to_string(&path)?;
1020 let metadata: SessionMetadata = serde_json::from_str(&content)?;
1021 Ok(Some(metadata))
1022 }
1023
1024 pub fn save(&self, state: &RunState) -> Result<()> {
1026 self.ensure_dirs()?;
1027
1028 let content = serde_json::to_string_pretty(state)?;
1030 fs::write(self.state_file(), content)?;
1031
1032 self.save_metadata(state)?;
1034
1035 Ok(())
1036 }
1037
1038 fn save_metadata(&self, state: &RunState) -> Result<()> {
1040 let worktree_path = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1041 let is_running = state.status == RunStatus::Running;
1042
1043 let metadata = if let Some(existing) = self.load_metadata()? {
1045 SessionMetadata {
1046 session_id: self.session_id.clone(),
1047 worktree_path,
1048 branch_name: state.branch.clone(),
1049 created_at: existing.created_at,
1050 last_active_at: Utc::now(),
1051 is_running,
1052 spec_json_path: Some(state.spec_json_path.clone()),
1053 }
1054 } else {
1055 SessionMetadata {
1056 session_id: self.session_id.clone(),
1057 worktree_path,
1058 branch_name: state.branch.clone(),
1059 created_at: state.started_at,
1060 last_active_at: Utc::now(),
1061 is_running,
1062 spec_json_path: Some(state.spec_json_path.clone()),
1063 }
1064 };
1065
1066 let content = serde_json::to_string_pretty(&metadata)?;
1067 fs::write(self.metadata_file(), content)?;
1068
1069 Ok(())
1070 }
1071
1072 pub fn clear_current(&self) -> Result<()> {
1073 let path = self.state_file();
1074 if path.exists() {
1075 fs::remove_file(path)?;
1076 }
1077 let metadata_path = self.metadata_file();
1079 if metadata_path.exists() {
1080 fs::remove_file(metadata_path)?;
1081 }
1082 self.clear_live()?;
1084 let session_dir = self.session_dir();
1086 let _ = fs::remove_dir(&session_dir); Ok(())
1088 }
1089
1090 pub fn save_live(&self, live_state: &LiveState) -> Result<()> {
1094 self.ensure_dirs()?;
1095
1096 let live_path = self.live_file();
1097 let temp_path = live_path.with_extension("json.tmp");
1098
1099 let content = serde_json::to_string(live_state)?;
1101 fs::write(&temp_path, content)?;
1102
1103 fs::rename(&temp_path, &live_path)?;
1105
1106 Ok(())
1107 }
1108
1109 pub fn load_live(&self) -> Option<LiveState> {
1114 let path = self.live_file();
1115 if !path.exists() {
1116 return None;
1117 }
1118
1119 let content = fs::read_to_string(&path).ok()?;
1120 serde_json::from_str(&content).ok()
1121 }
1122
1123 pub fn clear_live(&self) -> Result<()> {
1125 let path = self.live_file();
1126 if path.exists() {
1127 fs::remove_file(path)?;
1128 }
1129 Ok(())
1130 }
1131
1132 pub fn archive(&self, state: &RunState) -> Result<PathBuf> {
1133 self.ensure_dirs()?;
1134 let filename = format!(
1135 "{}_{}.json",
1136 state.started_at.format("%Y%m%d_%H%M%S"),
1137 &state.run_id[..8]
1138 );
1139 let archive_path = self.runs_dir().join(filename);
1140 let content = serde_json::to_string_pretty(state)?;
1141 fs::write(&archive_path, content)?;
1142 Ok(archive_path)
1143 }
1144
1145 pub fn list_archived(&self) -> Result<Vec<RunState>> {
1146 let runs_dir = self.runs_dir();
1147 if !runs_dir.exists() {
1148 return Ok(Vec::new());
1149 }
1150
1151 let mut runs = Vec::new();
1152 for entry in fs::read_dir(runs_dir)? {
1153 let entry = entry?;
1154 let path = entry.path();
1155 if path.extension().is_some_and(|e| e == "json") {
1156 if let Ok(content) = fs::read_to_string(&path) {
1157 if let Ok(state) = serde_json::from_str::<RunState>(&content) {
1158 runs.push(state);
1159 }
1160 }
1161 }
1162 }
1163
1164 runs.sort_by(|a, b| b.started_at.cmp(&a.started_at));
1165 Ok(runs)
1166 }
1167
1168 pub fn has_active_run(&self) -> Result<bool> {
1169 if let Some(state) = self.load_current()? {
1170 Ok(state.status == RunStatus::Running)
1171 } else {
1172 Ok(false)
1173 }
1174 }
1175
1176 pub fn list_sessions(&self) -> Result<Vec<SessionMetadata>> {
1181 let sessions_dir = self.sessions_dir();
1182 if !sessions_dir.exists() {
1183 return Ok(Vec::new());
1184 }
1185
1186 let mut sessions = Vec::new();
1187 for entry in fs::read_dir(&sessions_dir)? {
1188 let entry = entry?;
1189 let path = entry.path();
1190 if path.is_dir() {
1191 let metadata_path = path.join(METADATA_FILE);
1192 if let Ok(content) = fs::read_to_string(&metadata_path) {
1193 if let Ok(metadata) = serde_json::from_str::<SessionMetadata>(&content) {
1194 sessions.push(metadata);
1195 }
1196 }
1197 }
1198 }
1199
1200 sessions.sort_by(|a, b| b.last_active_at.cmp(&a.last_active_at));
1202 Ok(sessions)
1203 }
1204
1205 pub fn get_session(&self, session_id: &str) -> Option<StateManager> {
1210 let session_dir = self.sessions_dir().join(session_id);
1211 if session_dir.exists() && session_dir.join(STATE_FILE).exists() {
1212 Some(StateManager {
1213 base_dir: self.base_dir.clone(),
1214 session_id: session_id.to_string(),
1215 })
1216 } else {
1217 None
1218 }
1219 }
1220
1221 pub fn list_sessions_with_status(&self) -> Result<Vec<SessionStatus>> {
1227 let sessions = self.list_sessions()?;
1228 let current_dir = std::env::current_dir().ok();
1229
1230 let mut statuses: Vec<SessionStatus> = sessions
1231 .into_iter()
1232 .map(|metadata| {
1233 let is_current = current_dir
1235 .as_ref()
1236 .map(|cwd| cwd == &metadata.worktree_path)
1237 .unwrap_or(false);
1238
1239 let is_stale = !metadata.worktree_path.exists();
1241
1242 let (machine_state, current_story) =
1244 if let Some(session_sm) = self.get_session(&metadata.session_id) {
1245 if let Ok(Some(state)) = session_sm.load_current() {
1246 (Some(state.machine_state), state.current_story)
1247 } else {
1248 (None, None)
1249 }
1250 } else {
1251 (None, None)
1252 };
1253
1254 SessionStatus {
1255 metadata,
1256 machine_state,
1257 current_story,
1258 is_current,
1259 is_stale,
1260 }
1261 })
1262 .collect();
1263
1264 statuses.sort_by(|a, b| {
1266 match (a.is_current, b.is_current) {
1268 (true, false) => std::cmp::Ordering::Less,
1269 (false, true) => std::cmp::Ordering::Greater,
1270 _ => b.metadata.last_active_at.cmp(&a.metadata.last_active_at),
1271 }
1272 });
1273
1274 Ok(statuses)
1275 }
1276
1277 pub fn find_session_for_branch(&self, branch: &str) -> Result<Option<SessionMetadata>> {
1294 let sessions = self.list_sessions()?;
1295
1296 for session in sessions {
1299 if session.branch_name == branch {
1300 return Ok(Some(session));
1301 }
1302 }
1303
1304 Ok(None)
1305 }
1306
1307 pub fn check_branch_conflict(&self, branch_name: &str) -> Result<Option<SessionMetadata>> {
1324 let sessions = self.list_sessions()?;
1325
1326 for session in sessions {
1327 if session.session_id == self.session_id {
1329 continue;
1330 }
1331
1332 if session.branch_name != branch_name {
1334 continue;
1335 }
1336
1337 if !session.is_running {
1339 continue;
1340 }
1341
1342 if !session.worktree_path.exists() {
1344 continue;
1347 }
1348
1349 return Ok(Some(session));
1351 }
1352
1353 Ok(None)
1354 }
1355}
1356
1357#[cfg(test)]
1358mod tests {
1359 use super::*;
1360 use tempfile::TempDir;
1361
1362 #[test]
1367 fn test_run_state_creation_and_defaults() {
1368 let state = RunState::new(PathBuf::from("test.json"), "test-branch".to_string());
1369 assert_eq!(state.branch, "test-branch");
1370 assert_eq!(state.review_iteration, 0);
1371 assert_eq!(state.machine_state, MachineState::Initializing);
1372 assert_eq!(state.status, RunStatus::Running);
1373 assert!(state.config.is_none());
1374 assert!(state.session_id.is_none());
1375
1376 let state_with_config = RunState::new_with_config(
1377 PathBuf::from("test.json"),
1378 "test-branch".to_string(),
1379 crate::config::Config::default(),
1380 );
1381 assert!(state_with_config.config.is_some());
1382 }
1383
1384 #[test]
1385 fn test_state_transitions() {
1386 let mut state = RunState::new(PathBuf::from("test.json"), "test-branch".to_string());
1387
1388 state.transition_to(MachineState::PickingStory);
1390 assert_eq!(state.machine_state, MachineState::PickingStory);
1391 assert_eq!(state.status, RunStatus::Running);
1392
1393 state.transition_to(MachineState::RunningClaude);
1394 state.transition_to(MachineState::Reviewing);
1395 state.transition_to(MachineState::Correcting);
1396 state.transition_to(MachineState::Reviewing);
1397 state.review_iteration = 2;
1398 assert_eq!(state.review_iteration, 2);
1399
1400 state.transition_to(MachineState::Committing);
1401 state.transition_to(MachineState::CreatingPR);
1402 assert_eq!(state.status, RunStatus::Running);
1403
1404 state.transition_to(MachineState::Completed);
1405 assert_eq!(state.status, RunStatus::Completed);
1406
1407 let mut failed = RunState::new(PathBuf::from("test.json"), "branch".to_string());
1409 failed.transition_to(MachineState::Failed);
1410 assert_eq!(failed.status, RunStatus::Failed);
1411 }
1412
1413 #[test]
1418 fn test_serialization_roundtrip() {
1419 for (state, expected) in [
1421 (MachineState::Idle, "\"idle\""),
1422 (MachineState::RunningClaude, "\"running-claude\""),
1423 (MachineState::CreatingPR, "\"creating-pr\""),
1424 ] {
1425 let json = serde_json::to_string(&state).unwrap();
1426 assert_eq!(json, expected);
1427 assert_eq!(serde_json::from_str::<MachineState>(&json).unwrap(), state);
1428 }
1429
1430 for (status, expected) in [
1432 (RunStatus::Running, "\"running\""),
1433 (RunStatus::Completed, "\"completed\""),
1434 ] {
1435 let json = serde_json::to_string(&status).unwrap();
1436 assert_eq!(json, expected);
1437 assert_eq!(serde_json::from_str::<RunStatus>(&json).unwrap(), status);
1438 }
1439
1440 let mut state = RunState::new(PathBuf::from("test.json"), "test-branch".to_string());
1442 state.start_iteration("US-001");
1443 state.set_work_summary(Some("Summary".to_string()));
1444 let json = serde_json::to_string(&state).unwrap();
1445 let back: RunState = serde_json::from_str(&json).unwrap();
1446 assert_eq!(back.branch, state.branch);
1447 }
1448
1449 #[test]
1450 fn test_backwards_compatibility() {
1451 let legacy = r#"{"number":1,"story_id":"US-001","started_at":"2024-01-01T00:00:00Z","finished_at":null,"status":"running","output_snippet":""}"#;
1452 let record: IterationRecord = serde_json::from_str(legacy).unwrap();
1453 assert!(record.work_summary.is_none());
1454 }
1455
1456 #[test]
1461 fn test_iteration_management() {
1462 let mut state = RunState::new(PathBuf::from("test.json"), "test-branch".to_string());
1463 state.start_iteration("US-001");
1464 assert!(state.iterations[0].work_summary.is_none());
1465
1466 state.set_work_summary(Some("Feature".to_string()));
1467 assert_eq!(
1468 state.iterations[0].work_summary,
1469 Some("Feature".to_string())
1470 );
1471
1472 state.set_work_summary(None);
1473 assert!(state.iterations[0].work_summary.is_none());
1474
1475 let mut empty = RunState::new(PathBuf::from("test.json"), "branch".to_string());
1477 empty.set_work_summary(Some("Safe".to_string()));
1478 }
1479
1480 #[test]
1485 fn test_state_manager_save_load_clear() {
1486 let temp_dir = TempDir::new().unwrap();
1487 let sm = StateManager::with_dir(temp_dir.path().to_path_buf());
1488
1489 let state = RunState::new(PathBuf::from("test.json"), "test-branch".to_string());
1490 sm.save(&state).unwrap();
1491
1492 assert!(temp_dir
1493 .path()
1494 .join(SESSIONS_DIR)
1495 .join(MAIN_SESSION_ID)
1496 .join(STATE_FILE)
1497 .exists());
1498
1499 let loaded = sm.load_current().unwrap().unwrap();
1500 assert_eq!(loaded.branch, "test-branch");
1501
1502 sm.clear_current().unwrap();
1503 assert!(sm.load_current().unwrap().is_none());
1504 }
1505
1506 #[test]
1507 fn test_state_manager_archive() {
1508 let temp_dir = TempDir::new().unwrap();
1509 let sm = StateManager::with_dir(temp_dir.path().to_path_buf());
1510
1511 let state = RunState::new(PathBuf::from("test.json"), "feature/test".to_string());
1512 sm.save(&state).unwrap();
1513
1514 let archive_path = sm.archive(&state).unwrap();
1515 assert!(archive_path.exists());
1516
1517 let archived = sm.list_archived().unwrap();
1518 assert_eq!(archived.len(), 1);
1519 }
1520
1521 #[test]
1522 fn test_state_manager_directory_structure() {
1523 let temp_dir = TempDir::new().unwrap();
1524 let sm = StateManager::with_dir(temp_dir.path().to_path_buf());
1525 sm.ensure_dirs().unwrap();
1526
1527 assert!(temp_dir.path().join(RUNS_DIR).is_dir());
1528 assert!(temp_dir.path().join(SESSIONS_DIR).is_dir());
1529 }
1531
1532 #[test]
1537 fn test_session_isolation() {
1538 let temp_dir = TempDir::new().unwrap();
1539
1540 let sm1 = StateManager::with_dir_and_session(
1541 temp_dir.path().to_path_buf(),
1542 "session-a".to_string(),
1543 );
1544 let sm2 = StateManager::with_dir_and_session(
1545 temp_dir.path().to_path_buf(),
1546 "session-b".to_string(),
1547 );
1548
1549 sm1.save(&RunState::new(
1550 PathBuf::from("a.json"),
1551 "branch-a".to_string(),
1552 ))
1553 .unwrap();
1554 sm2.save(&RunState::new(
1555 PathBuf::from("b.json"),
1556 "branch-b".to_string(),
1557 ))
1558 .unwrap();
1559
1560 assert_eq!(sm1.load_current().unwrap().unwrap().branch, "branch-a");
1561 assert_eq!(sm2.load_current().unwrap().unwrap().branch, "branch-b");
1562 assert_eq!(sm1.list_sessions().unwrap().len(), 2);
1563 }
1564
1565 #[test]
1566 fn test_session_metadata() {
1567 let temp_dir = TempDir::new().unwrap();
1568 let sm = StateManager::with_dir(temp_dir.path().to_path_buf());
1569
1570 assert!(sm.load_metadata().unwrap().is_none());
1571
1572 let state = RunState::new(PathBuf::from("test.json"), "test-branch".to_string());
1573 sm.save(&state).unwrap();
1574
1575 let metadata = sm.load_metadata().unwrap().unwrap();
1576 assert!(metadata.is_running);
1577
1578 let mut completed = sm.load_current().unwrap().unwrap();
1579 completed.transition_to(MachineState::Completed);
1580 sm.save(&completed).unwrap();
1581 assert!(!sm.load_metadata().unwrap().unwrap().is_running);
1582 }
1583
1584 #[test]
1589 fn test_live_state() {
1590 let mut live = LiveState::new(MachineState::RunningClaude);
1591 assert!(live.is_heartbeat_fresh());
1592
1593 for i in 0..60 {
1594 live.append_line(format!("line {}", i));
1595 }
1596 assert_eq!(live.output_lines.len(), 50); live.last_heartbeat = chrono::Utc::now() - chrono::Duration::seconds(65);
1599 assert!(!live.is_heartbeat_fresh());
1600 }
1601
1602 #[test]
1603 fn test_live_state_persistence() {
1604 let temp_dir = TempDir::new().unwrap();
1605 let sm = StateManager::with_dir(temp_dir.path().to_path_buf());
1606
1607 assert!(sm.load_live().is_none());
1608
1609 let mut live = LiveState::new(MachineState::RunningClaude);
1610 live.append_line("output".to_string());
1611 sm.save_live(&live).unwrap();
1612
1613 assert!(sm.load_live().is_some());
1614 sm.clear_live().unwrap();
1615 assert!(sm.load_live().is_none());
1616 }
1617
1618 #[test]
1623 fn test_config_preservation() {
1624 let temp_dir = TempDir::new().unwrap();
1625 let sm = StateManager::with_dir(temp_dir.path().to_path_buf());
1626
1627 let mut config = crate::config::Config::default();
1628 config.review = false;
1629 let state =
1630 RunState::new_with_config(PathBuf::from("test.json"), "branch".to_string(), config);
1631 sm.save(&state).unwrap();
1632
1633 assert!(
1634 !sm.load_current()
1635 .unwrap()
1636 .unwrap()
1637 .effective_config()
1638 .review
1639 );
1640 }
1641
1642 #[test]
1643 fn test_knowledge_tracking() {
1644 let mut state = RunState::new(PathBuf::from("test.json"), "branch".to_string());
1645 state.knowledge.story_changes.push(StoryChanges {
1646 story_id: "US-001".to_string(),
1647 files_created: vec![],
1648 files_modified: vec![FileChange {
1649 path: PathBuf::from("src/main.rs"),
1650 additions: 10,
1651 deletions: 2,
1652 purpose: Some("Main entry point".to_string()),
1653 key_symbols: vec![],
1654 }],
1655 files_deleted: vec![],
1656 commit_hash: None,
1657 });
1658 assert!(state
1659 .knowledge
1660 .story_changes
1661 .iter()
1662 .any(|c| c.story_id == "US-001"));
1663 }
1664
1665 #[test]
1668 fn test_iteration_record_usage_initialized_as_none() {
1669 let mut state = RunState::new(PathBuf::from("test.json"), "test-branch".to_string());
1670 state.start_iteration("US-001");
1671 assert!(state.iterations[0].usage.is_none());
1672 }
1673
1674 #[test]
1675 fn test_iteration_record_usage_can_be_set() {
1676 let mut state = RunState::new(PathBuf::from("test.json"), "test-branch".to_string());
1677 state.start_iteration("US-001");
1678 state.iterations[0].usage = Some(ClaudeUsage {
1679 input_tokens: 1000,
1680 output_tokens: 500,
1681 cache_read_tokens: 200,
1682 cache_creation_tokens: 100,
1683 thinking_tokens: 50,
1684 model: Some("claude-sonnet-4-20250514".to_string()),
1685 });
1686 assert!(state.iterations[0].usage.is_some());
1687 assert_eq!(
1688 state.iterations[0].usage.as_ref().unwrap().input_tokens,
1689 1000
1690 );
1691 }
1692
1693 #[test]
1694 fn test_iteration_record_backwards_compatible_without_usage() {
1695 let legacy_json = r#"{
1697 "number": 1,
1698 "story_id": "US-001",
1699 "started_at": "2024-01-01T00:00:00Z",
1700 "finished_at": null,
1701 "status": "running",
1702 "output_snippet": ""
1703 }"#;
1704
1705 let record: IterationRecord = serde_json::from_str(legacy_json).unwrap();
1706 assert!(record.usage.is_none());
1707 assert_eq!(record.story_id, "US-001");
1708 }
1709
1710 #[test]
1711 fn test_run_state_total_usage_initialized_as_none() {
1712 let state = RunState::new(PathBuf::from("test.json"), "test-branch".to_string());
1713 assert!(state.total_usage.is_none());
1714 }
1715
1716 #[test]
1717 fn test_run_state_phase_usage_initialized_empty() {
1718 let state = RunState::new(PathBuf::from("test.json"), "test-branch".to_string());
1719 assert!(state.phase_usage.is_empty());
1720 }
1721
1722 #[test]
1723 fn test_run_state_total_usage_can_be_set() {
1724 let mut state = RunState::new(PathBuf::from("test.json"), "test-branch".to_string());
1725 state.total_usage = Some(ClaudeUsage {
1726 input_tokens: 5000,
1727 output_tokens: 2500,
1728 cache_read_tokens: 1000,
1729 cache_creation_tokens: 500,
1730 thinking_tokens: 250,
1731 model: Some("claude-sonnet-4-20250514".to_string()),
1732 });
1733 assert!(state.total_usage.is_some());
1734 assert_eq!(state.total_usage.as_ref().unwrap().total_tokens(), 7500);
1735 }
1736
1737 #[test]
1738 fn test_run_state_phase_usage_can_be_populated() {
1739 let mut state = RunState::new(PathBuf::from("test.json"), "test-branch".to_string());
1740
1741 state.phase_usage.insert(
1743 "Planning".to_string(),
1744 ClaudeUsage {
1745 input_tokens: 1000,
1746 output_tokens: 500,
1747 ..Default::default()
1748 },
1749 );
1750 state.phase_usage.insert(
1751 "US-001".to_string(),
1752 ClaudeUsage {
1753 input_tokens: 2000,
1754 output_tokens: 1000,
1755 ..Default::default()
1756 },
1757 );
1758 state.phase_usage.insert(
1759 "Final Review".to_string(),
1760 ClaudeUsage {
1761 input_tokens: 500,
1762 output_tokens: 250,
1763 ..Default::default()
1764 },
1765 );
1766 state.phase_usage.insert(
1767 "PR & Commit".to_string(),
1768 ClaudeUsage {
1769 input_tokens: 300,
1770 output_tokens: 150,
1771 ..Default::default()
1772 },
1773 );
1774
1775 assert_eq!(state.phase_usage.len(), 4);
1776 assert!(state.phase_usage.contains_key("Planning"));
1777 assert!(state.phase_usage.contains_key("US-001"));
1778 assert!(state.phase_usage.contains_key("Final Review"));
1779 assert!(state.phase_usage.contains_key("PR & Commit"));
1780 }
1781
1782 #[test]
1783 fn test_run_state_backwards_compatible_without_usage_fields() {
1784 let legacy_json = r#"{
1786 "run_id": "test-run-id",
1787 "status": "running",
1788 "machine_state": "running-claude",
1789 "spec_json_path": "test.json",
1790 "branch": "test-branch",
1791 "current_story": "US-001",
1792 "iteration": 1,
1793 "started_at": "2024-01-01T00:00:00Z",
1794 "finished_at": null,
1795 "iterations": []
1796 }"#;
1797
1798 let state: RunState = serde_json::from_str(legacy_json).unwrap();
1799 assert!(state.total_usage.is_none());
1800 assert!(state.phase_usage.is_empty());
1801 assert_eq!(state.run_id, "test-run-id");
1802 assert_eq!(state.branch, "test-branch");
1803 }
1804
1805 #[test]
1806 fn test_iteration_record_usage_serialization_roundtrip() {
1807 let mut state = RunState::new(PathBuf::from("test.json"), "test-branch".to_string());
1808 state.start_iteration("US-001");
1809 state.iterations[0].usage = Some(ClaudeUsage {
1810 input_tokens: 1000,
1811 output_tokens: 500,
1812 cache_read_tokens: 200,
1813 cache_creation_tokens: 100,
1814 thinking_tokens: 50,
1815 model: Some("claude-sonnet-4-20250514".to_string()),
1816 });
1817
1818 let json = serde_json::to_string(&state).unwrap();
1820 assert!(json.contains("\"inputTokens\":1000"));
1821 assert!(json.contains("\"outputTokens\":500"));
1822
1823 let deserialized: RunState = serde_json::from_str(&json).unwrap();
1825 assert!(deserialized.iterations[0].usage.is_some());
1826 let usage = deserialized.iterations[0].usage.as_ref().unwrap();
1827 assert_eq!(usage.input_tokens, 1000);
1828 assert_eq!(usage.output_tokens, 500);
1829 assert_eq!(usage.cache_read_tokens, 200);
1830 assert_eq!(usage.cache_creation_tokens, 100);
1831 assert_eq!(usage.thinking_tokens, 50);
1832 assert_eq!(usage.model, Some("claude-sonnet-4-20250514".to_string()));
1833 }
1834
1835 #[test]
1836 fn test_run_state_usage_serialization_roundtrip() {
1837 let mut state = RunState::new(PathBuf::from("test.json"), "test-branch".to_string());
1838 state.total_usage = Some(ClaudeUsage {
1839 input_tokens: 5000,
1840 output_tokens: 2500,
1841 ..Default::default()
1842 });
1843 state.phase_usage.insert(
1844 "US-001".to_string(),
1845 ClaudeUsage {
1846 input_tokens: 2000,
1847 output_tokens: 1000,
1848 ..Default::default()
1849 },
1850 );
1851
1852 let json = serde_json::to_string(&state).unwrap();
1854 assert!(json.contains("\"total_usage\""));
1856 assert!(json.contains("\"phase_usage\""));
1857
1858 let deserialized: RunState = serde_json::from_str(&json).unwrap();
1860 assert!(deserialized.total_usage.is_some());
1861 assert_eq!(
1862 deserialized.total_usage.as_ref().unwrap().input_tokens,
1863 5000
1864 );
1865 assert_eq!(deserialized.phase_usage.len(), 1);
1866 assert!(deserialized.phase_usage.contains_key("US-001"));
1867 assert_eq!(
1868 deserialized.phase_usage.get("US-001").unwrap().input_tokens,
1869 2000
1870 );
1871 }
1872
1873 #[test]
1874 fn test_from_spec_constructors_initialize_usage_fields() {
1875 let state = RunState::from_spec(
1876 PathBuf::from("spec-feature.md"),
1877 PathBuf::from("spec-feature.json"),
1878 );
1879 assert!(state.total_usage.is_none());
1880 assert!(state.phase_usage.is_empty());
1881
1882 let state2 = RunState::from_spec_with_config(
1883 PathBuf::from("spec-feature.md"),
1884 PathBuf::from("spec-feature.json"),
1885 Config::default(),
1886 );
1887 assert!(state2.total_usage.is_none());
1888 assert!(state2.phase_usage.is_empty());
1889 }
1890
1891 #[test]
1896 fn test_capture_usage_first_call_initializes_totals() {
1897 let mut state = RunState::new(PathBuf::from("test.json"), "test-branch".to_string());
1898
1899 let usage = ClaudeUsage {
1900 input_tokens: 100,
1901 output_tokens: 50,
1902 cache_read_tokens: 10,
1903 cache_creation_tokens: 5,
1904 thinking_tokens: 3,
1905 model: Some("claude-sonnet-4".to_string()),
1906 };
1907
1908 state.capture_usage("Planning", Some(usage.clone()));
1909
1910 assert!(state.total_usage.is_some());
1912 let total = state.total_usage.as_ref().unwrap();
1913 assert_eq!(total.input_tokens, 100);
1914 assert_eq!(total.output_tokens, 50);
1915 assert_eq!(total.cache_read_tokens, 10);
1916
1917 assert!(state.phase_usage.contains_key("Planning"));
1919 let planning = state.phase_usage.get("Planning").unwrap();
1920 assert_eq!(planning.input_tokens, 100);
1921 assert_eq!(planning.output_tokens, 50);
1922 }
1923
1924 #[test]
1925 fn test_capture_usage_accumulates_into_existing_phase() {
1926 let mut state = RunState::new(PathBuf::from("test.json"), "test-branch".to_string());
1927
1928 let usage1 = ClaudeUsage {
1929 input_tokens: 100,
1930 output_tokens: 50,
1931 ..Default::default()
1932 };
1933 let usage2 = ClaudeUsage {
1934 input_tokens: 200,
1935 output_tokens: 100,
1936 ..Default::default()
1937 };
1938
1939 state.capture_usage("Final Review", Some(usage1));
1940 state.capture_usage("Final Review", Some(usage2));
1941
1942 let review = state.phase_usage.get("Final Review").unwrap();
1944 assert_eq!(review.input_tokens, 300);
1945 assert_eq!(review.output_tokens, 150);
1946
1947 let total = state.total_usage.as_ref().unwrap();
1949 assert_eq!(total.input_tokens, 300);
1950 assert_eq!(total.output_tokens, 150);
1951 }
1952
1953 #[test]
1954 fn test_capture_usage_multiple_phases() {
1955 let mut state = RunState::new(PathBuf::from("test.json"), "test-branch".to_string());
1956
1957 state.capture_usage(
1958 "Planning",
1959 Some(ClaudeUsage {
1960 input_tokens: 1000,
1961 output_tokens: 500,
1962 ..Default::default()
1963 }),
1964 );
1965 state.capture_usage(
1966 "US-001",
1967 Some(ClaudeUsage {
1968 input_tokens: 2000,
1969 output_tokens: 1000,
1970 ..Default::default()
1971 }),
1972 );
1973 state.capture_usage(
1974 "US-002",
1975 Some(ClaudeUsage {
1976 input_tokens: 1500,
1977 output_tokens: 750,
1978 ..Default::default()
1979 }),
1980 );
1981 state.capture_usage(
1982 "Final Review",
1983 Some(ClaudeUsage {
1984 input_tokens: 500,
1985 output_tokens: 250,
1986 ..Default::default()
1987 }),
1988 );
1989 state.capture_usage(
1990 "PR & Commit",
1991 Some(ClaudeUsage {
1992 input_tokens: 300,
1993 output_tokens: 150,
1994 ..Default::default()
1995 }),
1996 );
1997
1998 assert_eq!(state.phase_usage.len(), 5);
2000 assert_eq!(
2001 state.phase_usage.get("Planning").unwrap().input_tokens,
2002 1000
2003 );
2004 assert_eq!(state.phase_usage.get("US-001").unwrap().input_tokens, 2000);
2005 assert_eq!(state.phase_usage.get("US-002").unwrap().input_tokens, 1500);
2006 assert_eq!(
2007 state.phase_usage.get("Final Review").unwrap().input_tokens,
2008 500
2009 );
2010 assert_eq!(
2011 state.phase_usage.get("PR & Commit").unwrap().input_tokens,
2012 300
2013 );
2014
2015 let total = state.total_usage.as_ref().unwrap();
2017 assert_eq!(total.input_tokens, 1000 + 2000 + 1500 + 500 + 300);
2018 assert_eq!(total.output_tokens, 500 + 1000 + 750 + 250 + 150);
2019 }
2020
2021 #[test]
2022 fn test_capture_usage_with_none_is_noop() {
2023 let mut state = RunState::new(PathBuf::from("test.json"), "test-branch".to_string());
2024
2025 state.capture_usage("Planning", None);
2026
2027 assert!(state.total_usage.is_none());
2029 assert!(state.phase_usage.is_empty());
2030 }
2031
2032 #[test]
2033 fn test_capture_usage_none_after_some_preserves_existing() {
2034 let mut state = RunState::new(PathBuf::from("test.json"), "test-branch".to_string());
2035
2036 state.capture_usage(
2037 "Planning",
2038 Some(ClaudeUsage {
2039 input_tokens: 100,
2040 output_tokens: 50,
2041 ..Default::default()
2042 }),
2043 );
2044
2045 state.capture_usage("Planning", None);
2047
2048 assert_eq!(state.phase_usage.get("Planning").unwrap().input_tokens, 100);
2049 assert_eq!(state.total_usage.as_ref().unwrap().input_tokens, 100);
2050 }
2051
2052 #[test]
2053 fn test_set_iteration_usage_sets_on_current_iteration() {
2054 let mut state = RunState::new(PathBuf::from("test.json"), "test-branch".to_string());
2055 state.start_iteration("US-001");
2056
2057 let usage = ClaudeUsage {
2058 input_tokens: 500,
2059 output_tokens: 250,
2060 model: Some("claude-sonnet-4".to_string()),
2061 ..Default::default()
2062 };
2063
2064 state.set_iteration_usage(Some(usage.clone()));
2065
2066 assert!(state.iterations.last().unwrap().usage.is_some());
2067 let iter_usage = state.iterations.last().unwrap().usage.as_ref().unwrap();
2068 assert_eq!(iter_usage.input_tokens, 500);
2069 assert_eq!(iter_usage.output_tokens, 250);
2070 }
2071
2072 #[test]
2073 fn test_set_iteration_usage_with_none_does_not_set() {
2074 let mut state = RunState::new(PathBuf::from("test.json"), "test-branch".to_string());
2075 state.start_iteration("US-001");
2076
2077 state.set_iteration_usage(None);
2078
2079 assert!(state.iterations.last().unwrap().usage.is_none());
2080 }
2081
2082 #[test]
2083 fn test_set_iteration_usage_no_iteration_is_noop() {
2084 let mut state = RunState::new(PathBuf::from("test.json"), "test-branch".to_string());
2085
2086 state.set_iteration_usage(Some(ClaudeUsage {
2088 input_tokens: 100,
2089 ..Default::default()
2090 }));
2091
2092 assert!(state.iterations.is_empty());
2094 }
2095
2096 #[test]
2097 fn test_capture_usage_preserves_model_from_first_call() {
2098 let mut state = RunState::new(PathBuf::from("test.json"), "test-branch".to_string());
2099
2100 state.capture_usage(
2101 "Planning",
2102 Some(ClaudeUsage {
2103 input_tokens: 100,
2104 model: Some("claude-sonnet-4".to_string()),
2105 ..Default::default()
2106 }),
2107 );
2108 state.capture_usage(
2109 "Planning",
2110 Some(ClaudeUsage {
2111 input_tokens: 200,
2112 model: Some("claude-opus-4".to_string()),
2113 ..Default::default()
2114 }),
2115 );
2116
2117 let planning = state.phase_usage.get("Planning").unwrap();
2119 assert_eq!(planning.model, Some("claude-sonnet-4".to_string()));
2120 }
2121
2122 #[test]
2127 fn test_find_session_for_branch_returns_none_when_no_sessions() {
2128 let temp_dir = TempDir::new().unwrap();
2129 let sm = StateManager::with_dir(temp_dir.path().to_path_buf());
2130
2131 let result = sm.find_session_for_branch("feature/test").unwrap();
2132 assert!(result.is_none());
2133 }
2134
2135 #[test]
2136 fn test_find_session_for_branch_returns_none_when_no_match() {
2137 let temp_dir = TempDir::new().unwrap();
2138 let sm = StateManager::with_dir_and_session(
2139 temp_dir.path().to_path_buf(),
2140 "session-1".to_string(),
2141 );
2142
2143 let state = RunState::new(PathBuf::from("test.json"), "feature/other".to_string());
2145 sm.save(&state).unwrap();
2146
2147 let result = sm.find_session_for_branch("feature/test").unwrap();
2148 assert!(result.is_none());
2149 }
2150
2151 #[test]
2152 fn test_find_session_for_branch_returns_matching_session() {
2153 let temp_dir = TempDir::new().unwrap();
2154 let sm = StateManager::with_dir_and_session(
2155 temp_dir.path().to_path_buf(),
2156 "session-1".to_string(),
2157 );
2158
2159 let state = RunState::new(PathBuf::from("test.json"), "feature/test".to_string());
2160 sm.save(&state).unwrap();
2161
2162 let result = sm.find_session_for_branch("feature/test").unwrap();
2163 assert!(result.is_some());
2164 let metadata = result.unwrap();
2165 assert_eq!(metadata.branch_name, "feature/test");
2166 assert_eq!(metadata.session_id, "session-1");
2167 }
2168
2169 #[test]
2170 fn test_find_session_for_branch_returns_most_recent() {
2171 let temp_dir = TempDir::new().unwrap();
2172
2173 let sm1 = StateManager::with_dir_and_session(
2175 temp_dir.path().to_path_buf(),
2176 "session-old".to_string(),
2177 );
2178 let state1 = RunState::new(PathBuf::from("test.json"), "feature/test".to_string());
2179 sm1.save(&state1).unwrap();
2180
2181 std::thread::sleep(std::time::Duration::from_millis(10));
2183
2184 let sm2 = StateManager::with_dir_and_session(
2185 temp_dir.path().to_path_buf(),
2186 "session-new".to_string(),
2187 );
2188 let state2 = RunState::new(PathBuf::from("test.json"), "feature/test".to_string());
2189 sm2.save(&state2).unwrap();
2190
2191 let result = sm1.find_session_for_branch("feature/test").unwrap();
2193 assert!(result.is_some());
2194 let metadata = result.unwrap();
2195 assert_eq!(metadata.session_id, "session-new");
2196 }
2197
2198 #[test]
2199 fn test_find_session_for_branch_searches_all_sessions() {
2200 let temp_dir = TempDir::new().unwrap();
2201
2202 let sm1 = StateManager::with_dir_and_session(
2204 temp_dir.path().to_path_buf(),
2205 "session-a".to_string(),
2206 );
2207 sm1.save(&RunState::new(
2208 PathBuf::from("a.json"),
2209 "feature/a".to_string(),
2210 ))
2211 .unwrap();
2212
2213 let sm2 = StateManager::with_dir_and_session(
2214 temp_dir.path().to_path_buf(),
2215 "session-b".to_string(),
2216 );
2217 sm2.save(&RunState::new(
2218 PathBuf::from("b.json"),
2219 "feature/b".to_string(),
2220 ))
2221 .unwrap();
2222
2223 let sm3 = StateManager::with_dir_and_session(
2224 temp_dir.path().to_path_buf(),
2225 MAIN_SESSION_ID.to_string(),
2226 );
2227 sm3.save(&RunState::new(
2228 PathBuf::from("main.json"),
2229 "feature/main".to_string(),
2230 ))
2231 .unwrap();
2232
2233 let result_a = sm3.find_session_for_branch("feature/a").unwrap();
2235 assert!(result_a.is_some());
2236 assert_eq!(result_a.unwrap().session_id, "session-a");
2237
2238 let result_b = sm1.find_session_for_branch("feature/b").unwrap();
2239 assert!(result_b.is_some());
2240 assert_eq!(result_b.unwrap().session_id, "session-b");
2241
2242 let result_main = sm2.find_session_for_branch("feature/main").unwrap();
2243 assert!(result_main.is_some());
2244 assert_eq!(result_main.unwrap().session_id, MAIN_SESSION_ID);
2245 }
2246
2247 #[test]
2252 fn test_session_metadata_spec_json_path_defaults_to_none() {
2253 let legacy_json = r#"{
2255 "sessionId": "test-session",
2256 "worktreePath": "/path/to/worktree",
2257 "branchName": "feature/test",
2258 "createdAt": "2024-01-01T00:00:00Z",
2259 "lastActiveAt": "2024-01-01T01:00:00Z",
2260 "isRunning": false
2261 }"#;
2262
2263 let metadata: SessionMetadata = serde_json::from_str(legacy_json).unwrap();
2264 assert!(metadata.spec_json_path.is_none());
2265 assert_eq!(metadata.session_id, "test-session");
2266 assert_eq!(metadata.branch_name, "feature/test");
2267 }
2268
2269 #[test]
2270 fn test_session_metadata_spec_json_path_serialization_roundtrip() {
2271 let metadata = SessionMetadata {
2272 session_id: "test-session".to_string(),
2273 worktree_path: PathBuf::from("/path/to/worktree"),
2274 branch_name: "feature/test".to_string(),
2275 created_at: Utc::now(),
2276 last_active_at: Utc::now(),
2277 is_running: false,
2278 spec_json_path: Some(PathBuf::from("/path/to/spec.json")),
2279 };
2280
2281 let json = serde_json::to_string(&metadata).unwrap();
2283 assert!(json.contains("\"specJsonPath\""));
2284 assert!(json.contains("/path/to/spec.json"));
2285
2286 let deserialized: SessionMetadata = serde_json::from_str(&json).unwrap();
2288 assert_eq!(
2289 deserialized.spec_json_path,
2290 Some(PathBuf::from("/path/to/spec.json"))
2291 );
2292 }
2293
2294 #[test]
2295 fn test_save_metadata_populates_spec_json_path() {
2296 let temp_dir = TempDir::new().unwrap();
2297 let sm = StateManager::with_dir(temp_dir.path().to_path_buf());
2298
2299 let state = RunState::new(
2300 PathBuf::from("/config/spec/spec-feature.json"),
2301 "feature/test".to_string(),
2302 );
2303 sm.save(&state).unwrap();
2304
2305 let metadata = sm.load_metadata().unwrap().unwrap();
2306 assert_eq!(
2307 metadata.spec_json_path,
2308 Some(PathBuf::from("/config/spec/spec-feature.json"))
2309 );
2310 }
2311
2312 #[test]
2313 fn test_save_metadata_updates_spec_json_path() {
2314 let temp_dir = TempDir::new().unwrap();
2315 let sm = StateManager::with_dir(temp_dir.path().to_path_buf());
2316
2317 let state1 = RunState::new(PathBuf::from("spec-v1.json"), "feature/test".to_string());
2319 sm.save(&state1).unwrap();
2320
2321 let metadata1 = sm.load_metadata().unwrap().unwrap();
2322 assert_eq!(
2323 metadata1.spec_json_path,
2324 Some(PathBuf::from("spec-v1.json"))
2325 );
2326
2327 let state2 = RunState::new(PathBuf::from("spec-v2.json"), "feature/test".to_string());
2329 sm.save(&state2).unwrap();
2330
2331 let metadata2 = sm.load_metadata().unwrap().unwrap();
2332 assert_eq!(
2333 metadata2.spec_json_path,
2334 Some(PathBuf::from("spec-v2.json"))
2335 );
2336 }
2337
2338 #[test]
2339 fn test_find_session_for_branch_returns_spec_json_path() {
2340 let temp_dir = TempDir::new().unwrap();
2341 let sm = StateManager::with_dir_and_session(
2342 temp_dir.path().to_path_buf(),
2343 "session-1".to_string(),
2344 );
2345
2346 let state = RunState::new(
2347 PathBuf::from("spec-feature.json"),
2348 "feature/test".to_string(),
2349 );
2350 sm.save(&state).unwrap();
2351
2352 let result = sm.find_session_for_branch("feature/test").unwrap();
2353 assert!(result.is_some());
2354 let metadata = result.unwrap();
2355 assert_eq!(
2356 metadata.spec_json_path,
2357 Some(PathBuf::from("spec-feature.json"))
2358 );
2359 }
2360}