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).context("Failed to write prompt file")?;
82 Ok(())
83 }
84
85 pub fn write_response(&self, node_id: &str, response: &str) -> Result<()> {
87 let dir = self.node_dir(node_id)?;
88 std::fs::write(dir.join("response.md"), response)
89 .context("Failed to write response file")?;
90 Ok(())
91 }
92
93 pub fn write_status(&self, node_id: &str, status: &serde_json::Value) -> Result<()> {
95 let dir = self.node_dir(node_id)?;
96 let json = serde_json::to_string_pretty(status)?;
97 std::fs::write(dir.join("status.json"), json).context("Failed to write status file")?;
98 Ok(())
99 }
100
101 pub fn write_manifest(&self, manifest: &RunManifest) -> Result<()> {
103 let json = serde_json::to_string_pretty(manifest)?;
104 std::fs::write(self.manifest_path(), json).context("Failed to write manifest")?;
105 Ok(())
106 }
107
108 pub fn read_manifest(&self) -> Result<RunManifest> {
110 let json =
111 std::fs::read_to_string(self.manifest_path()).context("Failed to read manifest")?;
112 serde_json::from_str(&json).context("Failed to parse manifest")
113 }
114
115 pub fn write_artifact(&self, name: &str, content: &[u8]) -> Result<PathBuf> {
117 let path = self.root.join("artifacts").join(name);
118 std::fs::write(&path, content).context("Failed to write artifact")?;
119 Ok(path)
120 }
121
122 pub fn read_response(&self, node_id: &str) -> Result<String> {
124 let path = self.root.join(sanitize_id(node_id)).join("response.md");
125 std::fs::read_to_string(&path).context("Failed to read response")
126 }
127}
128
129fn sanitize_id(id: &str) -> String {
131 id.replace(|c: char| !c.is_alphanumeric() && c != '_' && c != '-', "_")
132}
133
134#[cfg(test)]
135mod tests {
136 use super::*;
137
138 #[test]
139 fn test_run_directory_lifecycle() {
140 let dir = tempfile::tempdir().unwrap();
141 let run = RunDirectory::create(dir.path(), "test-run-001").unwrap();
142
143 assert!(run.root().exists());
144 assert!(run.root().join("artifacts").exists());
145
146 run.write_prompt("task_a", "Do the thing").unwrap();
148 run.write_response("task_a", "I did the thing").unwrap();
149 assert_eq!(run.read_response("task_a").unwrap(), "I did the thing");
150
151 let manifest = RunManifest {
153 run_id: "test-run-001".into(),
154 pipeline_name: "test".into(),
155 pipeline_file: "test.dot".into(),
156 started_at: "2025-01-01T00:00:00Z".into(),
157 status: "running".into(),
158 };
159 run.write_manifest(&manifest).unwrap();
160 let loaded = run.read_manifest().unwrap();
161 assert_eq!(loaded.run_id, "test-run-001");
162
163 let opened = RunDirectory::open(dir.path(), "test-run-001").unwrap();
165 assert!(opened.root().exists());
166 }
167
168 #[test]
169 fn test_sanitize_id() {
170 assert_eq!(sanitize_id("simple"), "simple");
171 assert_eq!(sanitize_id("with spaces"), "with_spaces");
172 assert_eq!(sanitize_id("node-1"), "node-1");
173 assert_eq!(sanitize_id("a/b.c"), "a_b_c");
174 }
175}