Skip to main content

scud/attractor/
run_directory.rs

1//! Filesystem layout for pipeline runs.
2//!
3//! ```text
4//! runs/{run_id}/
5//!     checkpoint.json
6//!     manifest.json
7//!     {node_id}/
8//!         status.json
9//!         prompt.md
10//!         response.md
11//!     artifacts/
12//! ```
13
14use anyhow::{Context, Result};
15use serde::{Deserialize, Serialize};
16use std::path::{Path, PathBuf};
17
18/// Manages the filesystem layout for a single pipeline run.
19#[derive(Debug, Clone)]
20pub struct RunDirectory {
21    root: PathBuf,
22}
23
24/// Manifest for a pipeline run.
25#[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    /// Create a new run directory.
36    ///
37    /// Creates the directory structure if it doesn't exist.
38    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    /// Open an existing run directory.
47    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    /// Get the root path of this run directory.
56    pub fn root(&self) -> &Path {
57        &self.root
58    }
59
60    /// Get the checkpoint file path.
61    pub fn checkpoint_path(&self) -> PathBuf {
62        self.root.join("checkpoint.json")
63    }
64
65    /// Get the manifest file path.
66    pub fn manifest_path(&self) -> PathBuf {
67        self.root.join("manifest.json")
68    }
69
70    /// Get the node directory path, creating it if needed.
71    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    /// Write the prompt for a node.
79    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    /// Write the response for a node.
86    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    /// Write node status.
94    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    /// Write the run manifest.
102    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    /// Read the run manifest.
109    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    /// Write an artifact file.
116    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    /// Read the response for a node.
123    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
129/// Sanitize a node ID for use as a directory name.
130fn 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        // Write and read prompt/response
147        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        // Write manifest
152        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        // Open existing
164        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}