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 "rho".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 =
73 serde_json::to_string_pretty(metadata).context("Failed to serialize session metadata")?;
74
75 std::fs::write(&metadata_file, content).context("Failed to write session metadata")?;
76
77 Ok(())
78}
79
80pub fn load_session_metadata(project_root: &Path, task_id: &str) -> Result<SessionMetadata> {
82 let metadata_file = session_metadata_path(project_root, task_id);
83
84 if !metadata_file.exists() {
85 anyhow::bail!(
86 "No session metadata found for task '{}'. Was it run in headless mode?",
87 task_id
88 );
89 }
90
91 let content =
92 std::fs::read_to_string(&metadata_file).context("Failed to read session metadata")?;
93
94 let metadata: SessionMetadata =
95 serde_json::from_str(&content).context("Failed to parse session metadata")?;
96
97 Ok(metadata)
98}
99
100pub fn list_sessions(project_root: &Path) -> Result<Vec<SessionMetadata>> {
102 let metadata_dir = headless_metadata_dir(project_root);
103
104 if !metadata_dir.exists() {
105 return Ok(Vec::new());
106 }
107
108 let mut sessions = Vec::new();
109
110 for entry in std::fs::read_dir(&metadata_dir)? {
111 let entry = entry?;
112 let path = entry.path();
113
114 if path.extension().map(|e| e == "json").unwrap_or(false) {
115 if let Ok(content) = std::fs::read_to_string(&path) {
116 if let Ok(metadata) = serde_json::from_str::<SessionMetadata>(&content) {
117 sessions.push(metadata);
118 }
119 }
120 }
121 }
122
123 Ok(sessions)
124}
125
126pub fn delete_session_metadata(project_root: &Path, task_id: &str) -> Result<()> {
128 let metadata_file = session_metadata_path(project_root, task_id);
129
130 if metadata_file.exists() {
131 std::fs::remove_file(&metadata_file).context("Failed to delete session metadata")?;
132 }
133
134 Ok(())
135}
136
137pub fn interactive_command(harness: Harness, session_id: &str) -> Result<Vec<String>> {
139 let binary_path = find_harness_binary(harness)?.to_string();
140
141 match harness {
142 Harness::Claude => Ok(vec![
143 binary_path,
144 "--resume".to_string(),
145 session_id.to_string(),
146 ]),
147 Harness::OpenCode => {
148 Ok(vec![
150 binary_path,
151 "attach".to_string(),
152 "http://localhost:4096".to_string(),
153 "--session".to_string(),
154 session_id.to_string(),
155 ])
156 }
157 Harness::Cursor => Ok(vec![
158 binary_path,
159 "--resume".to_string(),
160 session_id.to_string(),
161 ]),
162 Harness::Rho => Ok(vec![
163 binary_path,
164 "--resume".to_string(),
165 session_id.to_string(),
166 ]),
167 #[cfg(feature = "direct-api")]
168 Harness::DirectApi => anyhow::bail!("Direct API sessions cannot be resumed interactively"),
169 }
170}
171
172pub fn run(project_root: Option<PathBuf>, task_id: &str, harness_arg: Option<&str>) -> Result<()> {
174 let storage = Storage::new(project_root.clone());
175 let root = storage.project_root().to_path_buf();
176
177 let metadata = load_session_metadata(&root, task_id)?;
179
180 let harness_str = harness_arg.unwrap_or(&metadata.harness);
182 let harness = Harness::parse(harness_str)?;
183
184 let cmd_args = interactive_command(harness, &metadata.session_id)?;
186
187 println!("{}", "SCUD Attach".cyan().bold());
189 println!("{}", "═".repeat(50));
190 println!("{:<15} {}", "Task:".dimmed(), task_id.cyan());
191 println!(
192 "{:<15} {}",
193 "Session:".dimmed(),
194 metadata.session_id.dimmed()
195 );
196 println!("{:<15} {}", "Tag:".dimmed(), metadata.tag);
197 println!("{:<15} {}", "Harness:".dimmed(), harness.name().green());
198 if let Some(pid) = metadata.pid {
199 println!("{:<15} {}", "PID:".dimmed(), pid);
200 }
201 println!();
202 println!("{}", "Attaching to session...".cyan());
203 println!();
204
205 #[cfg(unix)]
207 {
208 use std::os::unix::process::CommandExt;
209
210 let err = std::process::Command::new(&cmd_args[0])
211 .args(&cmd_args[1..])
212 .exec();
213
214 anyhow::bail!("Failed to exec '{}': {}", cmd_args[0], err);
216 }
217
218 #[cfg(not(unix))]
219 {
220 let status = std::process::Command::new(&cmd_args[0])
222 .args(&cmd_args[1..])
223 .status()
224 .context("Failed to spawn interactive session")?;
225
226 if !status.success() {
227 anyhow::bail!("Interactive session exited with error");
228 }
229
230 Ok(())
231 }
232}
233
234pub fn run_list(project_root: Option<PathBuf>) -> Result<()> {
236 let storage = Storage::new(project_root);
237 let root = storage.project_root().to_path_buf();
238
239 let sessions = list_sessions(&root)?;
240
241 if sessions.is_empty() {
242 println!("{}", "No headless sessions found.".dimmed());
243 println!(
244 "Run {} to start a headless session.",
245 "scud spawn --headless".cyan()
246 );
247 return Ok(());
248 }
249
250 println!("{}", "Headless Sessions:".cyan().bold());
251 println!();
252
253 for session in &sessions {
254 let pid_info = session
255 .pid
256 .map(|p| format!(" (pid: {})", p))
257 .unwrap_or_default();
258
259 println!(
260 " {} {} [{}]{}",
261 session.task_id.cyan(),
262 session.tag.dimmed(),
263 session.harness.green(),
264 pid_info.dimmed()
265 );
266 }
267
268 println!();
269 println!(
270 "{}",
271 "Use 'scud attach <task_id>' to resume a session.".dimmed()
272 );
273
274 Ok(())
275}
276
277#[cfg(test)]
278mod tests {
279 use super::*;
280 use tempfile::TempDir;
281
282 #[test]
283 fn test_session_metadata_serialization() {
284 let metadata =
285 SessionMetadata::new("1.1", "sess-abc123", "alpha", "claude").with_pid(12345);
286
287 let json = serde_json::to_string(&metadata).unwrap();
288 let parsed: SessionMetadata = serde_json::from_str(&json).unwrap();
289
290 assert_eq!(parsed.task_id, "1.1");
291 assert_eq!(parsed.session_id, "sess-abc123");
292 assert_eq!(parsed.tag, "alpha");
293 assert_eq!(parsed.harness, "claude");
294 assert_eq!(parsed.pid, Some(12345));
295 }
296
297 #[test]
298 fn test_save_and_load_session_metadata() {
299 let temp_dir = TempDir::new().unwrap();
300 let project_root = temp_dir.path();
301
302 let metadata = SessionMetadata::new("2.1", "sess-xyz789", "beta", "opencode");
303
304 save_session_metadata(project_root, &metadata).unwrap();
306
307 let metadata_path = session_metadata_path(project_root, "2.1");
309 assert!(metadata_path.exists());
310
311 let loaded = load_session_metadata(project_root, "2.1").unwrap();
313 assert_eq!(loaded.task_id, "2.1");
314 assert_eq!(loaded.session_id, "sess-xyz789");
315 assert_eq!(loaded.tag, "beta");
316 assert_eq!(loaded.harness, "opencode");
317 }
318
319 #[test]
320 fn test_load_nonexistent_session() {
321 let temp_dir = TempDir::new().unwrap();
322 let project_root = temp_dir.path();
323
324 let result = load_session_metadata(project_root, "nonexistent");
325 assert!(result.is_err());
326 assert!(result
327 .unwrap_err()
328 .to_string()
329 .contains("No session metadata found"));
330 }
331
332 #[test]
333 fn test_list_sessions() {
334 let temp_dir = TempDir::new().unwrap();
335 let project_root = temp_dir.path();
336
337 save_session_metadata(
339 project_root,
340 &SessionMetadata::new("1.1", "sess-1", "alpha", "claude"),
341 )
342 .unwrap();
343 save_session_metadata(
344 project_root,
345 &SessionMetadata::new("2.1", "sess-2", "beta", "opencode"),
346 )
347 .unwrap();
348
349 let sessions = list_sessions(project_root).unwrap();
350 assert_eq!(sessions.len(), 2);
351 }
352
353 #[test]
354 fn test_delete_session_metadata() {
355 let temp_dir = TempDir::new().unwrap();
356 let project_root = temp_dir.path();
357
358 let metadata = SessionMetadata::new("3.1", "sess-delete", "gamma", "claude");
359 save_session_metadata(project_root, &metadata).unwrap();
360
361 let metadata_path = session_metadata_path(project_root, "3.1");
362 assert!(metadata_path.exists());
363
364 delete_session_metadata(project_root, "3.1").unwrap();
365 assert!(!metadata_path.exists());
366 }
367
368 #[test]
369 fn test_default_harness() {
370 let json = r#"{"task_id": "1.1", "session_id": "sess-123", "tag": "test"}"#;
372 let metadata: SessionMetadata = serde_json::from_str(json).unwrap();
373 assert_eq!(metadata.harness, "rho");
374 }
375
376 #[test]
377 fn test_interactive_command_claude() {
378 if find_harness_binary(Harness::Claude).is_err() {
381 return;
382 }
383
384 let cmd = interactive_command(Harness::Claude, "sess-123").unwrap();
385 assert!(cmd.len() >= 3);
386 assert!(cmd[0].contains("claude"));
387 assert_eq!(cmd[1], "--resume");
388 assert_eq!(cmd[2], "sess-123");
389 }
390
391 #[test]
392 fn test_interactive_command_rho_structure_when_available() {
393 let Ok(cmd) = interactive_command(Harness::Rho, "sess-rho-123") else {
394 return;
396 };
397 assert_eq!(cmd.len(), 3);
398 assert!(cmd[0].contains("rho-cli"));
399 assert_eq!(cmd[1], "--resume");
400 assert_eq!(cmd[2], "sess-rho-123");
401 }
402}