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 Harness::Rho => anyhow::bail!("Rho sessions cannot be resumed interactively yet"),
165 #[cfg(feature = "direct-api")]
166 Harness::DirectApi => anyhow::bail!("Direct API sessions cannot be resumed interactively"),
167 }
168}
169
170pub fn run(
172 project_root: Option<PathBuf>,
173 task_id: &str,
174 harness_arg: Option<&str>,
175) -> Result<()> {
176 let storage = Storage::new(project_root.clone());
177 let root = storage.project_root().to_path_buf();
178
179 let metadata = load_session_metadata(&root, task_id)?;
181
182 let harness_str = harness_arg.unwrap_or(&metadata.harness);
184 let harness = Harness::parse(harness_str)?;
185
186 let cmd_args = interactive_command(harness, &metadata.session_id)?;
188
189 println!("{}", "SCUD Attach".cyan().bold());
191 println!("{}", "═".repeat(50));
192 println!("{:<15} {}", "Task:".dimmed(), task_id.cyan());
193 println!("{:<15} {}", "Session:".dimmed(), metadata.session_id.dimmed());
194 println!("{:<15} {}", "Tag:".dimmed(), metadata.tag);
195 println!("{:<15} {}", "Harness:".dimmed(), harness.name().green());
196 if let Some(pid) = metadata.pid {
197 println!("{:<15} {}", "PID:".dimmed(), pid);
198 }
199 println!();
200 println!("{}", "Attaching to session...".cyan());
201 println!();
202
203 #[cfg(unix)]
205 {
206 use std::os::unix::process::CommandExt;
207
208 let err = std::process::Command::new(&cmd_args[0])
209 .args(&cmd_args[1..])
210 .exec();
211
212 anyhow::bail!("Failed to exec '{}': {}", cmd_args[0], err);
214 }
215
216 #[cfg(not(unix))]
217 {
218 let status = std::process::Command::new(&cmd_args[0])
220 .args(&cmd_args[1..])
221 .status()
222 .context("Failed to spawn interactive session")?;
223
224 if !status.success() {
225 anyhow::bail!("Interactive session exited with error");
226 }
227
228 Ok(())
229 }
230}
231
232pub fn run_list(project_root: Option<PathBuf>) -> Result<()> {
234 let storage = Storage::new(project_root);
235 let root = storage.project_root().to_path_buf();
236
237 let sessions = list_sessions(&root)?;
238
239 if sessions.is_empty() {
240 println!("{}", "No headless sessions found.".dimmed());
241 println!(
242 "Run {} to start a headless session.",
243 "scud spawn --headless".cyan()
244 );
245 return Ok(());
246 }
247
248 println!("{}", "Headless Sessions:".cyan().bold());
249 println!();
250
251 for session in &sessions {
252 let pid_info = session
253 .pid
254 .map(|p| format!(" (pid: {})", p))
255 .unwrap_or_default();
256
257 println!(
258 " {} {} [{}]{}",
259 session.task_id.cyan(),
260 session.tag.dimmed(),
261 session.harness.green(),
262 pid_info.dimmed()
263 );
264 }
265
266 println!();
267 println!(
268 "{}",
269 "Use 'scud attach <task_id>' to resume a session.".dimmed()
270 );
271
272 Ok(())
273}
274
275#[cfg(test)]
276mod tests {
277 use super::*;
278 use tempfile::TempDir;
279
280 #[test]
281 fn test_session_metadata_serialization() {
282 let metadata = SessionMetadata::new("1.1", "sess-abc123", "alpha", "claude")
283 .with_pid(12345);
284
285 let json = serde_json::to_string(&metadata).unwrap();
286 let parsed: SessionMetadata = serde_json::from_str(&json).unwrap();
287
288 assert_eq!(parsed.task_id, "1.1");
289 assert_eq!(parsed.session_id, "sess-abc123");
290 assert_eq!(parsed.tag, "alpha");
291 assert_eq!(parsed.harness, "claude");
292 assert_eq!(parsed.pid, Some(12345));
293 }
294
295 #[test]
296 fn test_save_and_load_session_metadata() {
297 let temp_dir = TempDir::new().unwrap();
298 let project_root = temp_dir.path();
299
300 let metadata = SessionMetadata::new("2.1", "sess-xyz789", "beta", "opencode");
301
302 save_session_metadata(project_root, &metadata).unwrap();
304
305 let metadata_path = session_metadata_path(project_root, "2.1");
307 assert!(metadata_path.exists());
308
309 let loaded = load_session_metadata(project_root, "2.1").unwrap();
311 assert_eq!(loaded.task_id, "2.1");
312 assert_eq!(loaded.session_id, "sess-xyz789");
313 assert_eq!(loaded.tag, "beta");
314 assert_eq!(loaded.harness, "opencode");
315 }
316
317 #[test]
318 fn test_load_nonexistent_session() {
319 let temp_dir = TempDir::new().unwrap();
320 let project_root = temp_dir.path();
321
322 let result = load_session_metadata(project_root, "nonexistent");
323 assert!(result.is_err());
324 assert!(result
325 .unwrap_err()
326 .to_string()
327 .contains("No session metadata found"));
328 }
329
330 #[test]
331 fn test_list_sessions() {
332 let temp_dir = TempDir::new().unwrap();
333 let project_root = temp_dir.path();
334
335 save_session_metadata(
337 project_root,
338 &SessionMetadata::new("1.1", "sess-1", "alpha", "claude"),
339 )
340 .unwrap();
341 save_session_metadata(
342 project_root,
343 &SessionMetadata::new("2.1", "sess-2", "beta", "opencode"),
344 )
345 .unwrap();
346
347 let sessions = list_sessions(project_root).unwrap();
348 assert_eq!(sessions.len(), 2);
349 }
350
351 #[test]
352 fn test_delete_session_metadata() {
353 let temp_dir = TempDir::new().unwrap();
354 let project_root = temp_dir.path();
355
356 let metadata = SessionMetadata::new("3.1", "sess-delete", "gamma", "claude");
357 save_session_metadata(project_root, &metadata).unwrap();
358
359 let metadata_path = session_metadata_path(project_root, "3.1");
360 assert!(metadata_path.exists());
361
362 delete_session_metadata(project_root, "3.1").unwrap();
363 assert!(!metadata_path.exists());
364 }
365
366 #[test]
367 fn test_default_harness() {
368 let json = r#"{"task_id": "1.1", "session_id": "sess-123", "tag": "test"}"#;
370 let metadata: SessionMetadata = serde_json::from_str(json).unwrap();
371 assert_eq!(metadata.harness, "claude");
372 }
373
374 #[test]
375 fn test_interactive_command_claude() {
376 if find_harness_binary(Harness::Claude).is_err() {
379 return;
380 }
381
382 let cmd = interactive_command(Harness::Claude, "sess-123").unwrap();
383 assert!(cmd.len() >= 3);
384 assert!(cmd[0].contains("claude"));
385 assert_eq!(cmd[1], "--resume");
386 assert_eq!(cmd[2], "sess-123");
387 }
388}