use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct RunDirectory {
root: PathBuf,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct RunManifest {
pub run_id: String,
pub pipeline_name: String,
pub pipeline_file: String,
pub started_at: String,
pub status: String,
}
impl RunDirectory {
pub fn create(base_dir: &Path, run_id: &str) -> Result<Self> {
let root = base_dir.join("runs").join(run_id);
std::fs::create_dir_all(&root).context("Failed to create run directory")?;
std::fs::create_dir_all(root.join("artifacts"))
.context("Failed to create artifacts directory")?;
Ok(Self { root })
}
pub fn open(base_dir: &Path, run_id: &str) -> Result<Self> {
let root = base_dir.join("runs").join(run_id);
if !root.exists() {
anyhow::bail!("Run directory not found: {}", root.display());
}
Ok(Self { root })
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn checkpoint_path(&self) -> PathBuf {
self.root.join("checkpoint.json")
}
pub fn manifest_path(&self) -> PathBuf {
self.root.join("manifest.json")
}
pub fn node_dir(&self, node_id: &str) -> Result<PathBuf> {
let dir = self.root.join(sanitize_id(node_id));
std::fs::create_dir_all(&dir)
.context(format!("Failed to create node directory for '{}'", node_id))?;
Ok(dir)
}
pub fn write_prompt(&self, node_id: &str, prompt: &str) -> Result<()> {
let dir = self.node_dir(node_id)?;
std::fs::write(dir.join("prompt.md"), prompt).context("Failed to write prompt file")?;
Ok(())
}
pub fn write_response(&self, node_id: &str, response: &str) -> Result<()> {
let dir = self.node_dir(node_id)?;
std::fs::write(dir.join("response.md"), response)
.context("Failed to write response file")?;
Ok(())
}
pub fn write_status(&self, node_id: &str, status: &serde_json::Value) -> Result<()> {
let dir = self.node_dir(node_id)?;
let json = serde_json::to_string_pretty(status)?;
std::fs::write(dir.join("status.json"), json).context("Failed to write status file")?;
Ok(())
}
pub fn write_manifest(&self, manifest: &RunManifest) -> Result<()> {
let json = serde_json::to_string_pretty(manifest)?;
std::fs::write(self.manifest_path(), json).context("Failed to write manifest")?;
Ok(())
}
pub fn read_manifest(&self) -> Result<RunManifest> {
let json =
std::fs::read_to_string(self.manifest_path()).context("Failed to read manifest")?;
serde_json::from_str(&json).context("Failed to parse manifest")
}
pub fn write_artifact(&self, name: &str, content: &[u8]) -> Result<PathBuf> {
let path = self.root.join("artifacts").join(name);
std::fs::write(&path, content).context("Failed to write artifact")?;
Ok(path)
}
pub fn read_response(&self, node_id: &str) -> Result<String> {
let path = self.root.join(sanitize_id(node_id)).join("response.md");
std::fs::read_to_string(&path).context("Failed to read response")
}
}
fn sanitize_id(id: &str) -> String {
id.replace(|c: char| !c.is_alphanumeric() && c != '_' && c != '-', "_")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_run_directory_lifecycle() {
let dir = tempfile::tempdir().unwrap();
let run = RunDirectory::create(dir.path(), "test-run-001").unwrap();
assert!(run.root().exists());
assert!(run.root().join("artifacts").exists());
run.write_prompt("task_a", "Do the thing").unwrap();
run.write_response("task_a", "I did the thing").unwrap();
assert_eq!(run.read_response("task_a").unwrap(), "I did the thing");
let manifest = RunManifest {
run_id: "test-run-001".into(),
pipeline_name: "test".into(),
pipeline_file: "test.dot".into(),
started_at: "2025-01-01T00:00:00Z".into(),
status: "running".into(),
};
run.write_manifest(&manifest).unwrap();
let loaded = run.read_manifest().unwrap();
assert_eq!(loaded.run_id, "test-run-001");
let opened = RunDirectory::open(dir.path(), "test-run-001").unwrap();
assert!(opened.root().exists());
}
#[test]
fn test_sanitize_id() {
assert_eq!(sanitize_id("simple"), "simple");
assert_eq!(sanitize_id("with spaces"), "with_spaces");
assert_eq!(sanitize_id("node-1"), "node-1");
assert_eq!(sanitize_id("a/b.c"), "a_b_c");
}
}