1use anyhow::Result;
7use serde::{Deserialize, Serialize};
8use std::fs;
9use std::path::PathBuf;
10
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
13#[serde(rename_all = "kebab-case")]
14pub enum AgentStatus {
15 Starting,
16 Running,
17 Completed,
18 Failed,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct AgentState {
24 pub task_id: String,
25 pub task_title: String,
26 pub window_name: String,
27 pub status: AgentStatus,
28 pub started_at: String,
29 pub tag: String,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct SpawnSession {
35 pub session_name: String,
36 pub tag: String,
37 pub terminal: String,
38 pub created_at: String,
39 pub working_dir: String,
40 pub agents: Vec<AgentState>,
41}
42
43impl SpawnSession {
44 pub fn new(session_name: &str, tag: &str, terminal: &str, working_dir: &str) -> Self {
46 Self {
47 session_name: session_name.to_string(),
48 tag: tag.to_string(),
49 terminal: terminal.to_string(),
50 created_at: chrono::Utc::now().to_rfc3339(),
51 working_dir: working_dir.to_string(),
52 agents: Vec::new(),
53 }
54 }
55
56 pub fn add_agent(&mut self, task_id: &str, task_title: &str, tag: &str) {
58 let window_name = format!("task-{}", task_id);
59 self.agents.push(AgentState {
60 task_id: task_id.to_string(),
61 task_title: task_title.to_string(),
62 window_name,
63 status: AgentStatus::Starting,
64 started_at: chrono::Utc::now().to_rfc3339(),
65 tag: tag.to_string(),
66 });
67 }
68
69 pub fn update_agent_status(&mut self, task_id: &str, status: AgentStatus) {
71 if let Some(agent) = self.agents.iter_mut().find(|a| a.task_id == task_id) {
72 agent.status = status;
73 }
74 }
75
76 pub fn count_by_status(&self, status: AgentStatus) -> usize {
78 self.agents.iter().filter(|a| a.status == status).count()
79 }
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct SpawnStats {
85 pub session_name: String,
86 pub tag: String,
87 pub total_agents: usize,
88 pub starting: usize,
89 pub running: usize,
90 pub completed: usize,
91 pub failed: usize,
92 pub created_at: String,
93}
94
95impl From<&SpawnSession> for SpawnStats {
96 fn from(session: &SpawnSession) -> Self {
97 Self {
98 session_name: session.session_name.clone(),
99 tag: session.tag.clone(),
100 total_agents: session.agents.len(),
101 starting: session.count_by_status(AgentStatus::Starting),
102 running: session.count_by_status(AgentStatus::Running),
103 completed: session.count_by_status(AgentStatus::Completed),
104 failed: session.count_by_status(AgentStatus::Failed),
105 created_at: session.created_at.clone(),
106 }
107 }
108}
109
110pub fn spawn_dir(project_root: Option<&PathBuf>) -> PathBuf {
112 let root = project_root
113 .cloned()
114 .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
115 root.join(".scud").join("spawn")
116}
117
118pub fn session_file(project_root: Option<&PathBuf>, session_name: &str) -> PathBuf {
120 spawn_dir(project_root).join(format!("{}.json", session_name))
121}
122
123pub fn save_session(project_root: Option<&PathBuf>, session: &SpawnSession) -> Result<PathBuf> {
125 let dir = spawn_dir(project_root);
126 fs::create_dir_all(&dir)?;
127
128 let file = session_file(project_root, &session.session_name);
129 let json = serde_json::to_string_pretty(session)?;
130 fs::write(&file, json)?;
131
132 Ok(file)
133}
134
135pub fn load_session(project_root: Option<&PathBuf>, session_name: &str) -> Result<SpawnSession> {
137 let file = session_file(project_root, session_name);
138 let json = fs::read_to_string(&file)?;
139 let session: SpawnSession = serde_json::from_str(&json)?;
140 Ok(session)
141}
142
143pub fn list_sessions(project_root: Option<&PathBuf>) -> Result<Vec<String>> {
145 let dir = spawn_dir(project_root);
146 if !dir.exists() {
147 return Ok(Vec::new());
148 }
149
150 let mut sessions = Vec::new();
151 for entry in fs::read_dir(dir)? {
152 let entry = entry?;
153 let path = entry.path();
154 if path.extension().map(|e| e == "json").unwrap_or(false) {
155 if let Some(stem) = path.file_stem() {
156 sessions.push(stem.to_string_lossy().to_string());
157 }
158 }
159 }
160
161 Ok(sessions)
162}
163
164pub fn delete_session(project_root: Option<&PathBuf>, session_name: &str) -> Result<()> {
166 let file = session_file(project_root, session_name);
167 if file.exists() {
168 fs::remove_file(file)?;
169 }
170 Ok(())
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176
177 #[test]
178 fn test_spawn_session_new() {
179 let session = SpawnSession::new("scud-test", "test-tag", "tmux", "/path/to/project");
180
181 assert_eq!(session.session_name, "scud-test");
182 assert_eq!(session.tag, "test-tag");
183 assert_eq!(session.terminal, "tmux");
184 assert!(session.agents.is_empty());
185 }
186
187 #[test]
188 fn test_add_agent() {
189 let mut session = SpawnSession::new("scud-test", "test-tag", "tmux", "/path/to/project");
190 session.add_agent("auth:1", "Implement auth", "auth");
191
192 assert_eq!(session.agents.len(), 1);
193 assert_eq!(session.agents[0].task_id, "auth:1");
194 assert_eq!(session.agents[0].window_name, "task-auth:1");
195 assert_eq!(session.agents[0].status, AgentStatus::Starting);
196 }
197
198 #[test]
199 fn test_update_agent_status() {
200 let mut session = SpawnSession::new("scud-test", "test-tag", "tmux", "/path/to/project");
201 session.add_agent("auth:1", "Implement auth", "auth");
202 session.update_agent_status("auth:1", AgentStatus::Running);
203
204 assert_eq!(session.agents[0].status, AgentStatus::Running);
205 }
206
207 #[test]
208 fn test_spawn_stats() {
209 let mut session = SpawnSession::new("scud-test", "test-tag", "tmux", "/path/to/project");
210 session.add_agent("auth:1", "Task 1", "auth");
211 session.add_agent("auth:2", "Task 2", "auth");
212 session.add_agent("auth:3", "Task 3", "auth");
213
214 session.update_agent_status("auth:1", AgentStatus::Running);
215 session.update_agent_status("auth:2", AgentStatus::Completed);
216
217 let stats = SpawnStats::from(&session);
218
219 assert_eq!(stats.total_agents, 3);
220 assert_eq!(stats.starting, 1); assert_eq!(stats.running, 1); assert_eq!(stats.completed, 1); assert_eq!(stats.failed, 0);
224 }
225}