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
110#[derive(Clone, Debug)]
116pub struct AgentView {
117 pub task_id: String,
118 pub task_title: String,
119 pub window_name: String,
120 pub status: AgentStatus,
121 pub tag: String,
122}
123
124#[derive(Clone, Debug)]
126pub struct WaveView {
127 pub wave_number: usize,
128 pub tasks: Vec<WaveTaskView>,
129}
130
131#[derive(Clone, Debug)]
133pub struct WaveTaskView {
134 pub task_id: String,
135 pub task_title: String,
136 pub state: WaveTaskState,
137 pub complexity: Option<u32>,
138}
139
140#[derive(Clone, Debug, PartialEq)]
142pub enum WaveTaskState {
143 Ready,
144 Running,
145 Done,
146 Blocked,
147 InProgress,
148}
149
150#[derive(Clone, Debug, Default)]
152pub struct StatusCounts {
153 pub starting: usize,
154 pub running: usize,
155 pub completed: usize,
156 pub failed: usize,
157}
158
159pub trait MonitorableSession: Send + Sync {
161 fn session_name(&self) -> &str;
163
164 fn tag(&self) -> &str;
166
167 fn working_dir(&self) -> &str;
169
170 fn agents(&self) -> Vec<AgentView>;
172
173 fn waves(&self) -> Vec<WaveView>;
175
176 fn status_counts(&self) -> StatusCounts;
178}
179
180impl MonitorableSession for SpawnSession {
181 fn session_name(&self) -> &str {
182 &self.session_name
183 }
184
185 fn tag(&self) -> &str {
186 &self.tag
187 }
188
189 fn working_dir(&self) -> &str {
190 &self.working_dir
191 }
192
193 fn agents(&self) -> Vec<AgentView> {
194 self.agents
195 .iter()
196 .map(|a| AgentView {
197 task_id: a.task_id.clone(),
198 task_title: a.task_title.clone(),
199 window_name: a.window_name.clone(),
200 status: a.status.clone(),
201 tag: a.tag.clone(),
202 })
203 .collect()
204 }
205
206 fn waves(&self) -> Vec<WaveView> {
207 Vec::new()
210 }
211
212 fn status_counts(&self) -> StatusCounts {
213 StatusCounts {
214 starting: self.count_by_status(AgentStatus::Starting),
215 running: self.count_by_status(AgentStatus::Running),
216 completed: self.count_by_status(AgentStatus::Completed),
217 failed: self.count_by_status(AgentStatus::Failed),
218 }
219 }
220}
221
222pub fn spawn_dir(project_root: Option<&PathBuf>) -> PathBuf {
224 let root = project_root
225 .cloned()
226 .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
227 root.join(".scud").join("spawn")
228}
229
230pub fn session_file(project_root: Option<&PathBuf>, session_name: &str) -> PathBuf {
232 spawn_dir(project_root).join(format!("{}.json", session_name))
233}
234
235pub fn save_session(project_root: Option<&PathBuf>, session: &SpawnSession) -> Result<PathBuf> {
237 let dir = spawn_dir(project_root);
238 fs::create_dir_all(&dir)?;
239
240 let file = session_file(project_root, &session.session_name);
241 let json = serde_json::to_string_pretty(session)?;
242 fs::write(&file, json)?;
243
244 Ok(file)
245}
246
247pub fn load_session(project_root: Option<&PathBuf>, session_name: &str) -> Result<SpawnSession> {
249 let file = session_file(project_root, session_name);
250 let json = fs::read_to_string(&file)?;
251 let session: SpawnSession = serde_json::from_str(&json)?;
252 Ok(session)
253}
254
255pub fn list_sessions(project_root: Option<&PathBuf>) -> Result<Vec<String>> {
257 let dir = spawn_dir(project_root);
258 if !dir.exists() {
259 return Ok(Vec::new());
260 }
261
262 let mut sessions = Vec::new();
263 for entry in fs::read_dir(dir)? {
264 let entry = entry?;
265 let path = entry.path();
266 if path.extension().map(|e| e == "json").unwrap_or(false) {
267 if let Some(stem) = path.file_stem() {
268 sessions.push(stem.to_string_lossy().to_string());
269 }
270 }
271 }
272
273 Ok(sessions)
274}
275
276pub fn delete_session(project_root: Option<&PathBuf>, session_name: &str) -> Result<()> {
278 let file = session_file(project_root, session_name);
279 if file.exists() {
280 fs::remove_file(file)?;
281 }
282 Ok(())
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288
289 #[test]
290 fn test_spawn_session_new() {
291 let session = SpawnSession::new("scud-test", "test-tag", "tmux", "/path/to/project");
292
293 assert_eq!(session.session_name, "scud-test");
294 assert_eq!(session.tag, "test-tag");
295 assert_eq!(session.terminal, "tmux");
296 assert!(session.agents.is_empty());
297 }
298
299 #[test]
300 fn test_add_agent() {
301 let mut session = SpawnSession::new("scud-test", "test-tag", "tmux", "/path/to/project");
302 session.add_agent("auth:1", "Implement auth", "auth");
303
304 assert_eq!(session.agents.len(), 1);
305 assert_eq!(session.agents[0].task_id, "auth:1");
306 assert_eq!(session.agents[0].window_name, "task-auth:1");
307 assert_eq!(session.agents[0].status, AgentStatus::Starting);
308 }
309
310 #[test]
311 fn test_update_agent_status() {
312 let mut session = SpawnSession::new("scud-test", "test-tag", "tmux", "/path/to/project");
313 session.add_agent("auth:1", "Implement auth", "auth");
314 session.update_agent_status("auth:1", AgentStatus::Running);
315
316 assert_eq!(session.agents[0].status, AgentStatus::Running);
317 }
318
319 #[test]
320 fn test_spawn_stats() {
321 let mut session = SpawnSession::new("scud-test", "test-tag", "tmux", "/path/to/project");
322 session.add_agent("auth:1", "Task 1", "auth");
323 session.add_agent("auth:2", "Task 2", "auth");
324 session.add_agent("auth:3", "Task 3", "auth");
325
326 session.update_agent_status("auth:1", AgentStatus::Running);
327 session.update_agent_status("auth:2", AgentStatus::Completed);
328
329 let stats = SpawnStats::from(&session);
330
331 assert_eq!(stats.total_agents, 3);
332 assert_eq!(stats.starting, 1); assert_eq!(stats.running, 1); assert_eq!(stats.completed, 1); assert_eq!(stats.failed, 0);
336 }
337
338 #[test]
339 fn test_spawn_session_implements_monitorable() {
340 let session = SpawnSession::new("test", "tag", "tmux", "/tmp");
341
342 let monitorable: &dyn MonitorableSession = &session;
344 assert_eq!(monitorable.session_name(), "test");
345 assert_eq!(monitorable.tag(), "tag");
346 assert_eq!(monitorable.working_dir(), "/tmp");
347 }
348
349 #[test]
350 fn test_spawn_session_agents_view() {
351 let mut session = SpawnSession::new("test", "tag", "tmux", "/tmp");
352 session.add_agent("task-1", "Title One", "tag");
353 session.add_agent("task-2", "Title Two", "tag");
354
355 let agents = session.agents();
356 assert_eq!(agents.len(), 2);
357 assert_eq!(agents[0].task_id, "task-1");
358 assert_eq!(agents[0].task_title, "Title One");
359 assert_eq!(agents[1].task_id, "task-2");
360 }
361
362 #[test]
363 fn test_spawn_session_status_counts() {
364 let mut session = SpawnSession::new("test", "tag", "tmux", "/tmp");
365 session.add_agent("task-1", "Title", "tag");
366 session.add_agent("task-2", "Title", "tag");
367 session.add_agent("task-3", "Title", "tag");
368
369 session.update_agent_status("task-1", AgentStatus::Running);
370 session.update_agent_status("task-2", AgentStatus::Completed);
371 let counts = session.status_counts();
374 assert_eq!(counts.starting, 1);
375 assert_eq!(counts.running, 1);
376 assert_eq!(counts.completed, 1);
377 assert_eq!(counts.failed, 0);
378 }
379
380 #[test]
381 fn test_spawn_session_waves_empty() {
382 let session = SpawnSession::new("test", "tag", "tmux", "/tmp");
383
384 let waves = session.waves();
386 assert!(waves.is_empty());
387 }
388}