Add graph.fabro as primary filename with graph.dot fallback

Write graph.fabro in run dirs and metadata branches. Read with
graph.dot fallback for backward compatibility with existing runs.
Add stack.child_workflow attribute with stack.child_dotfile fallback.
No files renamed yet — fallback paths handle everything.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-13 22:16:10 -04:00
parent e5d1790551
commit f12f261ddd
7 changed files with 45 additions and 21 deletions

View file

@ -456,7 +456,7 @@ pub async fn run_command(
tokio::fs::create_dir_all(&run_dir).await?;
fabro_util::run_log::activate(&run_dir.join("cli.log"))
.context("Failed to activate per-run log")?;
tokio::fs::write(run_dir.join("graph.dot"), &source).await?;
tokio::fs::write(run_dir.join("graph.fabro"), &source).await?;
tokio::fs::write(run_dir.join("run.pid"), std::process::id().to_string()).await?;
if workflow_path.extension().is_some_and(|ext| ext == "toml") {
if let Ok(toml_contents) = tokio::fs::read(workflow_path).await {
@ -1576,8 +1576,10 @@ async fn run_from_branch(
})?;
// Read graph DOT from metadata branch
let source = crate::git::MetadataStore::read_graph_dot(&original_cwd, &run_id)?
.ok_or_else(|| anyhow::anyhow!("no graph.dot found on metadata branch for run {run_id}"))?;
let source =
crate::git::MetadataStore::read_graph_dot(&original_cwd, &run_id)?.ok_or_else(|| {
anyhow::anyhow!("no graph.fabro found on metadata branch for run {run_id}")
})?;
// If --pipeline was also provided, use it instead (allows overriding)
let (mut graph, diagnostics) = if let Some(ref workflow_path) = args.workflow {
@ -1619,7 +1621,7 @@ async fn run_from_branch(
tokio::fs::create_dir_all(&run_dir).await?;
fabro_util::run_log::activate(&run_dir.join("cli.log"))
.context("Failed to activate per-run log")?;
tokio::fs::write(run_dir.join("graph.dot"), &source).await?;
tokio::fs::write(run_dir.join("graph.fabro"), &source).await?;
let base_sha =
crate::git::MetadataStore::read_manifest(&original_cwd, &run_id)?.and_then(|m| m.base_sha);

View file

@ -1211,7 +1211,9 @@ impl WorkflowRunEngine {
if let (Some(_), Some(ref repo_path)) = (&config.meta_branch, &config.host_repo_path) {
let store = crate::git::MetadataStore::new(repo_path, &config.git_author);
let manifest_bytes = serde_json::to_vec_pretty(&manifest).unwrap_or_default();
let dot_source = std::fs::read(config.run_dir.join("graph.dot")).unwrap_or_default();
let dot_source = std::fs::read(config.run_dir.join("graph.fabro"))
.or_else(|_| std::fs::read(config.run_dir.join("graph.dot")))
.unwrap_or_default();
let sandbox_json = std::fs::read(config.run_dir.join("sandbox.json")).ok();
let mut extra_files: Vec<(&str, &[u8])> = Vec::new();
if let Some(ref data) = sandbox_json {

View file

@ -389,7 +389,7 @@ impl MetadataStore {
bs.ensure_branch()
.map_err(|e| git_error(format!("ensure_branch failed: {e}")))?;
let mut entries: Vec<(&str, &[u8])> =
vec![("manifest.json", manifest_json), ("graph.dot", graph_dot)];
vec![("manifest.json", manifest_json), ("graph.fabro", graph_dot)];
entries.extend_from_slice(extra_files);
let msg = self.commit_message("init run");
bs.write_entries(&entries, &msg)
@ -475,8 +475,12 @@ impl MetadataStore {
}
}
/// Read the graph DOT source from the metadata branch. Returns `None` if not found.
/// Read the graph source from the metadata branch. Tries `graph.fabro` first,
/// then falls back to `graph.dot` for backward compatibility. Returns `None` if not found.
pub fn read_graph_dot(repo_path: &Path, run_id: &str) -> Result<Option<String>> {
if let Some(bytes) = Self::read_file(repo_path, run_id, "graph.fabro")? {
return Ok(Some(String::from_utf8_lossy(&bytes).to_string()));
}
match Self::read_file(repo_path, run_id, "graph.dot")? {
Some(bytes) => Ok(Some(String::from_utf8_lossy(&bytes).to_string())),
None => Ok(None),

View file

@ -44,7 +44,9 @@ fn parse_duration_str(s: &str) -> Duration {
}
/// Parse a child workflow graph from node attributes: inline `stack.child_dot_source`
/// (no file inlining) or file path `stack.child_dotfile` (with file inlining).
/// (no file inlining), or file path `stack.child_workflow` / `stack.child_dotfile`
/// (with file inlining). `stack.child_workflow` is preferred; `stack.child_dotfile`
/// is kept for backward compatibility.
fn parse_child_graph(node: &Node) -> Result<Graph, FabroError> {
if let Some(dot) = node
.attrs
@ -55,14 +57,15 @@ fn parse_child_graph(node: &Node) -> Result<Graph, FabroError> {
}
if let Some(path) = node
.attrs
.get("stack.child_dotfile")
.get("stack.child_workflow")
.or_else(|| node.attrs.get("stack.child_dotfile"))
.and_then(|v| v.as_str())
{
let (graph, diagnostics) = prepare_from_file(std::path::Path::new(path))?;
validation::raise_on_errors(&diagnostics)?;
return Ok(graph);
}
Err(FabroError::handler("No child DOT source".to_string()))
Err(FabroError::handler("No child workflow source".to_string()))
}
/// Compute the context diff: keys that changed or were added relative to `before`.

View file

@ -182,20 +182,25 @@ fn format_arc_details_section(conclusion: &Conclusion, dot_source: Option<&str>)
fn parse_dot_summary(dot: &str) -> (String, usize, usize) {
match crate::parser::parse(dot) {
Ok(graph) => (
format!("{}.dot", graph.name),
format!("{}.fabro", graph.name),
graph.nodes.len(),
graph.edges.len(),
),
Err(_) => ("workflow.dot".to_string(), 0, 0),
Err(_) => ("workflow.fabro".to_string(), 0, 0),
}
}
/// Read the DOT graph source from `run_dir/graph.dot`.
/// Read the workflow graph source from `run_dir/graph.fabro` (or `graph.dot` fallback).
fn read_dot_source(run_dir: &Path) -> Option<String> {
let path = run_dir.join("graph.dot");
match std::fs::read_to_string(&path) {
let fabro_path = run_dir.join("graph.fabro");
if let Ok(content) = std::fs::read_to_string(&fabro_path) {
debug!(path = %fabro_path.display(), "Read workflow graph for PR body");
return Some(content);
}
let dot_path = run_dir.join("graph.dot");
match std::fs::read_to_string(&dot_path) {
Ok(content) => {
debug!(path = %path.display(), "Read DOT graph for PR body");
debug!(path = %dot_path.display(), "Read workflow graph for PR body (dot fallback)");
Some(content)
}
Err(_) => None,
@ -767,11 +772,19 @@ mod tests {
#[test]
fn read_dot_source_found() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("graph.dot"), "digraph test {}").unwrap();
std::fs::write(tmp.path().join("graph.fabro"), "digraph test {}").unwrap();
let result = read_dot_source(tmp.path());
assert_eq!(result, Some("digraph test {}".to_string()));
}
#[test]
fn read_dot_source_dot_fallback() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("graph.dot"), "digraph old {}").unwrap();
let result = read_dot_source(tmp.path());
assert_eq!(result, Some("digraph old {}".to_string()));
}
#[test]
fn read_dot_source_not_found() {
let tmp = tempfile::tempdir().unwrap();

View file

@ -1143,8 +1143,8 @@ async fn daytona_git_checkpoint_with_shadow_branch() {
graph.edges.push(Edge::new("work", "exit"));
let dir = tempfile::tempdir().unwrap();
// Write graph.dot so init_run can read it
std::fs::write(dir.path().join("graph.dot"), "digraph {}").unwrap();
// Write graph.fabro so init_run can read it
std::fs::write(dir.path().join("graph.fabro"), "digraph {}").unwrap();
let mut registry = HandlerRegistry::new(Box::new(FileWriterHandler));
registry.register("start", Box::new(StartHandler));

View file

@ -10865,8 +10865,8 @@ async fn git_checkpoint_host_writes_shadow_branch() {
// 4. Set up engine with meta_branch
let run_dir = tempfile::tempdir().unwrap();
// Write graph.dot so init_run can read it
std::fs::write(run_dir.path().join("graph.dot"), "digraph {}").unwrap();
// Write graph.fabro so init_run can read it
std::fs::write(run_dir.path().join("graph.fabro"), "digraph {}").unwrap();
let emitter = EventEmitter::new();
let env: Arc<dyn fabro_agent::Sandbox> =