1use anyhow::{Context, Result};
8use colored::Colorize;
9use serde::{Deserialize, Serialize};
10use std::path::{Path, PathBuf};
11
12use crate::commands::spawn::terminal::{find_harness_binary, Harness};
13use crate::storage::Storage;
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct SessionMetadata {
18 pub task_id: String,
20 pub session_id: String,
22 pub tag: String,
24 #[serde(default)]
26 pub pid: Option<u32>,
27 #[serde(default = "default_harness")]
29 pub harness: String,
30}
31
32fn default_harness() -> String {
33 "claude".to_string()
34}
35
36impl SessionMetadata {
37 pub fn new(task_id: &str, session_id: &str, tag: &str, harness: &str) -> Self {
39 Self {
40 task_id: task_id.to_string(),
41 session_id: session_id.to_string(),
42 tag: tag.to_string(),
43 pid: None,
44 harness: harness.to_string(),
45 }
46 }
47
48 pub fn with_pid(mut self, pid: u32) -> Self {
50 self.pid = Some(pid);
51 self
52 }
53}
54
55pub fn headless_metadata_dir(project_root: &Path) -> PathBuf {
57 project_root.join(".scud").join("headless")
58}
59
60pub fn session_metadata_path(project_root: &Path, task_id: &str) -> PathBuf {
62 headless_metadata_dir(project_root).join(format!("{}.json", task_id))
63}
64
65pub fn save_session_metadata(project_root: &Path, metadata: &SessionMetadata) -> Result<()> {
67 let metadata_dir = headless_metadata_dir(project_root);
68 std::fs::create_dir_all(&metadata_dir)
69 .context("Failed to create headless metadata directory")?;
70
71 let metadata_file = session_metadata_path(project_root, &metadata.task_id);
72 let content = serde_json::to_string_pretty(metadata)
73 .context("Failed to serialize session metadata")?;
74
75 std::fs::write(&metadata_file, content)
76 .context("Failed to write session metadata")?;
77
78 Ok(())
79}
80
81pub fn load_session_metadata(project_root: &Path, task_id: &str) -> Result<SessionMetadata> {
83 let metadata_file = session_metadata_path(project_root, task_id);
84
85 if !metadata_file.exists() {
86 anyhow::bail!(
87 "No session metadata found for task '{}'. Was it run in headless mode?",
88 task_id
89 );
90 }
91
92 let content = std::fs::read_to_string(&metadata_file)
93 .context("Failed to read session metadata")?;
94
95 let metadata: SessionMetadata = serde_json::from_str(&content)
96 .context("Failed to parse session metadata")?;
97
98 Ok(metadata)
99}
100
101pub fn list_sessions(project_root: &Path) -> Result<Vec<SessionMetadata>> {
103 let metadata_dir = headless_metadata_dir(project_root);
104
105 if !metadata_dir.exists() {
106 return Ok(Vec::new());
107 }
108
109 let mut sessions = Vec::new();
110
111 for entry in std::fs::read_dir(&metadata_dir)? {
112 let entry = entry?;
113 let path = entry.path();
114
115 if path.extension().map(|e| e == "json").unwrap_or(false) {
116 if let Ok(content) = std::fs::read_to_string(&path) {
117 if let Ok(metadata) = serde_json::from_str::<SessionMetadata>(&content) {
118 sessions.push(metadata);
119 }
120 }
121 }
122 }
123
124 Ok(sessions)
125}
126
127pub fn delete_session_metadata(project_root: &Path, task_id: &str) -> Result<()> {
129 let metadata_file = session_metadata_path(project_root, task_id);
130
131 if metadata_file.exists() {
132 std::fs::remove_file(&metadata_file)
133 .context("Failed to delete session metadata")?;
134 }
135
136 Ok(())
137}
138
139pub fn interactive_command(harness: Harness, session_id: &str) -> Result<Vec<String>> {
141 let binary_path = find_harness_binary(harness)?.to_string();
142
143 match harness {
144 Harness::Claude => Ok(vec![
145 binary_path,
146 "--resume".to_string(),
147 session_id.to_string(),
148 ]),
149 Harness::OpenCode => {
150 Ok(vec![
152 binary_path,
153 "attach".to_string(),
154 "http://localhost:4096".to_string(),
155 "--session".to_string(),
156 session_id.to_string(),
157 ])
158 }
159 Harness::Cursor => Ok(vec![
160 binary_path,
161 "--resume".to_string(),
162 session_id.to_string(),
163 ]),
164 }
165}
166
167pub fn run(
169 project_root: Option<PathBuf>,
170 task_id: &str,
171 harness_arg: Option<&str>,
172) -> Result<()> {
173 let storage = Storage::new(project_root.clone());
174 let root = storage.project_root().to_path_buf();
175
176 let metadata = load_session_metadata(&root, task_id)?;
178
179 let harness_str = harness_arg.unwrap_or(&metadata.harness);
181 let harness = Harness::parse(harness_str)?;
182
183 let cmd_args = interactive_command(harness, &metadata.session_id)?;
185
186 println!("{}", "SCUD Attach".cyan().bold());
188 println!("{}", "═".repeat(50));
189 println!("{:<15} {}", "Task:".dimmed(), task_id.cyan());
190 println!("{:<15} {}", "Session:".dimmed(), metadata.session_id.dimmed());
191 println!("{:<15} {}", "Tag:".dimmed(), metadata.tag);
192 println!("{:<15} {}", "Harness:".dimmed(), harness.name().green());
193 if let Some(pid) = metadata.pid {
194 println!("{:<15} {}", "PID:".dimmed(), pid);
195 }
196 println!();
197 println!("{}", "Attaching to session...".cyan());
198 println!();
199
200 #[cfg(unix)]
202 {
203 use std::os::unix::process::CommandExt;
204
205 let err = std::process::Command::new(&cmd_args[0])
206 .args(&cmd_args[1..])
207 .exec();
208
209 anyhow::bail!("Failed to exec '{}': {}", cmd_args[0], err);
211 }
212
213 #[cfg(not(unix))]
214 {
215 let status = std::process::Command::new(&cmd_args[0])
217 .args(&cmd_args[1..])
218 .status()
219 .context("Failed to spawn interactive session")?;
220
221 if !status.success() {
222 anyhow::bail!("Interactive session exited with error");
223 }
224
225 Ok(())
226 }
227}
228
229pub fn run_list(project_root: Option<PathBuf>) -> Result<()> {
231 let storage = Storage::new(project_root);
232 let root = storage.project_root().to_path_buf();
233
234 let sessions = list_sessions(&root)?;
235
236 if sessions.is_empty() {
237 println!("{}", "No headless sessions found.".dimmed());
238 println!(
239 "Run {} to start a headless session.",
240 "scud spawn --headless".cyan()
241 );
242 return Ok(());
243 }
244
245 println!("{}", "Headless Sessions:".cyan().bold());
246 println!();
247
248 for session in &sessions {
249 let pid_info = session
250 .pid
251 .map(|p| format!(" (pid: {})", p))
252 .unwrap_or_default();
253
254 println!(
255 " {} {} [{}]{}",
256 session.task_id.cyan(),
257 session.tag.dimmed(),
258 session.harness.green(),
259 pid_info.dimmed()
260 );
261 }
262
263 println!();
264 println!(
265 "{}",
266 "Use 'scud attach <task_id>' to resume a session.".dimmed()
267 );
268
269 Ok(())
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275 use tempfile::TempDir;
276
277 #[test]
278 fn test_session_metadata_serialization() {
279 let metadata = SessionMetadata::new("1.1", "sess-abc123", "alpha", "claude")
280 .with_pid(12345);
281
282 let json = serde_json::to_string(&metadata).unwrap();
283 let parsed: SessionMetadata = serde_json::from_str(&json).unwrap();
284
285 assert_eq!(parsed.task_id, "1.1");
286 assert_eq!(parsed.session_id, "sess-abc123");
287 assert_eq!(parsed.tag, "alpha");
288 assert_eq!(parsed.harness, "claude");
289 assert_eq!(parsed.pid, Some(12345));
290 }
291
292 #[test]
293 fn test_save_and_load_session_metadata() {
294 let temp_dir = TempDir::new().unwrap();
295 let project_root = temp_dir.path();
296
297 let metadata = SessionMetadata::new("2.1", "sess-xyz789", "beta", "opencode");
298
299 save_session_metadata(project_root, &metadata).unwrap();
301
302 let metadata_path = session_metadata_path(project_root, "2.1");
304 assert!(metadata_path.exists());
305
306 let loaded = load_session_metadata(project_root, "2.1").unwrap();
308 assert_eq!(loaded.task_id, "2.1");
309 assert_eq!(loaded.session_id, "sess-xyz789");
310 assert_eq!(loaded.tag, "beta");
311 assert_eq!(loaded.harness, "opencode");
312 }
313
314 #[test]
315 fn test_load_nonexistent_session() {
316 let temp_dir = TempDir::new().unwrap();
317 let project_root = temp_dir.path();
318
319 let result = load_session_metadata(project_root, "nonexistent");
320 assert!(result.is_err());
321 assert!(result
322 .unwrap_err()
323 .to_string()
324 .contains("No session metadata found"));
325 }
326
327 #[test]
328 fn test_list_sessions() {
329 let temp_dir = TempDir::new().unwrap();
330 let project_root = temp_dir.path();
331
332 save_session_metadata(
334 project_root,
335 &SessionMetadata::new("1.1", "sess-1", "alpha", "claude"),
336 )
337 .unwrap();
338 save_session_metadata(
339 project_root,
340 &SessionMetadata::new("2.1", "sess-2", "beta", "opencode"),
341 )
342 .unwrap();
343
344 let sessions = list_sessions(project_root).unwrap();
345 assert_eq!(sessions.len(), 2);
346 }
347
348 #[test]
349 fn test_delete_session_metadata() {
350 let temp_dir = TempDir::new().unwrap();
351 let project_root = temp_dir.path();
352
353 let metadata = SessionMetadata::new("3.1", "sess-delete", "gamma", "claude");
354 save_session_metadata(project_root, &metadata).unwrap();
355
356 let metadata_path = session_metadata_path(project_root, "3.1");
357 assert!(metadata_path.exists());
358
359 delete_session_metadata(project_root, "3.1").unwrap();
360 assert!(!metadata_path.exists());
361 }
362
363 #[test]
364 fn test_default_harness() {
365 let json = r#"{"task_id": "1.1", "session_id": "sess-123", "tag": "test"}"#;
367 let metadata: SessionMetadata = serde_json::from_str(json).unwrap();
368 assert_eq!(metadata.harness, "claude");
369 }
370
371 #[test]
372 fn test_interactive_command_claude() {
373 if find_harness_binary(Harness::Claude).is_err() {
376 return;
377 }
378
379 let cmd = interactive_command(Harness::Claude, "sess-123").unwrap();
380 assert!(cmd.len() >= 3);
381 assert!(cmd[0].contains("claude"));
382 assert_eq!(cmd[1], "--resume");
383 assert_eq!(cmd[2], "sess-123");
384 }
385}