From f12f261ddd430fbb9c32ebcea97e6e3796bd1fee Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Fri, 13 Mar 2026 22:16:10 -0400 Subject: [PATCH] Add graph.fabro as primary filename with graph.dot fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- lib/crates/fabro-workflows/src/cli/run.rs | 10 ++++--- lib/crates/fabro-workflows/src/engine.rs | 4 ++- lib/crates/fabro-workflows/src/git.rs | 8 ++++-- .../src/handler/manager_loop.rs | 9 ++++--- .../fabro-workflows/src/pull_request.rs | 27 ++++++++++++++----- .../tests/daytona_integration.rs | 4 +-- .../fabro-workflows/tests/integration.rs | 4 +-- 7 files changed, 45 insertions(+), 21 deletions(-) diff --git a/lib/crates/fabro-workflows/src/cli/run.rs b/lib/crates/fabro-workflows/src/cli/run.rs index a57ef1f45..76005250e 100644 --- a/lib/crates/fabro-workflows/src/cli/run.rs +++ b/lib/crates/fabro-workflows/src/cli/run.rs @@ -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); diff --git a/lib/crates/fabro-workflows/src/engine.rs b/lib/crates/fabro-workflows/src/engine.rs index daea4d6fb..3c9ab8dae 100644 --- a/lib/crates/fabro-workflows/src/engine.rs +++ b/lib/crates/fabro-workflows/src/engine.rs @@ -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 { diff --git a/lib/crates/fabro-workflows/src/git.rs b/lib/crates/fabro-workflows/src/git.rs index 1ebdee6f7..a608fb231 100644 --- a/lib/crates/fabro-workflows/src/git.rs +++ b/lib/crates/fabro-workflows/src/git.rs @@ -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> { + 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), diff --git a/lib/crates/fabro-workflows/src/handler/manager_loop.rs b/lib/crates/fabro-workflows/src/handler/manager_loop.rs index 29f8c7c7e..8038f93f0 100644 --- a/lib/crates/fabro-workflows/src/handler/manager_loop.rs +++ b/lib/crates/fabro-workflows/src/handler/manager_loop.rs @@ -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 { if let Some(dot) = node .attrs @@ -55,14 +57,15 @@ fn parse_child_graph(node: &Node) -> Result { } 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`. diff --git a/lib/crates/fabro-workflows/src/pull_request.rs b/lib/crates/fabro-workflows/src/pull_request.rs index 9e8649847..d08c164b1 100644 --- a/lib/crates/fabro-workflows/src/pull_request.rs +++ b/lib/crates/fabro-workflows/src/pull_request.rs @@ -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 { - 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(); diff --git a/lib/crates/fabro-workflows/tests/daytona_integration.rs b/lib/crates/fabro-workflows/tests/daytona_integration.rs index 3c0671d00..f33507048 100644 --- a/lib/crates/fabro-workflows/tests/daytona_integration.rs +++ b/lib/crates/fabro-workflows/tests/daytona_integration.rs @@ -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)); diff --git a/lib/crates/fabro-workflows/tests/integration.rs b/lib/crates/fabro-workflows/tests/integration.rs index 1cf91dcec..b89825f74 100644 --- a/lib/crates/fabro-workflows/tests/integration.rs +++ b/lib/crates/fabro-workflows/tests/integration.rs @@ -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 =