scud/attractor/
run_directory.rs1use anyhow::{Context, Result};
15use serde::{Deserialize, Serialize};
16use std::path::{Path, PathBuf};
17
18#[derive(Debug, Clone)]
20pub struct RunDirectory {
21 root: PathBuf,
22}
23
24#[derive(Debug, Serialize, Deserialize)]
26pub struct RunManifest {
27 pub run_id: String,
28 pub pipeline_name: String,
29 pub pipeline_file: String,
30 pub started_at: String,
31 pub status: String,
32}
33
34impl RunDirectory {
35 pub fn create(base_dir: &Path, run_id: &str) -> Result<Self> {
39 let root = base_dir.join("runs").join(run_id);
40 std::fs::create_dir_all(&root).context("Failed to create run directory")?;
41 std::fs::create_dir_all(root.join("artifacts"))
42 .context("Failed to create artifacts directory")?;
43 Ok(Self { root })
44 }
45
46 pub fn open(base_dir: &Path, run_id: &str) -> Result<Self> {
48 let root = base_dir.join("runs").join(run_id);
49 if !root.exists() {
50 anyhow::bail!("Run directory not found: {}", root.display());
51 }
52 Ok(Self { root })
53 }
54
55 pub fn root(&self) -> &Path {
57 &self.root
58 }
59
60 pub fn checkpoint_path(&self) -> PathBuf {
62 self.root.join("checkpoint.json")
63 }
64
65 pub fn manifest_path(&self) -> PathBuf {
67 self.root.join("manifest.json")
68 }
69
70 pub fn node_dir(&self, node_id: &str) -> Result<PathBuf> {
72 let dir = self.root.join(sanitize_id(node_id));
73 std::fs::create_dir_all(&dir)
74 .context(format!("Failed to create node directory for '{}'", node_id))?;
75 Ok(dir)
76 }
77
78 pub fn write_prompt(&self, node_id: &str, prompt: &str) -> Result<()> {
80 let dir = self.node_dir(node_id)?;
81 std::fs::write(dir.join("prompt.md"), prompt)
82 .context("Failed to write prompt file")?;
83 Ok(())
84 }
85
86 pub fn write_response(&self, node_id: &str, response: &str) -> Result<()> {
88 let dir = self.node_dir(node_id)?;
89 std::fs::write(dir.join("response.md"), response)
90 .context("Failed to write response file")?;
91 Ok(())
92 }
93
94 pub fn write_status(&self, node_id: &str, status: &serde_json::Value) -> Result<()> {
96 let dir = self.node_dir(node_id)?;
97 let json = serde_json::to_string_pretty(status)?;
98 std::fs::write(dir.join("status.json"), json)
99 .context("Failed to write status file")?;
100 Ok(())
101 }
102
103 pub fn write_manifest(&self, manifest: &RunManifest) -> Result<()> {
105 let json = serde_json::to_string_pretty(manifest)?;
106 std::fs::write(self.manifest_path(), json)
107 .context("Failed to write manifest")?;
108 Ok(())
109 }
110
111 pub fn read_manifest(&self) -> Result<RunManifest> {
113 let json = std::fs::read_to_string(self.manifest_path())
114 .context("Failed to read manifest")?;
115 serde_json::from_str(&json).context("Failed to parse manifest")
116 }
117
118 pub fn write_artifact(&self, name: &str, content: &[u8]) -> Result<PathBuf> {
120 let path = self.root.join("artifacts").join(name);
121 std::fs::write(&path, content).context("Failed to write artifact")?;
122 Ok(path)
123 }
124
125 pub fn read_response(&self, node_id: &str) -> Result<String> {
127 let path = self.root.join(sanitize_id(node_id)).join("response.md");
128 std::fs::read_to_string(&path).context("Failed to read response")
129 }
130}
131
132fn sanitize_id(id: &str) -> String {
134 id.replace(|c: char| !c.is_alphanumeric() && c != '_' && c != '-', "_")
135}
136
137#[cfg(test)]
138mod tests {
139 use super::*;
140
141 #[test]
142 fn test_run_directory_lifecycle() {
143 let dir = tempfile::tempdir().unwrap();
144 let run = RunDirectory::create(dir.path(), "test-run-001").unwrap();
145
146 assert!(run.root().exists());
147 assert!(run.root().join("artifacts").exists());
148
149 run.write_prompt("task_a", "Do the thing").unwrap();
151 run.write_response("task_a", "I did the thing").unwrap();
152 assert_eq!(run.read_response("task_a").unwrap(), "I did the thing");
153
154 let manifest = RunManifest {
156 run_id: "test-run-001".into(),
157 pipeline_name: "test".into(),
158 pipeline_file: "test.dot".into(),
159 started_at: "2025-01-01T00:00:00Z".into(),
160 status: "running".into(),
161 };
162 run.write_manifest(&manifest).unwrap();
163 let loaded = run.read_manifest().unwrap();
164 assert_eq!(loaded.run_id, "test-run-001");
165
166 let opened = RunDirectory::open(dir.path(), "test-run-001").unwrap();
168 assert!(opened.root().exists());
169 }
170
171 #[test]
172 fn test_sanitize_id() {
173 assert_eq!(sanitize_id("simple"), "simple");
174 assert_eq!(sanitize_id("with spaces"), "with_spaces");
175 assert_eq!(sanitize_id("node-1"), "node-1");
176 assert_eq!(sanitize_id("a/b.c"), "a_b_c");
177 }
178}