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)
82            .context("Failed to write prompt file")?;
83        Ok(())
84    }
85
86    /// Write the response for a node.
87    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    /// Write node status.
95    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    /// Write the run manifest.
104    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    /// Read the run manifest.
112    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    /// Write an artifact file.
119    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    /// Read the response for a node.
126    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
132/// Sanitize a node ID for use as a directory name.
133fn 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        // Write and read prompt/response
150        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        // Write manifest
155        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        // Open existing
167        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}