fabro/lib/crates/fabro-workflows/tests/integration.rs
Bryan Helmkamp f39b37da64
Add auto-merge support for pull requests via GitHub GraphQL API
When `auto_merge = true` is set in `[pull_request]` config, Fabro enables
GitHub's auto-merge on created PRs using the `enablePullRequestAutoMerge`
GraphQL mutation. Auto-merge implies `draft = false` since GitHub doesn't
allow auto-merge on draft PRs. A `merge_strategy` field (squash/merge/rebase,
default squash) controls the merge method. Failures to enable auto-merge
(e.g. repo doesn't have the setting enabled) warn but don't fail the run.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 08:37:24 -04:00

13842 lines
471 KiB
Rust

use std::collections::VecDeque;
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use fabro_llm::provider::Provider;
use fabro_workflows::checkpoint::Checkpoint;
use fabro_workflows::cli::backend::AgentApiBackend;
use fabro_workflows::context::Context;
use fabro_workflows::engine::{RunConfig, WorkflowRunEngine};
use fabro_workflows::error::FabroError;
use fabro_workflows::event::{EventEmitter, WorkflowRunEvent};
use fabro_workflows::graph::{AttrValue, Edge, Graph, Node};
use fabro_workflows::handler::agent::{AgentHandler, CodergenBackend, CodergenResult};
use fabro_workflows::handler::command::CommandHandler;
use fabro_workflows::handler::conditional::ConditionalHandler;
use fabro_workflows::handler::default_registry;
use fabro_workflows::handler::exit::ExitHandler;
use fabro_workflows::handler::human::HumanHandler;
use fabro_workflows::handler::manager_loop::SubWorkflowHandler;
use fabro_workflows::handler::start::StartHandler;
use fabro_workflows::handler::wait::WaitHandler;
use fabro_workflows::handler::{Handler, HandlerRegistry};
use fabro_workflows::interviewer::auto_approve::AutoApproveInterviewer;
use fabro_workflows::interviewer::queue::QueueInterviewer;
use fabro_workflows::interviewer::recording::RecordingInterviewer;
use fabro_workflows::interviewer::{Answer, AnswerValue, Interviewer};
use fabro_workflows::outcome::{Outcome, StageStatus};
use fabro_workflows::parser::parse;
use fabro_workflows::stylesheet::{apply_stylesheet, parse_stylesheet};
use fabro_workflows::transform::{
StylesheetApplicationTransform, Transform, VariableExpansionTransform,
};
use fabro_workflows::validation::{validate, validate_or_raise, Severity};
fn local_env() -> Arc<dyn fabro_agent::Sandbox> {
Arc::new(fabro_agent::LocalSandbox::new(
std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")),
))
}
// ---------------------------------------------------------------------------
// 1. Parse and validate all 3 spec examples (Section 2.13)
// ---------------------------------------------------------------------------
#[test]
fn parse_and_validate_simple_linear() {
let input = r#"digraph Simple {
graph [goal="Run tests and report"]
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
run_tests [label="Run Tests", prompt="Run the test suite and report results"]
report [label="Report", prompt="Summarize the test results"]
start -> run_tests -> report -> exit
}"#;
let graph = parse(input).expect("parsing should succeed");
assert_eq!(graph.name, "Simple");
assert_eq!(graph.goal(), "Run tests and report");
assert_eq!(graph.nodes.len(), 4);
assert_eq!(graph.edges.len(), 3);
assert!(graph.find_start_node().is_some());
assert!(graph.find_exit_node().is_some());
let diagnostics = validate_or_raise(&graph, &[]).expect("validation should pass");
let errors: Vec<_> = diagnostics
.iter()
.filter(|d| d.severity == fabro_workflows::validation::Severity::Error)
.collect();
assert!(errors.is_empty(), "expected no validation errors");
}
#[test]
fn parse_and_validate_branching_with_conditions() {
let input = r#"digraph Branch {
graph [goal="Implement and validate a feature"]
rankdir=LR
node [shape=box, timeout="900s"]
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
plan [label="Plan", prompt="Plan the implementation"]
implement [label="Implement", prompt="Implement the plan"]
validate [label="Validate", prompt="Run tests"]
gate [shape=diamond, label="Tests passing?"]
start -> plan -> implement -> validate -> gate
gate -> exit [label="Yes", condition="outcome=success"]
gate -> implement [label="No"]
}"#;
let graph = parse(input).expect("parsing should succeed");
assert_eq!(graph.name, "Branch");
assert_eq!(graph.nodes.len(), 6);
assert_eq!(graph.edges.len(), 6);
let gate_exit = graph
.edges
.iter()
.find(|e| e.from == "gate" && e.to == "exit")
.expect("gate -> exit edge should exist");
assert_eq!(gate_exit.condition(), Some("outcome=success"));
let gate_impl = graph
.edges
.iter()
.find(|e| e.from == "gate" && e.to == "implement")
.expect("gate -> implement edge should exist");
assert_eq!(gate_impl.condition(), None);
let diagnostics = validate_or_raise(&graph, &[]).expect("validation should pass");
let errors: Vec<_> = diagnostics
.iter()
.filter(|d| d.severity == fabro_workflows::validation::Severity::Error)
.collect();
assert!(errors.is_empty(), "expected no validation errors");
}
#[test]
fn parse_and_validate_human_gate() {
let input = r#"digraph Review {
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
review_gate [
shape=hexagon,
label="Review Changes",
type="human"
]
start -> review_gate
review_gate -> ship_it [label="[A] Approve"]
review_gate -> fixes [label="[F] Fix"]
ship_it -> exit
fixes -> review_gate
}"#;
let graph = parse(input).expect("parsing should succeed");
assert_eq!(graph.name, "Review");
assert_eq!(graph.nodes.len(), 5);
assert_eq!(graph.edges.len(), 5);
let gate = &graph.nodes["review_gate"];
assert_eq!(gate.node_type(), Some("human"));
assert_eq!(gate.shape(), "hexagon");
assert_eq!(gate.label(), "Review Changes");
let diagnostics = validate_or_raise(&graph, &[]).expect("validation should pass");
let errors: Vec<_> = diagnostics
.iter()
.filter(|d| d.severity == fabro_workflows::validation::Severity::Error)
.collect();
assert!(errors.is_empty(), "expected no validation errors");
}
// ---------------------------------------------------------------------------
// 2. End-to-end linear pipeline
// ---------------------------------------------------------------------------
fn make_linear_registry() -> HandlerRegistry {
let mut registry = HandlerRegistry::new(Box::new(AgentHandler::new(None)));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("agent", Box::new(AgentHandler::new(None)));
registry
}
#[tokio::test]
async fn end_to_end_linear_pipeline() {
let input = r#"digraph Linear {
graph [goal="Build the feature"]
start [shape=Mdiamond]
exit [shape=Msquare]
codergen_step [shape=box, label="Code", prompt="Implement the feature"]
start -> codergen_step -> exit
}"#;
let graph = parse(input).expect("parse should succeed");
validate_or_raise(&graph, &[]).expect("validation should pass");
let dir = tempfile::tempdir().unwrap();
let engine = WorkflowRunEngine::new(
make_linear_registry(),
Arc::new(EventEmitter::new()),
local_env(),
);
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let outcome = engine
.run(&graph, &config)
.await
.expect("run should succeed");
assert_eq!(outcome.status, StageStatus::Success);
// Checkpoint should exist
let checkpoint_path = dir.path().join("checkpoint.json");
assert!(checkpoint_path.exists(), "checkpoint.json should exist");
let checkpoint = Checkpoint::load(&checkpoint_path).expect("checkpoint should load");
assert!(checkpoint.completed_nodes.contains(&"start".to_string()));
assert!(checkpoint
.completed_nodes
.contains(&"codergen_step".to_string()));
// Codergen handler writes prompt.md, response.md, status.json
let stage_dir = dir.path().join("nodes").join("codergen_step");
assert!(
stage_dir.join("prompt.md").exists(),
"prompt.md should exist"
);
assert!(
stage_dir.join("response.md").exists(),
"response.md should exist"
);
assert!(
stage_dir.join("status.json").exists(),
"status.json should exist"
);
let prompt_content = std::fs::read_to_string(stage_dir.join("prompt.md")).unwrap();
assert!(
prompt_content.ends_with("Implement the feature"),
"prompt should end with original prompt, got: {prompt_content}"
);
}
// ---------------------------------------------------------------------------
// 3. End-to-end branching pipeline
// ---------------------------------------------------------------------------
#[tokio::test]
async fn end_to_end_branching_pipeline() {
// Build a graph:
// start -> work -> gate (diamond)
// gate -> success_path [condition="outcome=success"]
// gate -> fail_path [condition="outcome=fail"]
// success_path -> exit
// fail_path -> exit
//
// Since work defaults to codergen (shape=box) which returns SUCCESS,
// the engine should route gate -> success_path via condition match.
let mut graph = Graph::new("BranchTest");
graph.attrs.insert(
"goal".to_string(),
AttrValue::String("Test branching".to_string()),
);
let mut start = Node::new("start");
start.attrs.insert(
"shape".to_string(),
AttrValue::String("Mdiamond".to_string()),
);
graph.nodes.insert("start".to_string(), start);
let mut exit = Node::new("exit");
exit.attrs.insert(
"shape".to_string(),
AttrValue::String("Msquare".to_string()),
);
graph.nodes.insert("exit".to_string(), exit);
let mut work = Node::new("work");
work.attrs
.insert("shape".to_string(), AttrValue::String("box".to_string()));
work.attrs.insert(
"prompt".to_string(),
AttrValue::String("Do work".to_string()),
);
graph.nodes.insert("work".to_string(), work);
let mut gate = Node::new("gate");
gate.attrs.insert(
"shape".to_string(),
AttrValue::String("diamond".to_string()),
);
graph.nodes.insert("gate".to_string(), gate);
graph
.nodes
.insert("success_path".to_string(), Node::new("success_path"));
graph
.nodes
.insert("fail_path".to_string(), Node::new("fail_path"));
graph.edges.push(Edge::new("start", "work"));
graph.edges.push(Edge::new("work", "gate"));
let mut gate_success = Edge::new("gate", "success_path");
gate_success.attrs.insert(
"condition".to_string(),
AttrValue::String("outcome=success".to_string()),
);
graph.edges.push(gate_success);
let mut gate_fail = Edge::new("gate", "fail_path");
gate_fail.attrs.insert(
"condition".to_string(),
AttrValue::String("outcome=fail".to_string()),
);
graph.edges.push(gate_fail);
graph.edges.push(Edge::new("success_path", "exit"));
graph.edges.push(Edge::new("fail_path", "exit"));
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(AgentHandler::new(None)));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("agent", Box::new(AgentHandler::new(None)));
registry.register("conditional", Box::new(ConditionalHandler));
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let outcome = engine
.run(&graph, &config)
.await
.expect("run should succeed");
assert_eq!(outcome.status, StageStatus::Success);
let checkpoint = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
assert!(
checkpoint
.completed_nodes
.contains(&"success_path".to_string()),
"should have traversed success_path"
);
assert!(
!checkpoint
.completed_nodes
.contains(&"fail_path".to_string()),
"should NOT have traversed fail_path"
);
}
// ---------------------------------------------------------------------------
// 4. End-to-end human gate pipeline with QueueInterviewer
// ---------------------------------------------------------------------------
#[tokio::test]
async fn end_to_end_human_gate_pipeline() {
// Build a graph:
// start -> gate (hexagon, type=wait.human)
// gate -> approve [label="[A] Approve"]
// gate -> reject [label="[R] Reject"]
// approve -> exit
// reject -> exit
//
// QueueInterviewer pre-filled to select "R" -> should route to reject
let mut graph = Graph::new("HumanGateTest");
let mut start = Node::new("start");
start.attrs.insert(
"shape".to_string(),
AttrValue::String("Mdiamond".to_string()),
);
graph.nodes.insert("start".to_string(), start);
let mut exit = Node::new("exit");
exit.attrs.insert(
"shape".to_string(),
AttrValue::String("Msquare".to_string()),
);
graph.nodes.insert("exit".to_string(), exit);
let mut gate = Node::new("gate");
gate.attrs.insert(
"shape".to_string(),
AttrValue::String("hexagon".to_string()),
);
gate.attrs
.insert("type".to_string(), AttrValue::String("human".to_string()));
gate.attrs.insert(
"label".to_string(),
AttrValue::String("Review Changes".to_string()),
);
graph.nodes.insert("gate".to_string(), gate);
graph
.nodes
.insert("approve".to_string(), Node::new("approve"));
graph
.nodes
.insert("reject".to_string(), Node::new("reject"));
graph.edges.push(Edge::new("start", "gate"));
let mut e_approve = Edge::new("gate", "approve");
e_approve.attrs.insert(
"label".to_string(),
AttrValue::String("[A] Approve".to_string()),
);
graph.edges.push(e_approve);
let mut e_reject = Edge::new("gate", "reject");
e_reject.attrs.insert(
"label".to_string(),
AttrValue::String("[R] Reject".to_string()),
);
graph.edges.push(e_reject);
graph.edges.push(Edge::new("approve", "exit"));
graph.edges.push(Edge::new("reject", "exit"));
// Pre-fill the queue with an answer selecting "R"
let answers = VecDeque::from([Answer {
value: AnswerValue::Selected("R".to_string()),
selected_option: None,
selected_options: Vec::new(),
text: None,
}]);
let interviewer = Arc::new(QueueInterviewer::new(answers));
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("human", Box::new(HumanHandler::new(interviewer)));
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let outcome = engine
.run(&graph, &config)
.await
.expect("run should succeed");
assert_eq!(outcome.status, StageStatus::Success);
let checkpoint = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
assert!(
checkpoint.completed_nodes.contains(&"reject".to_string()),
"should have traversed reject path"
);
assert!(
!checkpoint.completed_nodes.contains(&"approve".to_string()),
"should NOT have traversed approve path"
);
}
// ---------------------------------------------------------------------------
// 5. Goal gate enforcement
// ---------------------------------------------------------------------------
/// A custom handler that always returns FAIL for testing goal gate enforcement.
struct AlwaysFailHandler;
#[async_trait::async_trait]
impl Handler for AlwaysFailHandler {
async fn execute(
&self,
node: &Node,
_context: &fabro_workflows::context::Context,
_graph: &Graph,
_run_dir: &Path,
_services: &fabro_workflows::handler::EngineServices,
) -> Result<Outcome, fabro_workflows::error::FabroError> {
Ok(Outcome::fail_classify(format!(
"forced failure for {}",
node.id
)))
}
}
#[tokio::test]
async fn goal_gate_routes_to_retry_target_on_failure() {
// Pipeline:
// start -> gated_work -> exit
// gated_work has goal_gate=true, retry_target=start
// gated_work always returns FAIL
//
// When engine reaches exit, it checks goal gates and finds gated_work failed.
// It should route back to retry_target (start).
//
// To avoid infinite loops, we set max_retries=0 on gated_work so it fails
// immediately each time. After looping once (start -> gated_work -> exit -> start
// -> gated_work -> exit), if goal gate is still unsatisfied and no retry_target
// changes, we need to limit iterations. The engine itself doesn't limit loops,
// so we test a simpler scenario: verify the error when retry_target is missing.
// Test: goal_gate with NO retry_target returns an error
let mut graph = Graph::new("GoalGateNoRetry");
let mut start = Node::new("start");
start.attrs.insert(
"shape".to_string(),
AttrValue::String("Mdiamond".to_string()),
);
graph.nodes.insert("start".to_string(), start);
let mut exit = Node::new("exit");
exit.attrs.insert(
"shape".to_string(),
AttrValue::String("Msquare".to_string()),
);
graph.nodes.insert("exit".to_string(), exit);
let mut gated_work = Node::new("gated_work");
gated_work
.attrs
.insert("goal_gate".to_string(), AttrValue::Boolean(true));
gated_work
.attrs
.insert("max_retries".to_string(), AttrValue::Integer(0));
gated_work.attrs.insert(
"type".to_string(),
AttrValue::String("always_fail".to_string()),
);
graph.nodes.insert("gated_work".to_string(), gated_work);
graph.edges.push(Edge::new("start", "gated_work"));
graph.edges.push(Edge::new("gated_work", "exit"));
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("always_fail", Box::new(AlwaysFailHandler));
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let result = engine.run(&graph, &config).await;
assert!(
result.is_ok(),
"goal gate unsatisfied with no retry_target should return Ok(fail outcome)"
);
let outcome = result.unwrap();
assert_eq!(
outcome.status,
StageStatus::Fail,
"pipeline outcome should be 'fail' when goal gate unsatisfied"
);
let failure_reason = outcome.failure_reason().unwrap_or_default();
assert!(
failure_reason.contains("goal gate unsatisfied"),
"failure_reason should mention goal gate, got: {failure_reason}"
);
}
#[tokio::test]
async fn goal_gate_routes_to_retry_target_when_present() {
// Pipeline:
// start -> gated_work -> exit
// gated_work has goal_gate=true, retry_target=start
// gated_work always fails via AlwaysFailHandler.
//
// When engine reaches exit and finds goal gate unsatisfied, it should route
// to the retry_target. Since AlwaysFailHandler always fails, this creates a
// loop. However, the gated_work node will emit a FAIL outcome, and the
// edge gated_work -> exit is unconditional, so it still reaches exit. After
// the first retry (start -> gated_work -> exit), goal gate is still failed
// and retry_target is still start, so it loops. To prevent an infinite loop
// in tests, we use a custom handler that fails the first time and succeeds
// the second time.
struct FailThenSucceedHandler {
call_count: std::sync::atomic::AtomicU32,
}
#[async_trait::async_trait]
impl Handler for FailThenSucceedHandler {
async fn execute(
&self,
_node: &Node,
_context: &fabro_workflows::context::Context,
_graph: &Graph,
_run_dir: &Path,
_services: &fabro_workflows::handler::EngineServices,
) -> Result<Outcome, fabro_workflows::error::FabroError> {
let count = self
.call_count
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
if count == 0 {
Ok(Outcome::fail_classify("first attempt fails"))
} else {
Ok(Outcome::success())
}
}
}
let mut graph = Graph::new("GoalGateRetry");
let mut start = Node::new("start");
start.attrs.insert(
"shape".to_string(),
AttrValue::String("Mdiamond".to_string()),
);
graph.nodes.insert("start".to_string(), start);
let mut exit = Node::new("exit");
exit.attrs.insert(
"shape".to_string(),
AttrValue::String("Msquare".to_string()),
);
graph.nodes.insert("exit".to_string(), exit);
let mut gated_work = Node::new("gated_work");
gated_work
.attrs
.insert("goal_gate".to_string(), AttrValue::Boolean(true));
gated_work
.attrs
.insert("max_retries".to_string(), AttrValue::Integer(0));
gated_work.attrs.insert(
"retry_target".to_string(),
AttrValue::String("start".to_string()),
);
gated_work.attrs.insert(
"type".to_string(),
AttrValue::String("fail_then_succeed".to_string()),
);
graph.nodes.insert("gated_work".to_string(), gated_work);
graph.edges.push(Edge::new("start", "gated_work"));
graph.edges.push(Edge::new("gated_work", "exit"));
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register(
"fail_then_succeed",
Box::new(FailThenSucceedHandler {
call_count: std::sync::atomic::AtomicU32::new(0),
}),
);
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let outcome = engine
.run(&graph, &config)
.await
.expect("run should eventually succeed after retry");
assert_eq!(outcome.status, StageStatus::Success);
let checkpoint = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
// gated_work should appear in completed nodes (at least twice -- first fail, then succeed)
let gated_work_count = checkpoint
.completed_nodes
.iter()
.filter(|n| *n == "gated_work")
.count();
assert!(
gated_work_count >= 2,
"gated_work should have been executed at least twice, got {gated_work_count}"
);
}
// ---------------------------------------------------------------------------
// 6. Variable expansion transform
// ---------------------------------------------------------------------------
#[test]
fn variable_expansion_replaces_goal_in_prompts() {
let mut graph = Graph::new("test");
graph.attrs.insert(
"goal".to_string(),
AttrValue::String("Fix all bugs".to_string()),
);
let mut plan_node = Node::new("plan");
plan_node.attrs.insert(
"prompt".to_string(),
AttrValue::String("Plan to achieve: $goal".to_string()),
);
graph.nodes.insert("plan".to_string(), plan_node);
let mut impl_node = Node::new("implement");
impl_node.attrs.insert(
"prompt".to_string(),
AttrValue::String("Implement $goal now".to_string()),
);
graph.nodes.insert("implement".to_string(), impl_node);
let mut no_var_node = Node::new("report");
no_var_node.attrs.insert(
"prompt".to_string(),
AttrValue::String("Generate a report".to_string()),
);
graph.nodes.insert("report".to_string(), no_var_node);
let transform = VariableExpansionTransform;
transform.apply(&mut graph);
let plan_prompt = graph.nodes["plan"]
.attrs
.get("prompt")
.and_then(AttrValue::as_str)
.expect("plan prompt should exist");
assert_eq!(plan_prompt, "Plan to achieve: Fix all bugs");
let impl_prompt = graph.nodes["implement"]
.attrs
.get("prompt")
.and_then(AttrValue::as_str)
.expect("implement prompt should exist");
assert_eq!(impl_prompt, "Implement Fix all bugs now");
let report_prompt = graph.nodes["report"]
.attrs
.get("prompt")
.and_then(AttrValue::as_str)
.expect("report prompt should exist");
assert_eq!(report_prompt, "Generate a report");
}
// ---------------------------------------------------------------------------
// 7. Stylesheet application
// ---------------------------------------------------------------------------
#[test]
fn stylesheet_application_by_specificity() {
let stylesheet_text = r"
* { model: claude-sonnet-4-5; provider: anthropic; }
.code { model: claude-opus-4-6; provider: anthropic; }
#critical_review { model: gpt-5.2; provider: openai; reasoning_effort: high; }
";
let mut graph = Graph::new("test");
graph.attrs.insert(
"model_stylesheet".to_string(),
AttrValue::String(stylesheet_text.to_string()),
);
// plan node: no class, should get universal defaults
let plan = Node::new("plan");
graph.nodes.insert("plan".to_string(), plan);
// implement node: class="code", should get .code overrides
let mut implement = Node::new("implement");
implement.classes.push("code".to_string());
graph.nodes.insert("implement".to_string(), implement);
// critical_review node: class="code" AND id="critical_review", id wins
let mut critical = Node::new("critical_review");
critical.classes.push("code".to_string());
graph.nodes.insert("critical_review".to_string(), critical);
// explicit node: has explicit model, should NOT be overridden
let mut explicit = Node::new("explicit_node");
explicit.attrs.insert(
"model".to_string(),
AttrValue::String("my-custom-model".to_string()),
);
graph.nodes.insert("explicit_node".to_string(), explicit);
let transform = StylesheetApplicationTransform;
transform.apply(&mut graph);
// plan: universal -> claude-sonnet-4-5
assert_eq!(
graph.nodes["plan"].attrs.get("model"),
Some(&AttrValue::String("claude-sonnet-4-5".to_string()))
);
assert_eq!(
graph.nodes["plan"].attrs.get("provider"),
Some(&AttrValue::String("anthropic".to_string()))
);
// implement: .code -> claude-opus-4-6
assert_eq!(
graph.nodes["implement"].attrs.get("model"),
Some(&AttrValue::String("claude-opus-4-6".to_string()))
);
assert_eq!(
graph.nodes["implement"].attrs.get("provider"),
Some(&AttrValue::String("anthropic".to_string()))
);
// critical_review: #critical_review -> gpt-5.2 (id overrides class)
assert_eq!(
graph.nodes["critical_review"].attrs.get("model"),
Some(&AttrValue::String("gpt-5.2".to_string()))
);
assert_eq!(
graph.nodes["critical_review"].attrs.get("provider"),
Some(&AttrValue::String("openai".to_string()))
);
assert_eq!(
graph.nodes["critical_review"].attrs.get("reasoning_effort"),
Some(&AttrValue::String("high".to_string()))
);
// explicit_node: explicit attr NOT overridden by universal
assert_eq!(
graph.nodes["explicit_node"].attrs.get("model"),
Some(&AttrValue::String("my-custom-model".to_string()))
);
}
#[test]
fn stylesheet_application_via_parsed_graph() {
let input = r#"digraph StyleTest {
graph [
goal="Test stylesheet",
model_stylesheet="* { model: sonnet; }"
]
start [shape=Mdiamond]
exit [shape=Msquare]
work [shape=box, prompt="Do work"]
start -> work -> exit
}"#;
let mut graph = parse(input).expect("parse should succeed");
validate_or_raise(&graph, &[]).expect("validation should pass");
let transform = StylesheetApplicationTransform;
transform.apply(&mut graph);
// All nodes without explicit model should get "sonnet"
assert_eq!(
graph.nodes["work"].attrs.get("model"),
Some(&AttrValue::String("sonnet".to_string()))
);
assert_eq!(
graph.nodes["start"].attrs.get("model"),
Some(&AttrValue::String("sonnet".to_string()))
);
assert_eq!(
graph.nodes["exit"].attrs.get("model"),
Some(&AttrValue::String("sonnet".to_string()))
);
}
#[test]
fn stylesheet_parse_and_apply_directly() {
let stylesheet_text = "* { model: base; } .fast { model: turbo; }";
let stylesheet = parse_stylesheet(stylesheet_text).expect("stylesheet parse should succeed");
assert_eq!(stylesheet.rules.len(), 2);
let mut graph = Graph::new("test");
let plain = Node::new("a");
graph.nodes.insert("a".to_string(), plain);
let mut fast_node = Node::new("b");
fast_node.classes.push("fast".to_string());
graph.nodes.insert("b".to_string(), fast_node);
apply_stylesheet(&stylesheet, &mut graph);
assert_eq!(
graph.nodes["a"].attrs.get("model"),
Some(&AttrValue::String("base".to_string()))
);
assert_eq!(
graph.nodes["b"].attrs.get("model"),
Some(&AttrValue::String("turbo".to_string()))
);
}
// ---------------------------------------------------------------------------
// 8. Retry on failure (Gap #35.1)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn retry_on_failure_then_succeed() {
// A handler that fails the first call and succeeds on the second.
struct RetryHandler {
call_count: std::sync::atomic::AtomicU32,
}
#[async_trait::async_trait]
impl Handler for RetryHandler {
async fn execute(
&self,
_node: &Node,
_context: &Context,
_graph: &Graph,
_run_dir: &Path,
_services: &fabro_workflows::handler::EngineServices,
) -> Result<Outcome, FabroError> {
let count = self
.call_count
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
if count == 0 {
Ok(Outcome::retry_classify("transient failure"))
} else {
Ok(Outcome::success())
}
}
}
let mut graph = Graph::new("RetryTest");
let mut start = Node::new("start");
start.attrs.insert(
"shape".to_string(),
AttrValue::String("Mdiamond".to_string()),
);
graph.nodes.insert("start".to_string(), start);
let mut exit = Node::new("exit");
exit.attrs.insert(
"shape".to_string(),
AttrValue::String("Msquare".to_string()),
);
graph.nodes.insert("exit".to_string(), exit);
let mut retry_node = Node::new("work");
retry_node.attrs.insert(
"type".to_string(),
AttrValue::String("retry_handler".to_string()),
);
retry_node
.attrs
.insert("max_retries".to_string(), AttrValue::Integer(3));
retry_node.attrs.insert(
"retry_policy".to_string(),
AttrValue::String("linear".to_string()),
);
graph.nodes.insert("work".to_string(), retry_node);
graph.edges.push(Edge::new("start", "work"));
graph.edges.push(Edge::new("work", "exit"));
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register(
"retry_handler",
Box::new(RetryHandler {
call_count: std::sync::atomic::AtomicU32::new(0),
}),
);
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let outcome = engine
.run(&graph, &config)
.await
.expect("should succeed after retry");
assert_eq!(outcome.status, StageStatus::Success);
}
// ---------------------------------------------------------------------------
// 9. Pipeline with 10+ nodes (Gap #35.2)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn pipeline_with_many_nodes() {
// Build a linear pipeline: start -> n1 -> n2 -> ... -> n10 -> exit (12 nodes)
let mut graph = Graph::new("ManyNodes");
graph.attrs.insert(
"goal".to_string(),
AttrValue::String("Test large pipeline".to_string()),
);
let mut start = Node::new("start");
start.attrs.insert(
"shape".to_string(),
AttrValue::String("Mdiamond".to_string()),
);
graph.nodes.insert("start".to_string(), start);
let mut exit = Node::new("exit");
exit.attrs.insert(
"shape".to_string(),
AttrValue::String("Msquare".to_string()),
);
graph.nodes.insert("exit".to_string(), exit);
let node_names: Vec<String> = (1..=10).map(|i| format!("step_{i}")).collect();
for name in &node_names {
let mut node = Node::new(name.clone());
node.attrs
.insert("shape".to_string(), AttrValue::String("box".to_string()));
node.attrs.insert(
"prompt".to_string(),
AttrValue::String(format!("Execute {name}")),
);
graph.nodes.insert(name.clone(), node);
}
graph.edges.push(Edge::new("start", &node_names[0]));
for pair in node_names.windows(2) {
graph.edges.push(Edge::new(&pair[0], &pair[1]));
}
graph
.edges
.push(Edge::new(node_names.last().unwrap(), "exit"));
let dir = tempfile::tempdir().unwrap();
let engine = WorkflowRunEngine::new(
make_linear_registry(),
Arc::new(EventEmitter::new()),
local_env(),
);
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let outcome = engine
.run(&graph, &config)
.await
.expect("large pipeline should succeed");
assert_eq!(outcome.status, StageStatus::Success);
let checkpoint = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
// All 10 step nodes should be in completed_nodes
for name in &node_names {
assert!(
checkpoint.completed_nodes.contains(name),
"{name} should be in completed_nodes"
);
}
}
// ---------------------------------------------------------------------------
// 10. Checkpoint save and load round-trip (Gap #35.3)
// ---------------------------------------------------------------------------
#[test]
fn checkpoint_save_and_resume_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("checkpoint.json");
let ctx = Context::new();
ctx.set("goal", serde_json::json!("Test checkpoint"));
ctx.set("progress", serde_json::json!(42));
ctx.append_log("started");
ctx.append_log("step_1 completed");
let mut retries = std::collections::HashMap::new();
retries.insert("step_1".to_string(), 1u32);
let checkpoint = Checkpoint::from_context(
&ctx,
"step_2",
vec!["start".to_string(), "step_1".to_string()],
retries,
std::collections::HashMap::new(),
None,
std::collections::HashMap::new(),
std::collections::HashMap::new(),
std::collections::HashMap::new(),
);
checkpoint.save(&path).expect("save should succeed");
let loaded = Checkpoint::load(&path).expect("load should succeed");
assert_eq!(loaded.current_node, "step_2");
assert_eq!(loaded.completed_nodes.len(), 2);
assert!(loaded.completed_nodes.contains(&"start".to_string()));
assert!(loaded.completed_nodes.contains(&"step_1".to_string()));
assert_eq!(loaded.node_retries.get("step_1"), Some(&1));
assert_eq!(
loaded.context_values.get("goal"),
Some(&serde_json::json!("Test checkpoint"))
);
assert_eq!(
loaded.context_values.get("progress"),
Some(&serde_json::json!(42))
);
assert_eq!(loaded.logs.len(), 2);
}
// ---------------------------------------------------------------------------
// 11. Smoke test with mock CodergenBackend (Gap #36)
// ---------------------------------------------------------------------------
struct MockCodergenBackend;
#[async_trait::async_trait]
impl CodergenBackend for MockCodergenBackend {
async fn run(
&self,
node: &Node,
prompt: &str,
_context: &Context,
_thread_id: Option<&str>,
_emitter: &Arc<EventEmitter>,
_stage_dir: &std::path::Path,
_sandbox: &Arc<dyn fabro_agent::Sandbox>,
_tool_hooks: Option<Arc<dyn fabro_agent::ToolHookCallback>>,
) -> Result<CodergenResult, FabroError> {
Ok(CodergenResult::Text {
text: format!(
"Response for {}: processed prompt '{}'",
node.id,
&prompt[..prompt.len().min(50)]
),
usage: None,
files_touched: Vec::new(),
last_file_touched: None,
})
}
}
// ---------------------------------------------------------------------------
// Helpers for parity tests
// ---------------------------------------------------------------------------
/// A handler backed by a shared `AtomicU32` counter.
/// Returns Fail on call 0, Success on call >= 1.
struct CounterHandler {
call_count: Arc<std::sync::atomic::AtomicU32>,
}
#[async_trait::async_trait]
impl Handler for CounterHandler {
async fn execute(
&self,
_node: &Node,
_context: &Context,
_graph: &Graph,
_run_dir: &Path,
_services: &fabro_workflows::handler::EngineServices,
) -> Result<Outcome, FabroError> {
let count = self
.call_count
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
if count == 0 {
// Use a message that heuristics classify as transient_infra
Ok(Outcome::fail_classify("connection refused"))
} else {
Ok(Outcome::success())
}
}
}
/// A handler that sets a context_update with a large value (>100KB) to trigger artifact offloading.
struct LargeOutputHandler;
#[async_trait::async_trait]
impl Handler for LargeOutputHandler {
async fn execute(
&self,
node: &Node,
_context: &Context,
_graph: &Graph,
_run_dir: &Path,
_services: &fabro_workflows::handler::EngineServices,
) -> Result<Outcome, FabroError> {
let mut outcome = Outcome::success();
// 150KB string — well above the 100KB artifact threshold
let large_value = "x".repeat(150 * 1024);
outcome.context_updates.insert(
format!("response.{}", node.id),
serde_json::json!(large_value),
);
Ok(outcome)
}
}
/// A handler that sets `context_updates` = {"`my_flag"`: "set"}.
struct ContextSetterHandler;
#[async_trait::async_trait]
impl Handler for ContextSetterHandler {
async fn execute(
&self,
_node: &Node,
_context: &Context,
_graph: &Graph,
_run_dir: &Path,
_services: &fabro_workflows::handler::EngineServices,
) -> Result<Outcome, FabroError> {
let mut outcome = Outcome::success();
outcome
.context_updates
.insert("my_flag".to_string(), serde_json::json!("set"));
Ok(outcome)
}
}
fn collect_events(emitter: &mut EventEmitter) -> Arc<std::sync::Mutex<Vec<WorkflowRunEvent>>> {
let events = Arc::new(std::sync::Mutex::new(Vec::new()));
let events_clone = Arc::clone(&events);
emitter.on_event(move |event| {
events_clone.lock().unwrap().push(event.clone());
});
events
}
fn make_full_registry(interviewer: Arc<dyn Interviewer>) -> HandlerRegistry {
let mut registry = HandlerRegistry::new(Box::new(AgentHandler::new(None)));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("agent", Box::new(AgentHandler::new(None)));
registry.register("conditional", Box::new(ConditionalHandler));
registry.register("command", Box::new(CommandHandler));
registry.register("human", Box::new(HumanHandler::new(interviewer)));
registry.register("wait", Box::new(WaitHandler));
registry.register("stack.manager_loop", Box::new(SubWorkflowHandler));
registry
}
fn make_graph_with_start_exit(name: &str) -> Graph {
let mut graph = Graph::new(name);
let mut start = Node::new("start");
start.attrs.insert(
"shape".to_string(),
AttrValue::String("Mdiamond".to_string()),
);
graph.nodes.insert("start".to_string(), start);
let mut exit = Node::new("exit");
exit.attrs.insert(
"shape".to_string(),
AttrValue::String("Msquare".to_string()),
);
graph.nodes.insert("exit".to_string(), exit);
graph
}
#[tokio::test]
async fn smoke_test_with_mock_codergen_backend() {
// Pipeline:
// start -> plan -> gate (diamond)
// gate -> implement [condition="outcome=success"]
// gate -> fix [condition="outcome!=success"]
// implement -> exit
// fix -> exit
//
// codergen nodes use MockCodergenBackend which returns real Text responses.
// The gate is a conditional node. Since the mock backend returns success,
// we should route through implement.
let mut graph = Graph::new("SmokeTest");
graph.attrs.insert(
"goal".to_string(),
AttrValue::String("Build and validate".to_string()),
);
let mut start = Node::new("start");
start.attrs.insert(
"shape".to_string(),
AttrValue::String("Mdiamond".to_string()),
);
graph.nodes.insert("start".to_string(), start);
let mut exit = Node::new("exit");
exit.attrs.insert(
"shape".to_string(),
AttrValue::String("Msquare".to_string()),
);
graph.nodes.insert("exit".to_string(), exit);
let mut plan = Node::new("plan");
plan.attrs
.insert("shape".to_string(), AttrValue::String("box".to_string()));
plan.attrs.insert(
"prompt".to_string(),
AttrValue::String("Plan to achieve: $goal".to_string()),
);
graph.nodes.insert("plan".to_string(), plan);
let mut gate = Node::new("gate");
gate.attrs.insert(
"shape".to_string(),
AttrValue::String("diamond".to_string()),
);
graph.nodes.insert("gate".to_string(), gate);
let mut implement = Node::new("implement");
implement
.attrs
.insert("shape".to_string(), AttrValue::String("box".to_string()));
implement.attrs.insert(
"prompt".to_string(),
AttrValue::String("Implement the plan".to_string()),
);
graph.nodes.insert("implement".to_string(), implement);
let mut fix = Node::new("fix");
fix.attrs
.insert("shape".to_string(), AttrValue::String("box".to_string()));
fix.attrs.insert(
"prompt".to_string(),
AttrValue::String("Fix the issues".to_string()),
);
graph.nodes.insert("fix".to_string(), fix);
graph.edges.push(Edge::new("start", "plan"));
graph.edges.push(Edge::new("plan", "gate"));
let mut gate_impl = Edge::new("gate", "implement");
gate_impl.attrs.insert(
"condition".to_string(),
AttrValue::String("outcome=success".to_string()),
);
graph.edges.push(gate_impl);
let mut gate_fix = Edge::new("gate", "fix");
gate_fix.attrs.insert(
"condition".to_string(),
AttrValue::String("outcome!=success".to_string()),
);
graph.edges.push(gate_fix);
graph.edges.push(Edge::new("implement", "exit"));
graph.edges.push(Edge::new("fix", "exit"));
let dir = tempfile::tempdir().unwrap();
let backend = Box::new(MockCodergenBackend);
let mut registry = HandlerRegistry::new(Box::new(AgentHandler::new(Some(backend))));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register(
"agent",
Box::new(AgentHandler::new(Some(Box::new(MockCodergenBackend)))),
);
registry.register("conditional", Box::new(ConditionalHandler));
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let outcome = engine
.run(&graph, &config)
.await
.expect("smoke test should succeed");
assert_eq!(outcome.status, StageStatus::Success);
let checkpoint = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
assert!(
checkpoint.completed_nodes.contains(&"plan".to_string()),
"plan should have executed"
);
assert!(
checkpoint
.completed_nodes
.contains(&"implement".to_string()),
"should route through implement (success path)"
);
assert!(
!checkpoint.completed_nodes.contains(&"fix".to_string()),
"should NOT have traversed fix path"
);
// Verify response.md was written by the mock backend
let plan_response =
std::fs::read_to_string(dir.path().join("nodes").join("plan").join("response.md"))
.expect("plan response should exist");
assert!(
plan_response.contains("Response for plan"),
"mock backend should have written response, got: {plan_response}"
);
// Verify prompt.md had $goal expanded by the AgentHandler
let plan_prompt =
std::fs::read_to_string(dir.path().join("nodes").join("plan").join("prompt.md"))
.expect("plan prompt should exist");
assert!(
plan_prompt.ends_with("Plan to achieve: Build and validate"),
"prompt should end with original prompt, got: {plan_prompt}"
);
}
// ---------------------------------------------------------------------------
// 12. Parallel fan-out / fan-in integration test (Gap #14)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn end_to_end_parallel_fan_out_fan_in() {
use fabro_workflows::handler::fan_in::FanInHandler;
use fabro_workflows::handler::parallel::ParallelHandler;
let input = r#"digraph parallel_test {
start [shape=Mdiamond]
fan_out [shape=component]
branch_a [shape=box, prompt="Branch A work"]
branch_b [shape=box, prompt="Branch B work"]
fan_in_node [shape=tripleoctagon]
done [shape=Msquare]
start -> fan_out
fan_out -> branch_a
fan_out -> branch_b
branch_a -> fan_in_node
branch_b -> fan_in_node
fan_in_node -> done
}"#;
let graph = parse(input).expect("parse should succeed");
validate_or_raise(&graph, &[]).expect("validation should pass");
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(AgentHandler::new(Some(Box::new(
MockCodergenBackend,
)))));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register(
"agent",
Box::new(AgentHandler::new(Some(Box::new(MockCodergenBackend)))),
);
registry.register("parallel", Box::new(ParallelHandler));
registry.register(
"parallel.fan_in",
Box::new(FanInHandler::new(Some(Box::new(MockCodergenBackend)))),
);
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let outcome = engine
.run(&graph, &config)
.await
.expect("parallel pipeline should succeed");
assert_eq!(outcome.status, StageStatus::Success);
let checkpoint = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
// The parallel node (fan_out) and fan_in_node should be in completed_nodes.
// Branch nodes run inside the parallel handler, so they are not recorded
// individually by the engine -- but fan_out and fan_in_node are top-level.
assert!(
checkpoint.completed_nodes.contains(&"fan_out".to_string()),
"fan_out should have been executed"
);
assert!(
checkpoint
.completed_nodes
.contains(&"fan_in_node".to_string()),
"fan_in_node should have been executed"
);
// Verify parallel.results was populated (both branches ran)
let parallel_results = checkpoint
.context_values
.get("parallel.results")
.expect("parallel.results should be in context");
let results_arr = parallel_results.as_array().expect("should be an array");
assert_eq!(results_arr.len(), 2, "should have 2 branch results");
}
// ---------------------------------------------------------------------------
// 13. Resume from checkpoint (P1)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn resume_from_checkpoint_completes_pipeline() {
// Build a pipeline: start -> step_a -> step_b -> exit
// Create a checkpoint mid-pipeline (after step_a) and verify
// run_from_checkpoint completes from step_b onward.
let mut graph = Graph::new("ResumeTest");
graph.attrs.insert(
"goal".to_string(),
AttrValue::String("Test resume".to_string()),
);
let mut start = Node::new("start");
start.attrs.insert(
"shape".to_string(),
AttrValue::String("Mdiamond".to_string()),
);
graph.nodes.insert("start".to_string(), start);
let mut exit = Node::new("exit");
exit.attrs.insert(
"shape".to_string(),
AttrValue::String("Msquare".to_string()),
);
graph.nodes.insert("exit".to_string(), exit);
let step_a = Node::new("step_a");
graph.nodes.insert("step_a".to_string(), step_a);
let step_b = Node::new("step_b");
graph.nodes.insert("step_b".to_string(), step_b);
graph.edges.push(Edge::new("start", "step_a"));
graph.edges.push(Edge::new("step_a", "step_b"));
graph.edges.push(Edge::new("step_b", "exit"));
// Simulate a checkpoint saved after step_a completed.
// The checkpoint records step_a as current_node with next_node_id = step_b.
let ctx = Context::new();
ctx.set("graph.goal", serde_json::json!("Test resume"));
ctx.set("outcome", serde_json::json!("success"));
let mut outcomes = std::collections::HashMap::new();
outcomes.insert("start".to_string(), Outcome::success());
outcomes.insert("step_a".to_string(), Outcome::success());
let checkpoint = Checkpoint::from_context(
&ctx,
"step_a",
vec!["start".to_string(), "step_a".to_string()],
std::collections::HashMap::new(),
outcomes,
Some("step_b".to_string()),
std::collections::HashMap::new(),
std::collections::HashMap::new(),
std::collections::HashMap::new(),
);
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let outcome = engine
.run_from_checkpoint(&graph, &config, &checkpoint)
.await
.expect("resume should succeed");
assert_eq!(outcome.status, StageStatus::Success);
// Verify checkpoint written after resume contains step_b
let final_cp = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
assert!(
final_cp.completed_nodes.contains(&"step_b".to_string()),
"step_b should have been executed after resume"
);
// step_a should also be present (carried over from the checkpoint)
assert!(
final_cp.completed_nodes.contains(&"step_a".to_string()),
"step_a should be preserved from checkpoint"
);
// start should also be present
assert!(
final_cp.completed_nodes.contains(&"start".to_string()),
"start should be preserved from checkpoint"
);
}
#[tokio::test]
async fn resume_from_checkpoint_preserves_goal_gate_outcomes() {
// Build: start -> gated_work (goal_gate=true) -> step_b -> exit
// Checkpoint after gated_work (success), resume at step_b.
// At exit, goal gate should pass because outcomes are restored.
let mut graph = Graph::new("ResumeGoalGateTest");
let mut start = Node::new("start");
start.attrs.insert(
"shape".to_string(),
AttrValue::String("Mdiamond".to_string()),
);
graph.nodes.insert("start".to_string(), start);
let mut exit = Node::new("exit");
exit.attrs.insert(
"shape".to_string(),
AttrValue::String("Msquare".to_string()),
);
graph.nodes.insert("exit".to_string(), exit);
let mut gated_work = Node::new("gated_work");
gated_work
.attrs
.insert("goal_gate".to_string(), AttrValue::Boolean(true));
graph.nodes.insert("gated_work".to_string(), gated_work);
let step_b = Node::new("step_b");
graph.nodes.insert("step_b".to_string(), step_b);
graph.edges.push(Edge::new("start", "gated_work"));
graph.edges.push(Edge::new("gated_work", "step_b"));
graph.edges.push(Edge::new("step_b", "exit"));
// Checkpoint: gated_work completed with success, next is step_b
let ctx = Context::new();
ctx.set("outcome", serde_json::json!("success"));
let mut outcomes = std::collections::HashMap::new();
outcomes.insert("start".to_string(), Outcome::success());
outcomes.insert("gated_work".to_string(), Outcome::success());
let checkpoint = Checkpoint::from_context(
&ctx,
"gated_work",
vec!["start".to_string(), "gated_work".to_string()],
std::collections::HashMap::new(),
outcomes,
Some("step_b".to_string()),
std::collections::HashMap::new(),
std::collections::HashMap::new(),
std::collections::HashMap::new(),
);
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
// This should succeed because goal gate for gated_work is satisfied
// via restored outcomes
let outcome = engine
.run_from_checkpoint(&graph, &config, &checkpoint)
.await
.expect("resume with goal gate should succeed");
assert_eq!(outcome.status, StageStatus::Success);
}
// ===========================================================================
// Parity tests — P1: Core pipeline behaviors
// ===========================================================================
#[tokio::test]
async fn graph_goal_in_context() {
let input = r#"digraph GoalTest {
graph [goal="Ship the widget"]
start [shape=Mdiamond]
exit [shape=Msquare]
work [shape=box, prompt="Build it"]
start -> work -> exit
}"#;
let graph = parse(input).expect("parse");
let dir = tempfile::tempdir().unwrap();
let engine = WorkflowRunEngine::new(
make_linear_registry(),
Arc::new(EventEmitter::new()),
local_env(),
);
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
engine.run(&graph, &config).await.expect("run");
let cp = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
assert_eq!(
cp.context_values.get("graph.goal"),
Some(&serde_json::json!("Ship the widget"))
);
}
#[tokio::test]
async fn event_streaming_lifecycle() {
let input = r#"digraph EventTest {
start [shape=Mdiamond]
exit [shape=Msquare]
task [shape=box, prompt="Do something"]
start -> task -> exit
}"#;
let graph = parse(input).expect("parse");
let dir = tempfile::tempdir().unwrap();
let mut emitter = EventEmitter::new();
let events = collect_events(&mut emitter);
let engine = WorkflowRunEngine::new(make_linear_registry(), Arc::new(emitter), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
engine.run(&graph, &config).await.expect("run");
let collected = events.lock().unwrap();
assert!(collected
.iter()
.any(|e| matches!(e, WorkflowRunEvent::WorkflowRunStarted { .. })));
assert!(collected
.iter()
.any(|e| matches!(e, WorkflowRunEvent::StageStarted { name, .. } if name == "start")));
assert!(collected
.iter()
.any(|e| matches!(e, WorkflowRunEvent::StageCompleted { name, .. } if name == "start")));
assert!(collected
.iter()
.any(|e| matches!(e, WorkflowRunEvent::StageStarted { name, .. } if name == "task")));
assert!(collected
.iter()
.any(|e| matches!(e, WorkflowRunEvent::StageCompleted { name, .. } if name == "task")));
assert!(collected
.iter()
.any(|e| matches!(e, WorkflowRunEvent::CheckpointCompleted { .. })));
assert!(collected
.iter()
.any(|e| matches!(e, WorkflowRunEvent::WorkflowRunCompleted { .. })));
// WorkflowRunStarted first, WorkflowRunCompleted last
assert!(matches!(
collected.first().unwrap(),
WorkflowRunEvent::WorkflowRunStarted { .. }
));
assert!(matches!(
collected.last().unwrap(),
WorkflowRunEvent::WorkflowRunCompleted { .. }
));
}
#[tokio::test]
async fn context_flow_between_stages() {
let mut graph = make_graph_with_start_exit("ContextFlowTest");
let mut step_a = Node::new("step_a");
step_a
.attrs
.insert("shape".to_string(), AttrValue::String("box".to_string()));
step_a.attrs.insert(
"prompt".to_string(),
AttrValue::String("Step A work".to_string()),
);
graph.nodes.insert("step_a".to_string(), step_a);
let mut step_b = Node::new("step_b");
step_b
.attrs
.insert("shape".to_string(), AttrValue::String("box".to_string()));
step_b.attrs.insert(
"prompt".to_string(),
AttrValue::String("Step B work".to_string()),
);
graph.nodes.insert("step_b".to_string(), step_b);
graph.edges.push(Edge::new("start", "step_a"));
graph.edges.push(Edge::new("step_a", "step_b"));
graph.edges.push(Edge::new("step_b", "exit"));
let dir = tempfile::tempdir().unwrap();
let engine = WorkflowRunEngine::new(
make_linear_registry(),
Arc::new(EventEmitter::new()),
local_env(),
);
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
engine.run(&graph, &config).await.expect("run");
let cp = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
assert_eq!(
cp.context_values.get("last_stage"),
Some(&serde_json::json!("step_b"))
);
let last_response = cp
.context_values
.get("last_response")
.unwrap()
.as_str()
.unwrap();
assert!(last_response.contains("[Simulated]"));
}
#[tokio::test]
async fn tool_handler_e2e() {
let mut graph = make_graph_with_start_exit("ToolTest");
let mut echo_task = Node::new("echo_task");
echo_task.attrs.insert(
"shape".to_string(),
AttrValue::String("parallelogram".to_string()),
);
echo_task.attrs.insert(
"script".to_string(),
AttrValue::String("echo hello-from-script".to_string()),
);
graph.nodes.insert("echo_task".to_string(), echo_task);
graph.edges.push(Edge::new("start", "echo_task"));
graph.edges.push(Edge::new("echo_task", "exit"));
let dir = tempfile::tempdir().unwrap();
let interviewer = Arc::new(AutoApproveInterviewer);
let engine = WorkflowRunEngine::new(
make_full_registry(interviewer),
Arc::new(EventEmitter::new()),
local_env(),
);
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let outcome = engine.run(&graph, &config).await.expect("run");
assert_eq!(outcome.status, StageStatus::Success);
let cp = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
let command_output = cp
.context_values
.get("command.output")
.expect("command.output should exist");
assert!(command_output
.as_str()
.unwrap()
.contains("hello-from-script"));
}
#[tokio::test]
async fn auto_approve_interviewer_e2e() {
let mut graph = make_graph_with_start_exit("AutoApproveTest");
let mut gate = Node::new("gate");
gate.attrs.insert(
"shape".to_string(),
AttrValue::String("hexagon".to_string()),
);
gate.attrs
.insert("type".to_string(), AttrValue::String("human".to_string()));
gate.attrs
.insert("label".to_string(), AttrValue::String("Review".to_string()));
graph.nodes.insert("gate".to_string(), gate);
graph
.nodes
.insert("approve".to_string(), Node::new("approve"));
graph
.nodes
.insert("reject".to_string(), Node::new("reject"));
graph.edges.push(Edge::new("start", "gate"));
let mut e_approve = Edge::new("gate", "approve");
e_approve.attrs.insert(
"label".to_string(),
AttrValue::String("[A] Approve".to_string()),
);
graph.edges.push(e_approve);
let mut e_reject = Edge::new("gate", "reject");
e_reject.attrs.insert(
"label".to_string(),
AttrValue::String("[R] Reject".to_string()),
);
graph.edges.push(e_reject);
graph.edges.push(Edge::new("approve", "exit"));
graph.edges.push(Edge::new("reject", "exit"));
let dir = tempfile::tempdir().unwrap();
let interviewer = Arc::new(AutoApproveInterviewer);
let engine = WorkflowRunEngine::new(
make_full_registry(interviewer),
Arc::new(EventEmitter::new()),
local_env(),
);
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let outcome = engine.run(&graph, &config).await.expect("run");
assert_eq!(outcome.status, StageStatus::Success);
let cp = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
assert!(cp.completed_nodes.contains(&"approve".to_string()));
assert!(!cp.completed_nodes.contains(&"reject".to_string()));
}
#[tokio::test]
async fn codergen_without_backend_simulated() {
let input = r#"digraph SimTest {
start [shape=Mdiamond]
exit [shape=Msquare]
code [shape=box, prompt="Write the code"]
start -> code -> exit
}"#;
let graph = parse(input).expect("parse");
let dir = tempfile::tempdir().unwrap();
let engine = WorkflowRunEngine::new(
make_linear_registry(),
Arc::new(EventEmitter::new()),
local_env(),
);
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
engine.run(&graph, &config).await.expect("run");
let response =
std::fs::read_to_string(dir.path().join("nodes").join("code").join("response.md")).unwrap();
assert!(response.contains("[Simulated]"));
let cp = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
let last_response = cp
.context_values
.get("last_response")
.unwrap()
.as_str()
.unwrap();
assert!(last_response.contains("[Simulated]"));
}
// ===========================================================================
// Parity tests — P2: Complex scenarios
// ===========================================================================
#[tokio::test]
async fn branching_loop_back_on_failure() {
struct FailThenSucceedHandler {
call_count: std::sync::atomic::AtomicU32,
}
#[async_trait::async_trait]
impl Handler for FailThenSucceedHandler {
async fn execute(
&self,
_node: &Node,
_context: &Context,
_graph: &Graph,
_run_dir: &Path,
_services: &fabro_workflows::handler::EngineServices,
) -> Result<Outcome, FabroError> {
let count = self
.call_count
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
if count == 0 {
Ok(Outcome::fail_classify("first attempt fails"))
} else {
Ok(Outcome::success())
}
}
}
let mut graph = make_graph_with_start_exit("LoopTest");
let mut implement = Node::new("implement");
implement
.attrs
.insert("shape".to_string(), AttrValue::String("box".to_string()));
implement.attrs.insert(
"prompt".to_string(),
AttrValue::String("Implement".to_string()),
);
graph.nodes.insert("implement".to_string(), implement);
let mut validate_node = Node::new("validate");
validate_node.attrs.insert(
"type".to_string(),
AttrValue::String("fail_then_succeed".to_string()),
);
graph.nodes.insert("validate".to_string(), validate_node);
graph.edges.push(Edge::new("start", "implement"));
graph.edges.push(Edge::new("implement", "validate"));
let mut e_success = Edge::new("validate", "exit");
e_success.attrs.insert(
"condition".to_string(),
AttrValue::String("outcome=success".to_string()),
);
graph.edges.push(e_success);
let mut e_fail = Edge::new("validate", "implement");
e_fail.attrs.insert(
"condition".to_string(),
AttrValue::String("outcome=fail".to_string()),
);
graph.edges.push(e_fail);
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(AgentHandler::new(None)));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("agent", Box::new(AgentHandler::new(None)));
registry.register(
"fail_then_succeed",
Box::new(FailThenSucceedHandler {
call_count: std::sync::atomic::AtomicU32::new(0),
}),
);
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let outcome = engine.run(&graph, &config).await.expect("run");
assert_eq!(outcome.status, StageStatus::Success);
let cp = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
let implement_count = cp
.completed_nodes
.iter()
.filter(|n| *n == "implement")
.count();
assert!(
implement_count >= 2,
"implement should appear at least 2x, got {implement_count}"
);
}
#[tokio::test]
async fn human_gate_loops_back() {
let mut graph = make_graph_with_start_exit("HumanLoopTest");
let mut gate = Node::new("gate");
gate.attrs.insert(
"shape".to_string(),
AttrValue::String("hexagon".to_string()),
);
gate.attrs
.insert("type".to_string(), AttrValue::String("human".to_string()));
gate.attrs
.insert("label".to_string(), AttrValue::String("Review".to_string()));
graph.nodes.insert("gate".to_string(), gate);
graph
.nodes
.insert("approve".to_string(), Node::new("approve"));
graph.nodes.insert("fix".to_string(), Node::new("fix"));
graph.edges.push(Edge::new("start", "gate"));
let mut e_approve = Edge::new("gate", "approve");
e_approve.attrs.insert(
"label".to_string(),
AttrValue::String("[A] Approve".to_string()),
);
graph.edges.push(e_approve);
let mut e_fix = Edge::new("gate", "fix");
e_fix.attrs.insert(
"label".to_string(),
AttrValue::String("[F] Fix".to_string()),
);
graph.edges.push(e_fix);
graph.edges.push(Edge::new("fix", "gate"));
graph.edges.push(Edge::new("approve", "exit"));
let answers = VecDeque::from([
Answer {
value: AnswerValue::Selected("F".to_string()),
selected_option: None,
selected_options: Vec::new(),
text: None,
},
Answer {
value: AnswerValue::Selected("A".to_string()),
selected_option: None,
selected_options: Vec::new(),
text: None,
},
]);
let interviewer = Arc::new(QueueInterviewer::new(answers));
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("human", Box::new(HumanHandler::new(interviewer)));
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let outcome = engine.run(&graph, &config).await.expect("run");
assert_eq!(outcome.status, StageStatus::Success);
let cp = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
let gate_count = cp.completed_nodes.iter().filter(|n| *n == "gate").count();
assert!(
gate_count >= 2,
"gate should appear at least 2x, got {gate_count}"
);
assert!(cp.completed_nodes.contains(&"approve".to_string()));
}
#[tokio::test]
async fn scenario_ship_a_feature() {
let dot = r#"digraph ShipFeature {
graph [goal="Ship the widget"]
rankdir=LR
start [shape=Mdiamond]
exit [shape=Msquare]
plan [shape=box, prompt="Plan to achieve: $goal"]
implement [shape=box, prompt="Implement the plan"]
test [shape=parallelogram, script="echo PASS"]
review [shape=hexagon, label="Review Changes"]
start -> plan -> implement -> test -> review
review -> exit [label="[A] Approve"]
review -> implement [label="[F] Fix"]
}"#;
let mut graph = parse(dot).expect("parse");
validate_or_raise(&graph, &[]).expect("validate");
VariableExpansionTransform.apply(&mut graph);
assert_eq!(
graph.nodes["plan"].prompt().unwrap(),
"Plan to achieve: Ship the widget"
);
let interviewer = Arc::new(AutoApproveInterviewer);
let dir = tempfile::tempdir().unwrap();
let mut emitter = EventEmitter::new();
let events = collect_events(&mut emitter);
let engine = WorkflowRunEngine::new(
make_full_registry(interviewer),
Arc::new(emitter),
local_env(),
);
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let outcome = engine.run(&graph, &config).await.expect("run");
assert_eq!(outcome.status, StageStatus::Success);
let cp = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
let command_output = cp
.context_values
.get("command.output")
.expect("command.output");
assert!(command_output.as_str().unwrap().contains("PASS"));
assert!(cp.completed_nodes.contains(&"plan".to_string()));
assert!(cp.completed_nodes.contains(&"implement".to_string()));
assert!(cp.completed_nodes.contains(&"test".to_string()));
assert!(cp.completed_nodes.contains(&"review".to_string()));
let collected = events.lock().unwrap();
assert!(collected
.iter()
.any(|e| matches!(e, WorkflowRunEvent::WorkflowRunStarted { .. })));
assert!(collected
.iter()
.any(|e| matches!(e, WorkflowRunEvent::WorkflowRunCompleted { .. })));
}
#[tokio::test]
async fn scenario_parallel_expert_review() {
use fabro_workflows::handler::fan_in::FanInHandler;
use fabro_workflows::handler::parallel::ParallelHandler;
let input = r#"digraph ParallelReview {
start [shape=Mdiamond]
fan_out [shape=component]
expert_a [shape=box, prompt="Expert A review"]
expert_b [shape=box, prompt="Expert B review"]
expert_c [shape=box, prompt="Expert C review"]
fan_in_node [shape=tripleoctagon]
review [shape=hexagon, label="Final Review"]
exit [shape=Msquare]
start -> fan_out
fan_out -> expert_a
fan_out -> expert_b
fan_out -> expert_c
expert_a -> fan_in_node
expert_b -> fan_in_node
expert_c -> fan_in_node
fan_in_node -> review
review -> exit [label="[A] Approve"]
review -> fan_out [label="[F] Redo"]
}"#;
let graph = parse(input).expect("parse");
validate_or_raise(&graph, &[]).expect("validate");
let recorder = Arc::new(RecordingInterviewer::new(Box::new(AutoApproveInterviewer)));
let dir = tempfile::tempdir().unwrap();
let interviewer: Arc<dyn Interviewer> = recorder.clone();
let mut registry = HandlerRegistry::new(Box::new(AgentHandler::new(Some(Box::new(
MockCodergenBackend,
)))));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register(
"agent",
Box::new(AgentHandler::new(Some(Box::new(MockCodergenBackend)))),
);
registry.register("parallel", Box::new(ParallelHandler));
registry.register(
"parallel.fan_in",
Box::new(FanInHandler::new(Some(Box::new(MockCodergenBackend)))),
);
registry.register("human", Box::new(HumanHandler::new(interviewer)));
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let outcome = engine.run(&graph, &config).await.expect("run");
assert_eq!(outcome.status, StageStatus::Success);
let cp = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
let results = cp
.context_values
.get("parallel.results")
.expect("parallel.results");
assert_eq!(results.as_array().unwrap().len(), 3);
let recordings = recorder.recordings();
assert_eq!(recordings.len(), 1, "should have 1 interview recording");
assert!(cp.completed_nodes.contains(&"review".to_string()));
}
#[tokio::test]
async fn scenario_node_retries_on_retry_status() {
struct RetryHandler {
call_count: std::sync::atomic::AtomicU32,
}
#[async_trait::async_trait]
impl Handler for RetryHandler {
async fn execute(
&self,
_node: &Node,
_context: &Context,
_graph: &Graph,
_run_dir: &Path,
_services: &fabro_workflows::handler::EngineServices,
) -> Result<Outcome, FabroError> {
let count = self
.call_count
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
if count == 0 {
Ok(Outcome::retry_classify("transient failure"))
} else {
Ok(Outcome::success())
}
}
}
let mut graph = make_graph_with_start_exit("RetryScenarioTest");
let mut flaky = Node::new("flaky");
flaky.attrs.insert(
"type".to_string(),
AttrValue::String("retry_handler".to_string()),
);
flaky
.attrs
.insert("max_retries".to_string(), AttrValue::Integer(2));
flaky.attrs.insert(
"retry_policy".to_string(),
AttrValue::String("linear".to_string()),
);
graph.nodes.insert("flaky".to_string(), flaky);
graph.edges.push(Edge::new("start", "flaky"));
graph.edges.push(Edge::new("flaky", "exit"));
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register(
"retry_handler",
Box::new(RetryHandler {
call_count: std::sync::atomic::AtomicU32::new(0),
}),
);
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let outcome = engine.run(&graph, &config).await.expect("run");
assert_eq!(outcome.status, StageStatus::Success);
let cp = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
let retry_count = cp
.node_retries
.get("flaky")
.expect("flaky should have retries");
assert_eq!(*retry_count, 2, "should have been called 2x");
}
#[tokio::test]
async fn scenario_loop_restart_resets_context() {
let mut graph = make_graph_with_start_exit("LoopRestartTest");
let mut work = Node::new("work");
work.attrs
.insert("type".to_string(), AttrValue::String("counter".to_string()));
graph.nodes.insert("work".to_string(), work);
graph.edges.push(Edge::new("start", "work"));
let mut success_edge = Edge::new("work", "exit");
success_edge.attrs.insert(
"condition".to_string(),
AttrValue::String("outcome=success".to_string()),
);
graph.edges.push(success_edge);
let mut fail_edge = Edge::new("work", "start");
fail_edge.attrs.insert(
"condition".to_string(),
AttrValue::String("outcome=fail".to_string()),
);
fail_edge
.attrs
.insert("loop_restart".to_string(), AttrValue::Boolean(true));
graph.edges.push(fail_edge);
let call_count = Arc::new(std::sync::atomic::AtomicU32::new(0));
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register(
"counter",
Box::new(CounterHandler {
call_count: Arc::clone(&call_count),
}),
);
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let outcome = engine.run(&graph, &config).await.expect("run");
assert_eq!(outcome.status, StageStatus::Success);
assert!(call_count.load(std::sync::atomic::Ordering::SeqCst) >= 2);
}
#[tokio::test]
async fn scenario_bug_triage_router() {
let mut graph = make_graph_with_start_exit("TriageTest");
let mut triage = Node::new("triage");
triage.attrs.insert(
"shape".to_string(),
AttrValue::String("diamond".to_string()),
);
graph.nodes.insert("triage".to_string(), triage);
graph
.nodes
.insert("critical".to_string(), Node::new("critical"));
graph
.nodes
.insert("normal".to_string(), Node::new("normal"));
graph
.nodes
.insert("wontfix".to_string(), Node::new("wontfix"));
graph.edges.push(Edge::new("start", "triage"));
let mut e_critical = Edge::new("triage", "critical");
e_critical.attrs.insert(
"condition".to_string(),
AttrValue::String("outcome=success".to_string()),
);
e_critical
.attrs
.insert("weight".to_string(), AttrValue::Integer(10));
graph.edges.push(e_critical);
let mut e_normal = Edge::new("triage", "normal");
e_normal.attrs.insert(
"condition".to_string(),
AttrValue::String("outcome=success".to_string()),
);
e_normal
.attrs
.insert("weight".to_string(), AttrValue::Integer(5));
graph.edges.push(e_normal);
graph.edges.push(Edge::new("triage", "wontfix"));
graph.edges.push(Edge::new("critical", "exit"));
graph.edges.push(Edge::new("normal", "exit"));
graph.edges.push(Edge::new("wontfix", "exit"));
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("conditional", Box::new(ConditionalHandler));
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let outcome = engine.run(&graph, &config).await.expect("run");
assert_eq!(outcome.status, StageStatus::Success);
let cp = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
assert!(
cp.completed_nodes.contains(&"critical".to_string()),
"critical should be selected (highest weight)"
);
assert!(!cp.completed_nodes.contains(&"normal".to_string()));
assert!(!cp.completed_nodes.contains(&"wontfix".to_string()));
}
#[tokio::test]
async fn scenario_crash_recovery() {
let mut graph = make_graph_with_start_exit("CrashRecoveryTest");
graph.nodes.insert("a".to_string(), Node::new("a"));
graph.nodes.insert("b".to_string(), Node::new("b"));
graph.nodes.insert("c".to_string(), Node::new("c"));
graph.edges.push(Edge::new("start", "a"));
graph.edges.push(Edge::new("a", "b"));
graph.edges.push(Edge::new("b", "c"));
graph.edges.push(Edge::new("c", "exit"));
let ctx = Context::new();
ctx.set("outcome", serde_json::json!("success"));
let mut outcomes = std::collections::HashMap::new();
outcomes.insert("start".to_string(), Outcome::success());
outcomes.insert("a".to_string(), Outcome::success());
let checkpoint = Checkpoint::from_context(
&ctx,
"a",
vec!["start".to_string(), "a".to_string()],
std::collections::HashMap::new(),
outcomes,
Some("b".to_string()),
std::collections::HashMap::new(),
std::collections::HashMap::new(),
std::collections::HashMap::new(),
);
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let outcome = engine
.run_from_checkpoint(&graph, &config, &checkpoint)
.await
.expect("run");
assert_eq!(outcome.status, StageStatus::Success);
let cp = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
assert!(cp.completed_nodes.contains(&"b".to_string()));
assert!(cp.completed_nodes.contains(&"c".to_string()));
assert!(cp.completed_nodes.contains(&"a".to_string()));
let a_count = cp.completed_nodes.iter().filter(|n| *n == "a").count();
assert_eq!(a_count, 1, "a should not be re-executed");
}
#[tokio::test]
async fn manager_loop_stop_condition_satisfied_e2e() {
struct DoneSetterHandler;
#[async_trait::async_trait]
impl Handler for DoneSetterHandler {
async fn execute(
&self,
_node: &Node,
_context: &Context,
_graph: &Graph,
_run_dir: &Path,
_services: &fabro_workflows::handler::EngineServices,
) -> Result<Outcome, FabroError> {
let mut outcome = Outcome::success();
outcome
.context_updates
.insert("done".to_string(), serde_json::json!("true"));
Ok(outcome)
}
}
// A slow handler so the child doesn't finish before the stop condition is checked
struct SlowHandler;
#[async_trait::async_trait]
impl Handler for SlowHandler {
async fn execute(
&self,
_node: &Node,
_context: &Context,
_graph: &Graph,
_run_dir: &Path,
_services: &fabro_workflows::handler::EngineServices,
) -> Result<Outcome, FabroError> {
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
Ok(Outcome::success())
}
}
let mut graph = make_graph_with_start_exit("ManagerStopTest");
let mut setter = Node::new("setter");
setter.attrs.insert(
"type".to_string(),
AttrValue::String("done_setter".to_string()),
);
graph.nodes.insert("setter".to_string(), setter);
let mut manager = Node::new("manager");
manager.attrs.insert(
"type".to_string(),
AttrValue::String("stack.manager_loop".to_string()),
);
manager.attrs.insert(
"stack.child_dot_source".to_string(),
AttrValue::String(
"digraph Child { start [shape=Mdiamond]; slow [shape=box]; exit [shape=Msquare]; start -> slow -> exit }"
.to_string(),
),
);
manager.attrs.insert(
"manager.stop_condition".to_string(),
AttrValue::String("context.done=true".to_string()),
);
manager
.attrs
.insert("manager.max_cycles".to_string(), AttrValue::Integer(10));
manager.attrs.insert(
"manager.poll_interval".to_string(),
AttrValue::Duration(std::time::Duration::from_millis(1)),
);
graph.nodes.insert("manager".to_string(), manager);
graph.edges.push(Edge::new("start", "setter"));
graph.edges.push(Edge::new("setter", "manager"));
graph.edges.push(Edge::new("manager", "exit"));
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(SlowHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("done_setter", Box::new(DoneSetterHandler));
registry.register("stack.manager_loop", Box::new(SubWorkflowHandler));
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let outcome = engine.run(&graph, &config).await.expect("run");
let cp = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
let manager_outcome = cp.node_outcomes.get("manager").expect("manager outcome");
assert_eq!(manager_outcome.status, StageStatus::Success);
assert!(manager_outcome
.notes
.as_deref()
.unwrap()
.contains("Stop condition satisfied"));
// Overall pipeline succeeds because manager succeeded
assert_eq!(outcome.status, StageStatus::Success);
}
#[tokio::test]
async fn manager_loop_max_cycles_exceeded_e2e() {
// A slow handler so the child doesn't finish before max cycles
struct SlowHandler;
#[async_trait::async_trait]
impl Handler for SlowHandler {
async fn execute(
&self,
_node: &Node,
_context: &Context,
_graph: &Graph,
_run_dir: &Path,
_services: &fabro_workflows::handler::EngineServices,
) -> Result<Outcome, FabroError> {
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
Ok(Outcome::success())
}
}
let mut graph = make_graph_with_start_exit("ManagerMaxCyclesTest");
let mut manager = Node::new("manager");
manager.attrs.insert(
"type".to_string(),
AttrValue::String("stack.manager_loop".to_string()),
);
manager.attrs.insert(
"stack.child_dot_source".to_string(),
AttrValue::String(
"digraph Child { start [shape=Mdiamond]; slow [shape=box]; exit [shape=Msquare]; start -> slow -> exit }"
.to_string(),
),
);
manager
.attrs
.insert("manager.max_cycles".to_string(), AttrValue::Integer(2));
manager.attrs.insert(
"manager.poll_interval".to_string(),
AttrValue::Duration(std::time::Duration::from_millis(1)),
);
graph.nodes.insert("manager".to_string(), manager);
graph.edges.push(Edge::new("start", "manager"));
graph.edges.push(Edge::new("manager", "exit"));
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(SlowHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("stack.manager_loop", Box::new(SubWorkflowHandler));
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let outcome = engine.run(&graph, &config).await.expect("run");
let cp = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
let manager_outcome = cp.node_outcomes.get("manager").expect("manager outcome");
assert_eq!(manager_outcome.status, StageStatus::Fail);
assert!(manager_outcome
.failure_reason()
.unwrap()
.contains("Max cycles"));
// Overall pipeline outcome is from last completed node (manager) = Fail
assert_eq!(outcome.status, StageStatus::Fail);
}
// ===========================================================================
// Parity tests — P3: Validation
// ===========================================================================
#[test]
fn validation_missing_start_node() {
let mut graph = Graph::new("NoStartTest");
let mut exit = Node::new("exit");
exit.attrs.insert(
"shape".to_string(),
AttrValue::String("Msquare".to_string()),
);
graph.nodes.insert("exit".to_string(), exit);
let diagnostics = validate(&graph, &[]);
let start_errors: Vec<_> = diagnostics
.iter()
.filter(|d| d.severity == Severity::Error && d.rule == "start_node")
.collect();
assert!(
!start_errors.is_empty(),
"should have start_node error diagnostic"
);
}
#[test]
fn validation_missing_exit_node() {
let mut graph = Graph::new("NoExitTest");
let mut start = Node::new("start");
start.attrs.insert(
"shape".to_string(),
AttrValue::String("Mdiamond".to_string()),
);
graph.nodes.insert("start".to_string(), start);
graph.nodes.insert("work".to_string(), Node::new("work"));
graph.edges.push(Edge::new("start", "work"));
let diagnostics = validate(&graph, &[]);
let exit_errors: Vec<_> = diagnostics
.iter()
.filter(|d| d.severity == Severity::Error && d.rule == "terminal_node")
.collect();
assert!(
!exit_errors.is_empty(),
"should have terminal_node error diagnostic"
);
}
#[test]
fn validation_orphan_unreachable_node() {
let mut graph = make_graph_with_start_exit("OrphanTest");
graph
.nodes
.insert("orphan".to_string(), Node::new("orphan"));
graph.edges.push(Edge::new("start", "exit"));
let diagnostics = validate(&graph, &[]);
let reachability_errors: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "reachability")
.collect();
assert!(
!reachability_errors.is_empty(),
"should have reachability diagnostic for orphan node"
);
}
// ===========================================================================
// Parity tests — P4: Edge selection and cross-feature
// ===========================================================================
#[tokio::test]
async fn conditional_branching_success_fail_paths() {
let mut graph = make_graph_with_start_exit("CondBranchTest");
let mut work = Node::new("work");
work.attrs.insert(
"type".to_string(),
AttrValue::String("always_fail".to_string()),
);
graph.nodes.insert("work".to_string(), work);
graph
.nodes
.insert("success_path".to_string(), Node::new("success_path"));
graph
.nodes
.insert("fail_path".to_string(), Node::new("fail_path"));
graph.edges.push(Edge::new("start", "work"));
let mut e_success = Edge::new("work", "success_path");
e_success.attrs.insert(
"condition".to_string(),
AttrValue::String("outcome=success".to_string()),
);
graph.edges.push(e_success);
let mut e_fail = Edge::new("work", "fail_path");
e_fail.attrs.insert(
"condition".to_string(),
AttrValue::String("outcome=fail".to_string()),
);
graph.edges.push(e_fail);
graph.edges.push(Edge::new("success_path", "exit"));
graph.edges.push(Edge::new("fail_path", "exit"));
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("always_fail", Box::new(AlwaysFailHandler));
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let outcome = engine.run(&graph, &config).await.expect("run");
assert_eq!(outcome.status, StageStatus::Success);
let cp = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
assert!(cp.completed_nodes.contains(&"fail_path".to_string()));
assert!(!cp.completed_nodes.contains(&"success_path".to_string()));
}
#[tokio::test]
async fn edge_selection_condition_match_wins_over_weight() {
let mut graph = make_graph_with_start_exit("CondVsWeightTest");
graph.nodes.insert("a".to_string(), Node::new("a"));
graph
.nodes
.insert("cond_target".to_string(), Node::new("cond_target"));
graph
.nodes
.insert("weighted_target".to_string(), Node::new("weighted_target"));
graph.edges.push(Edge::new("start", "a"));
let mut e_cond = Edge::new("a", "cond_target");
e_cond.attrs.insert(
"condition".to_string(),
AttrValue::String("outcome=success".to_string()),
);
graph.edges.push(e_cond);
let mut e_weight = Edge::new("a", "weighted_target");
e_weight
.attrs
.insert("weight".to_string(), AttrValue::Integer(100));
graph.edges.push(e_weight);
graph.edges.push(Edge::new("cond_target", "exit"));
graph.edges.push(Edge::new("weighted_target", "exit"));
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
engine.run(&graph, &config).await.expect("run");
let cp = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
assert!(cp.completed_nodes.contains(&"cond_target".to_string()));
assert!(!cp.completed_nodes.contains(&"weighted_target".to_string()));
}
#[tokio::test]
async fn edge_selection_weight_breaks_ties() {
let mut graph = make_graph_with_start_exit("WeightTiesTest");
graph.nodes.insert("a".to_string(), Node::new("a"));
graph.nodes.insert("low".to_string(), Node::new("low"));
graph.nodes.insert("high".to_string(), Node::new("high"));
graph.edges.push(Edge::new("start", "a"));
let mut e_low = Edge::new("a", "low");
e_low
.attrs
.insert("weight".to_string(), AttrValue::Integer(1));
graph.edges.push(e_low);
let mut e_high = Edge::new("a", "high");
e_high
.attrs
.insert("weight".to_string(), AttrValue::Integer(10));
graph.edges.push(e_high);
graph.edges.push(Edge::new("low", "exit"));
graph.edges.push(Edge::new("high", "exit"));
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
engine.run(&graph, &config).await.expect("run");
let cp = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
assert!(cp.completed_nodes.contains(&"high".to_string()));
assert!(!cp.completed_nodes.contains(&"low".to_string()));
}
#[tokio::test]
async fn edge_selection_lexical_tiebreak() {
let mut graph = make_graph_with_start_exit("LexicalTieTest");
graph.nodes.insert("a".to_string(), Node::new("a"));
graph.nodes.insert("beta".to_string(), Node::new("beta"));
graph.nodes.insert("alpha".to_string(), Node::new("alpha"));
graph.edges.push(Edge::new("start", "a"));
graph.edges.push(Edge::new("a", "beta"));
graph.edges.push(Edge::new("a", "alpha"));
graph.edges.push(Edge::new("beta", "exit"));
graph.edges.push(Edge::new("alpha", "exit"));
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
engine.run(&graph, &config).await.expect("run");
let cp = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
assert!(cp.completed_nodes.contains(&"alpha".to_string()));
assert!(!cp.completed_nodes.contains(&"beta".to_string()));
}
#[tokio::test]
async fn context_updates_visible_across_nodes() {
let mut graph = make_graph_with_start_exit("ContextVisibilityTest");
let mut setter = Node::new("setter");
setter.attrs.insert(
"type".to_string(),
AttrValue::String("context_setter".to_string()),
);
graph.nodes.insert("setter".to_string(), setter);
let mut gate = Node::new("gate");
gate.attrs.insert(
"shape".to_string(),
AttrValue::String("diamond".to_string()),
);
graph.nodes.insert("gate".to_string(), gate);
graph.nodes.insert("yes".to_string(), Node::new("yes"));
graph.nodes.insert("no".to_string(), Node::new("no"));
graph.edges.push(Edge::new("start", "setter"));
graph.edges.push(Edge::new("setter", "gate"));
let mut e_yes = Edge::new("gate", "yes");
e_yes.attrs.insert(
"condition".to_string(),
AttrValue::String("context.my_flag=set".to_string()),
);
graph.edges.push(e_yes);
graph.edges.push(Edge::new("gate", "no"));
graph.edges.push(Edge::new("yes", "exit"));
graph.edges.push(Edge::new("no", "exit"));
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("conditional", Box::new(ConditionalHandler));
registry.register("context_setter", Box::new(ContextSetterHandler));
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
engine.run(&graph, &config).await.expect("run");
let cp = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
assert!(cp.completed_nodes.contains(&"yes".to_string()));
assert!(!cp.completed_nodes.contains(&"no".to_string()));
}
#[tokio::test]
async fn stylesheet_applies_model_override() {
let input = r#"digraph StylesheetTest {
graph [
goal="Test stylesheet",
model_stylesheet="* { model: custom-model; }"
]
start [shape=Mdiamond]
exit [shape=Msquare]
work [shape=box, prompt="Do work"]
start -> work -> exit
}"#;
let mut graph = parse(input).expect("parse");
validate_or_raise(&graph, &[]).expect("validate");
StylesheetApplicationTransform.apply(&mut graph);
assert_eq!(graph.nodes["work"].model(), Some("custom-model"));
let dir = tempfile::tempdir().unwrap();
let engine = WorkflowRunEngine::new(
make_linear_registry(),
Arc::new(EventEmitter::new()),
local_env(),
);
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let outcome = engine.run(&graph, &config).await.expect("run");
assert_eq!(outcome.status, StageStatus::Success);
}
#[tokio::test]
async fn custom_handler_registration_and_execution() {
struct CustomHandler;
#[async_trait::async_trait]
impl Handler for CustomHandler {
async fn execute(
&self,
_node: &Node,
_context: &Context,
_graph: &Graph,
_run_dir: &Path,
_services: &fabro_workflows::handler::EngineServices,
) -> Result<Outcome, FabroError> {
let mut outcome = Outcome::success();
outcome
.context_updates
.insert("custom.ran".to_string(), serde_json::json!("true"));
Ok(outcome)
}
}
let mut graph = make_graph_with_start_exit("CustomHandlerTest");
let mut custom = Node::new("custom");
custom.attrs.insert(
"type".to_string(),
AttrValue::String("my_custom".to_string()),
);
graph.nodes.insert("custom".to_string(), custom);
graph.edges.push(Edge::new("start", "custom"));
graph.edges.push(Edge::new("custom", "exit"));
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("my_custom", Box::new(CustomHandler));
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
engine.run(&graph, &config).await.expect("run");
let cp = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
assert_eq!(
cp.context_values.get("custom.ran"),
Some(&serde_json::json!("true"))
);
}
#[tokio::test]
async fn integration_smoke_plan_implement_review_done() {
let dot = r#"digraph SmokeIntegration {
graph [
goal="Build the feature",
model_stylesheet="* { model: test-model; }"
]
rankdir=LR
start [shape=Mdiamond]
exit [shape=Msquare]
plan [shape=box, prompt="Plan: $goal"]
implement [shape=box, prompt="Implement"]
review [shape=hexagon, label="Review"]
start -> plan -> implement -> review
review -> exit [label="[A] Approve"]
review -> implement [label="[F] Fix"]
}"#;
// Parse and validate
let mut graph = parse(dot).expect("parse");
let diagnostics = validate_or_raise(&graph, &[]).expect("validate");
let errors: Vec<_> = diagnostics
.iter()
.filter(|d| d.severity == Severity::Error)
.collect();
assert!(errors.is_empty());
// Apply transforms
VariableExpansionTransform.apply(&mut graph);
StylesheetApplicationTransform.apply(&mut graph);
// Verify transforms applied
assert_eq!(
graph.nodes["plan"].prompt().unwrap(),
"Plan: Build the feature"
);
assert_eq!(graph.nodes["plan"].model(), Some("test-model"));
// Run pipeline
let interviewer = Arc::new(AutoApproveInterviewer);
let dir = tempfile::tempdir().unwrap();
let mut emitter = EventEmitter::new();
let events = collect_events(&mut emitter);
let engine = WorkflowRunEngine::new(
make_full_registry(interviewer),
Arc::new(emitter),
local_env(),
);
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let outcome = engine.run(&graph, &config).await.expect("run");
assert_eq!(outcome.status, StageStatus::Success);
// Verify all nodes completed
let cp = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
assert!(cp.completed_nodes.contains(&"plan".to_string()));
assert!(cp.completed_nodes.contains(&"implement".to_string()));
assert!(cp.completed_nodes.contains(&"review".to_string()));
// Verify prompt.md and response.md exist
assert!(dir
.path()
.join("nodes")
.join("plan")
.join("prompt.md")
.exists());
assert!(dir
.path()
.join("nodes")
.join("plan")
.join("response.md")
.exists());
// Verify events
let collected = events.lock().unwrap();
assert!(collected
.iter()
.any(|e| matches!(e, WorkflowRunEvent::WorkflowRunStarted { .. })));
assert!(collected
.iter()
.any(|e| matches!(e, WorkflowRunEvent::WorkflowRunCompleted { .. })));
}
// ===========================================================================
// 19b. Manager loop runs child engine E2E
// ===========================================================================
#[tokio::test]
async fn manager_loop_runs_child_engine_e2e() {
let mut graph = Graph::new("ManagerLoopE2E");
graph.attrs.insert(
"goal".to_string(),
AttrValue::String("Test manager loop".to_string()),
);
let mut start = Node::new("start");
start.attrs.insert(
"shape".to_string(),
AttrValue::String("Mdiamond".to_string()),
);
graph.nodes.insert("start".to_string(), start);
let mut exit = Node::new("exit");
exit.attrs.insert(
"shape".to_string(),
AttrValue::String("Msquare".to_string()),
);
graph.nodes.insert("exit".to_string(), exit);
let mut supervisor = Node::new("supervisor");
supervisor.attrs.insert(
"type".to_string(),
AttrValue::String("stack.manager_loop".to_string()),
);
supervisor.attrs.insert(
"stack.child_dot_source".to_string(),
AttrValue::String(
"digraph Child { start [shape=Mdiamond]; exit [shape=Msquare]; start -> exit }"
.to_string(),
),
);
supervisor.attrs.insert(
"manager.poll_interval".to_string(),
AttrValue::Duration(std::time::Duration::from_millis(10)),
);
supervisor
.attrs
.insert("manager.max_cycles".to_string(), AttrValue::Integer(100));
graph.nodes.insert("supervisor".to_string(), supervisor);
graph.edges.push(Edge::new("start", "supervisor"));
graph.edges.push(Edge::new("supervisor", "exit"));
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("stack.manager_loop", Box::new(SubWorkflowHandler));
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let outcome = engine
.run(&graph, &config)
.await
.expect("manager loop E2E should succeed");
assert_eq!(outcome.status, StageStatus::Success);
let checkpoint = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
assert!(
checkpoint
.completed_nodes
.contains(&"supervisor".to_string()),
"supervisor should be in completed_nodes"
);
let supervisor_outcome = checkpoint.node_outcomes.get("supervisor");
assert!(
supervisor_outcome.is_some(),
"supervisor outcome should exist"
);
let notes = supervisor_outcome.unwrap().notes.as_deref().unwrap_or("");
assert!(
notes.contains("Child completed"),
"notes should mention child completion, got: {notes}"
);
}
// ===========================================================================
// 19b-2. Manager loop: context flows parent → child → parent
// ===========================================================================
#[tokio::test]
async fn manager_loop_context_flows_e2e() {
// Handler that reads parent's context value and sets a result
struct ContextEchoHandler;
#[async_trait::async_trait]
impl Handler for ContextEchoHandler {
async fn execute(
&self,
_node: &Node,
context: &Context,
_graph: &Graph,
_run_dir: &Path,
_services: &fabro_workflows::handler::EngineServices,
) -> Result<Outcome, FabroError> {
let target = context.get_string("review.target", "");
let mut outcome = Outcome::success();
outcome
.context_updates
.insert("review.result".to_string(), serde_json::json!("approved"));
outcome
.context_updates
.insert("review.echo".to_string(), serde_json::json!(target));
Ok(outcome)
}
}
let mut graph = make_graph_with_start_exit("ManagerContextFlowE2E");
// A setter node that puts review.target into context before the manager
struct SetterHandler;
#[async_trait::async_trait]
impl Handler for SetterHandler {
async fn execute(
&self,
_node: &Node,
_context: &Context,
_graph: &Graph,
_run_dir: &Path,
_services: &fabro_workflows::handler::EngineServices,
) -> Result<Outcome, FabroError> {
let mut outcome = Outcome::success();
outcome.context_updates.insert(
"review.target".to_string(),
serde_json::json!("src/main.rs"),
);
Ok(outcome)
}
}
let mut setter = Node::new("setter");
setter
.attrs
.insert("type".to_string(), AttrValue::String("setter".to_string()));
graph.nodes.insert("setter".to_string(), setter);
let mut supervisor = Node::new("supervisor");
supervisor.attrs.insert(
"type".to_string(),
AttrValue::String("stack.manager_loop".to_string()),
);
supervisor.attrs.insert(
"stack.child_dot_source".to_string(),
AttrValue::String(
"digraph Child { start [shape=Mdiamond]; work [shape=box]; exit [shape=Msquare]; start -> work -> exit }"
.to_string(),
),
);
supervisor.attrs.insert(
"manager.poll_interval".to_string(),
AttrValue::Duration(std::time::Duration::from_millis(10)),
);
supervisor
.attrs
.insert("manager.max_cycles".to_string(), AttrValue::Integer(100));
graph.nodes.insert("supervisor".to_string(), supervisor);
graph.edges.push(Edge::new("start", "setter"));
graph.edges.push(Edge::new("setter", "supervisor"));
graph.edges.push(Edge::new("supervisor", "exit"));
let dir = tempfile::tempdir().unwrap();
// Default handler = ContextEchoHandler (handles the child's "work" node)
let mut registry = HandlerRegistry::new(Box::new(ContextEchoHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("setter", Box::new(SetterHandler));
registry.register("stack.manager_loop", Box::new(SubWorkflowHandler));
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let outcome = engine.run(&graph, &config).await.expect("run");
assert_eq!(outcome.status, StageStatus::Success);
// Check that child's context updates were propagated through the manager
let checkpoint = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
let sup_outcome = checkpoint.node_outcomes.get("supervisor").unwrap();
assert_eq!(
sup_outcome.context_updates.get("review.result"),
Some(&serde_json::json!("approved")),
"child's review.result should propagate to parent"
);
assert_eq!(
sup_outcome.context_updates.get("review.echo"),
Some(&serde_json::json!("src/main.rs")),
"child should have read parent's review.target"
);
}
// ===========================================================================
// 19b-3. Manager loop with child_dotfile E2E
// ===========================================================================
#[tokio::test]
async fn manager_loop_child_dotfile_e2e() {
let dir = tempfile::tempdir().unwrap();
let dot_path = dir.path().join("child.dot");
std::fs::write(
&dot_path,
"digraph Child { start [shape=Mdiamond]; exit [shape=Msquare]; start -> exit }",
)
.unwrap();
let mut graph = make_graph_with_start_exit("ManagerDotfileE2E");
let mut supervisor = Node::new("supervisor");
supervisor.attrs.insert(
"type".to_string(),
AttrValue::String("stack.manager_loop".to_string()),
);
supervisor.attrs.insert(
"stack.child_dotfile".to_string(),
AttrValue::String(dot_path.to_string_lossy().to_string()),
);
supervisor.attrs.insert(
"manager.poll_interval".to_string(),
AttrValue::Duration(std::time::Duration::from_millis(10)),
);
supervisor
.attrs
.insert("manager.max_cycles".to_string(), AttrValue::Integer(100));
graph.nodes.insert("supervisor".to_string(), supervisor);
graph.edges.push(Edge::new("start", "supervisor"));
graph.edges.push(Edge::new("supervisor", "exit"));
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("stack.manager_loop", Box::new(SubWorkflowHandler));
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let outcome = engine.run(&graph, &config).await.expect("run");
assert_eq!(outcome.status, StageStatus::Success);
}
// ===========================================================================
// 19c. GraphMerge E2E (TS Scenario 11)
// ===========================================================================
#[tokio::test]
async fn graph_merge_e2e_through_engine() {
use fabro_workflows::transform::GraphMergeTransform;
// Module "val": lint -> test
let mut val_graph = Graph::new("val");
let mut lint = Node::new("lint");
lint.attrs
.insert("shape".to_string(), AttrValue::String("box".to_string()));
lint.attrs.insert(
"prompt".to_string(),
AttrValue::String("Lint the code".to_string()),
);
val_graph.nodes.insert("lint".to_string(), lint);
let mut test_node = Node::new("test");
test_node
.attrs
.insert("shape".to_string(), AttrValue::String("box".to_string()));
test_node.attrs.insert(
"prompt".to_string(),
AttrValue::String("Run tests".to_string()),
);
val_graph.nodes.insert("test".to_string(), test_node);
val_graph.edges.push(Edge::new("lint", "test"));
// Module "dep": stage -> release
let mut dep_graph = Graph::new("dep");
let mut stage = Node::new("stage");
stage
.attrs
.insert("shape".to_string(), AttrValue::String("box".to_string()));
stage.attrs.insert(
"prompt".to_string(),
AttrValue::String("Stage the release".to_string()),
);
dep_graph.nodes.insert("stage".to_string(), stage);
let mut release = Node::new("release");
release
.attrs
.insert("shape".to_string(), AttrValue::String("box".to_string()));
release.attrs.insert(
"prompt".to_string(),
AttrValue::String("Release it".to_string()),
);
dep_graph.nodes.insert("release".to_string(), release);
dep_graph.edges.push(Edge::new("stage", "release"));
// Main graph: start, exit; edges connect modules
let mut main_graph = Graph::new("MergeE2E");
main_graph.attrs.insert(
"goal".to_string(),
AttrValue::String("Test graph merge".to_string()),
);
let mut start = Node::new("start");
start.attrs.insert(
"shape".to_string(),
AttrValue::String("Mdiamond".to_string()),
);
main_graph.nodes.insert("start".to_string(), start);
let mut exit = Node::new("exit");
exit.attrs.insert(
"shape".to_string(),
AttrValue::String("Msquare".to_string()),
);
main_graph.nodes.insert("exit".to_string(), exit);
// Apply merge transform
let merge = GraphMergeTransform::new(vec![val_graph, dep_graph]);
merge.apply(&mut main_graph);
// Add cross-module edges
main_graph.edges.push(Edge::new("start", "val.lint"));
main_graph.edges.push(Edge::new("val.test", "dep.stage"));
main_graph.edges.push(Edge::new("dep.release", "exit"));
// Verify merged nodes exist
assert!(main_graph.nodes.contains_key("val.lint"));
assert!(main_graph.nodes.contains_key("val.test"));
assert!(main_graph.nodes.contains_key("dep.stage"));
assert!(main_graph.nodes.contains_key("dep.release"));
let dir = tempfile::tempdir().unwrap();
let engine = WorkflowRunEngine::new(
make_linear_registry(),
Arc::new(EventEmitter::new()),
local_env(),
);
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let outcome = engine
.run(&main_graph, &config)
.await
.expect("graph merge E2E should succeed");
assert_eq!(outcome.status, StageStatus::Success);
let checkpoint = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
assert!(
checkpoint.completed_nodes.contains(&"val.lint".to_string()),
"val.lint should be completed"
);
assert!(
checkpoint.completed_nodes.contains(&"val.test".to_string()),
"val.test should be completed"
);
assert!(
checkpoint
.completed_nodes
.contains(&"dep.stage".to_string()),
"dep.stage should be completed"
);
assert!(
checkpoint
.completed_nodes
.contains(&"dep.release".to_string()),
"dep.release should be completed"
);
// Verify ordering: val.test appears before dep.stage
let val_test_pos = checkpoint
.completed_nodes
.iter()
.position(|n| n == "val.test")
.expect("val.test should be in completed_nodes");
let dep_stage_pos = checkpoint
.completed_nodes
.iter()
.position(|n| n == "dep.stage")
.expect("dep.stage should be in completed_nodes");
assert!(
val_test_pos < dep_stage_pos,
"val.test ({val_test_pos}) should execute before dep.stage ({dep_stage_pos})"
);
}
// ===========================================================================
// Context fidelity integration tests (spec Section 5.4)
// ===========================================================================
type SharedVec<T> = Arc<std::sync::Mutex<Vec<T>>>;
/// Shared capture storage for fidelity tests.
#[derive(Clone)]
struct FidelityCaptures {
fidelities: SharedVec<(String, String)>,
thread_ids: SharedVec<(String, Option<String>)>,
preambles: SharedVec<(String, String)>,
}
impl FidelityCaptures {
fn new() -> Self {
Self {
fidelities: Arc::new(std::sync::Mutex::new(Vec::new())),
thread_ids: Arc::new(std::sync::Mutex::new(Vec::new())),
preambles: Arc::new(std::sync::Mutex::new(Vec::new())),
}
}
}
/// A handler that captures the resolved fidelity and `thread_id` from the context.
struct FidelityCapturingHandler {
captures: FidelityCaptures,
}
#[async_trait::async_trait]
impl Handler for FidelityCapturingHandler {
async fn execute(
&self,
node: &Node,
context: &Context,
_graph: &Graph,
_run_dir: &Path,
_services: &fabro_workflows::handler::EngineServices,
) -> Result<Outcome, FabroError> {
let fidelity = context.get_string("internal.fidelity", "none");
self.captures
.fidelities
.lock()
.unwrap()
.push((node.id.clone(), fidelity));
let thread_id = context
.get("internal.thread_id")
.and_then(|v| v.as_str().map(String::from));
self.captures
.thread_ids
.lock()
.unwrap()
.push((node.id.clone(), thread_id));
let preamble = context.get_string("current.preamble", "");
self.captures
.preambles
.lock()
.unwrap()
.push((node.id.clone(), preamble));
Ok(Outcome::success())
}
}
#[tokio::test]
async fn fidelity_default_is_compact() {
let mut graph = make_graph_with_start_exit("FidelityDefaultTest");
let mut work = Node::new("work");
work.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
graph.nodes.insert("work".to_string(), work);
graph.edges.push(Edge::new("start", "work"));
graph.edges.push(Edge::new("work", "exit"));
let captures = FidelityCaptures::new();
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register(
"fidelity_capture",
Box::new(FidelityCapturingHandler {
captures: captures.clone(),
}),
);
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
engine.run(&graph, &config).await.expect("run");
let fidelities = captures.fidelities.lock().unwrap();
assert_eq!(fidelities.len(), 1);
assert_eq!(fidelities[0].0, "work");
assert_eq!(fidelities[0].1, "compact");
let preambles = captures.preambles.lock().unwrap();
assert!(
!preambles[0].1.is_empty(),
"compact fidelity should produce a preamble"
);
}
#[tokio::test]
async fn fidelity_graph_default_applied() {
let mut graph = make_graph_with_start_exit("FidelityGraphDefaultTest");
graph.attrs.insert(
"default_fidelity".to_string(),
AttrValue::String("truncate".to_string()),
);
let mut work = Node::new("work");
work.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
graph.nodes.insert("work".to_string(), work);
graph.edges.push(Edge::new("start", "work"));
graph.edges.push(Edge::new("work", "exit"));
let captures = FidelityCaptures::new();
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register(
"fidelity_capture",
Box::new(FidelityCapturingHandler {
captures: captures.clone(),
}),
);
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
engine.run(&graph, &config).await.expect("run");
let fidelities = captures.fidelities.lock().unwrap();
assert_eq!(fidelities[0].1, "truncate");
}
#[tokio::test]
async fn fidelity_node_overrides_graph_default() {
let mut graph = make_graph_with_start_exit("FidelityNodeOverrideTest");
graph.attrs.insert(
"default_fidelity".to_string(),
AttrValue::String("truncate".to_string()),
);
let mut work = Node::new("work");
work.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
work.attrs.insert(
"fidelity".to_string(),
AttrValue::String("summary:medium".to_string()),
);
graph.nodes.insert("work".to_string(), work);
graph.edges.push(Edge::new("start", "work"));
graph.edges.push(Edge::new("work", "exit"));
let captures = FidelityCaptures::new();
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register(
"fidelity_capture",
Box::new(FidelityCapturingHandler {
captures: captures.clone(),
}),
);
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
engine.run(&graph, &config).await.expect("run");
let fidelities = captures.fidelities.lock().unwrap();
assert_eq!(fidelities[0].1, "summary:medium");
}
#[tokio::test]
async fn fidelity_edge_overrides_node_and_graph() {
let mut graph = make_graph_with_start_exit("FidelityEdgeOverrideTest");
graph.attrs.insert(
"default_fidelity".to_string(),
AttrValue::String("truncate".to_string()),
);
let mut work = Node::new("work");
work.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
work.attrs.insert(
"fidelity".to_string(),
AttrValue::String("compact".to_string()),
);
graph.nodes.insert("work".to_string(), work);
let mut edge_with_fidelity = Edge::new("start", "work");
edge_with_fidelity.attrs.insert(
"fidelity".to_string(),
AttrValue::String("summary:high".to_string()),
);
graph.edges.push(edge_with_fidelity);
graph.edges.push(Edge::new("work", "exit"));
let captures = FidelityCaptures::new();
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register(
"fidelity_capture",
Box::new(FidelityCapturingHandler {
captures: captures.clone(),
}),
);
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
engine.run(&graph, &config).await.expect("run");
let fidelities = captures.fidelities.lock().unwrap();
assert_eq!(fidelities[0].1, "summary:high");
}
#[tokio::test]
async fn fidelity_full_produces_empty_preamble() {
let mut graph = make_graph_with_start_exit("FidelityFullPreambleTest");
let mut work = Node::new("work");
work.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
work.attrs.insert(
"fidelity".to_string(),
AttrValue::String("full".to_string()),
);
graph.nodes.insert("work".to_string(), work);
graph.edges.push(Edge::new("start", "work"));
graph.edges.push(Edge::new("work", "exit"));
let captures = FidelityCaptures::new();
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register(
"fidelity_capture",
Box::new(FidelityCapturingHandler {
captures: captures.clone(),
}),
);
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
engine.run(&graph, &config).await.expect("run");
let fidelities = captures.fidelities.lock().unwrap();
assert_eq!(fidelities[0].1, "full");
let preambles = captures.preambles.lock().unwrap();
assert_eq!(
preambles[0].1, "",
"full fidelity should produce empty preamble"
);
}
#[tokio::test]
async fn fidelity_truncate_preamble_minimal() {
let mut graph = make_graph_with_start_exit("FidelityTruncateTest");
graph.attrs.insert(
"goal".to_string(),
AttrValue::String("Test truncate mode".to_string()),
);
graph.attrs.insert(
"default_fidelity".to_string(),
AttrValue::String("truncate".to_string()),
);
let mut work = Node::new("work");
work.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
graph.nodes.insert("work".to_string(), work);
graph.edges.push(Edge::new("start", "work"));
graph.edges.push(Edge::new("work", "exit"));
let captures = FidelityCaptures::new();
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register(
"fidelity_capture",
Box::new(FidelityCapturingHandler {
captures: captures.clone(),
}),
);
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
engine.run(&graph, &config).await.expect("run");
let preambles = captures.preambles.lock().unwrap();
let preamble = &preambles[0].1;
assert!(
preamble.contains("Goal: Test truncate mode"),
"truncate preamble should contain the goal"
);
assert!(
preamble.contains("Run ID:"),
"truncate preamble should contain run ID"
);
assert!(
!preamble.contains("Completed stages:"),
"truncate should not include stage details"
);
}
#[tokio::test]
async fn fidelity_summary_low_mode() {
let mut graph = make_graph_with_start_exit("SummaryLow");
graph.attrs.insert(
"goal".to_string(),
AttrValue::String("Test summary".to_string()),
);
graph.attrs.insert(
"default_fidelity".to_string(),
AttrValue::String("summary:low".to_string()),
);
let mut step_a = Node::new("step_a");
step_a.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
graph.nodes.insert("step_a".to_string(), step_a);
let mut step_b = Node::new("step_b");
step_b.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
graph.nodes.insert("step_b".to_string(), step_b);
graph.edges.push(Edge::new("start", "step_a"));
graph.edges.push(Edge::new("step_a", "step_b"));
graph.edges.push(Edge::new("step_b", "exit"));
let captures = FidelityCaptures::new();
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register(
"fidelity_capture",
Box::new(FidelityCapturingHandler {
captures: captures.clone(),
}),
);
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
engine.run(&graph, &config).await.expect("run");
let fidelities = captures.fidelities.lock().unwrap();
assert_eq!(fidelities[0].1, "summary:low");
assert_eq!(fidelities[1].1, "summary:low");
let preambles = captures.preambles.lock().unwrap();
assert!(
preambles[1].1.contains("Test summary"),
"summary:low preamble should contain goal"
);
}
#[tokio::test]
async fn fidelity_summary_medium_mode() {
let mut graph = make_graph_with_start_exit("SummaryMedium");
graph.attrs.insert(
"goal".to_string(),
AttrValue::String("Test summary".to_string()),
);
graph.attrs.insert(
"default_fidelity".to_string(),
AttrValue::String("summary:medium".to_string()),
);
let mut step_a = Node::new("step_a");
step_a.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
graph.nodes.insert("step_a".to_string(), step_a);
let mut step_b = Node::new("step_b");
step_b.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
graph.nodes.insert("step_b".to_string(), step_b);
graph.edges.push(Edge::new("start", "step_a"));
graph.edges.push(Edge::new("step_a", "step_b"));
graph.edges.push(Edge::new("step_b", "exit"));
let captures = FidelityCaptures::new();
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register(
"fidelity_capture",
Box::new(FidelityCapturingHandler {
captures: captures.clone(),
}),
);
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
engine.run(&graph, &config).await.expect("run");
let fidelities = captures.fidelities.lock().unwrap();
assert_eq!(fidelities[0].1, "summary:medium");
assert_eq!(fidelities[1].1, "summary:medium");
let preambles = captures.preambles.lock().unwrap();
assert!(
preambles[1].1.contains("Test summary"),
"summary:medium preamble should contain goal"
);
}
#[tokio::test]
async fn fidelity_summary_high_mode() {
let mut graph = make_graph_with_start_exit("SummaryHigh");
graph.attrs.insert(
"goal".to_string(),
AttrValue::String("Test summary".to_string()),
);
graph.attrs.insert(
"default_fidelity".to_string(),
AttrValue::String("summary:high".to_string()),
);
let mut step_a = Node::new("step_a");
step_a.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
graph.nodes.insert("step_a".to_string(), step_a);
let mut step_b = Node::new("step_b");
step_b.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
graph.nodes.insert("step_b".to_string(), step_b);
graph.edges.push(Edge::new("start", "step_a"));
graph.edges.push(Edge::new("step_a", "step_b"));
graph.edges.push(Edge::new("step_b", "exit"));
let captures = FidelityCaptures::new();
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register(
"fidelity_capture",
Box::new(FidelityCapturingHandler {
captures: captures.clone(),
}),
);
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
engine.run(&graph, &config).await.expect("run");
let fidelities = captures.fidelities.lock().unwrap();
assert_eq!(fidelities[0].1, "summary:high");
assert_eq!(fidelities[1].1, "summary:high");
let preambles = captures.preambles.lock().unwrap();
assert!(
preambles[1].1.contains("Test summary"),
"summary:high preamble should contain goal"
);
}
#[tokio::test]
async fn fidelity_full_sets_thread_id_in_context() {
let mut graph = make_graph_with_start_exit("FidelityThreadTest");
let mut work = Node::new("work");
work.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
work.attrs.insert(
"fidelity".to_string(),
AttrValue::String("full".to_string()),
);
work.attrs.insert(
"thread_id".to_string(),
AttrValue::String("my-session".to_string()),
);
graph.nodes.insert("work".to_string(), work);
graph.edges.push(Edge::new("start", "work"));
graph.edges.push(Edge::new("work", "exit"));
let captures = FidelityCaptures::new();
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register(
"fidelity_capture",
Box::new(FidelityCapturingHandler {
captures: captures.clone(),
}),
);
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
engine.run(&graph, &config).await.expect("run");
let thread_ids = captures.thread_ids.lock().unwrap();
assert_eq!(thread_ids[0].0, "work");
assert_eq!(thread_ids[0].1, Some("my-session".to_string()));
}
#[tokio::test]
async fn fidelity_full_nodes_share_thread_id() {
let mut graph = make_graph_with_start_exit("FidelitySharedThreadTest");
let mut step_a = Node::new("step_a");
step_a.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
step_a.attrs.insert(
"fidelity".to_string(),
AttrValue::String("full".to_string()),
);
step_a.attrs.insert(
"thread_id".to_string(),
AttrValue::String("shared-session".to_string()),
);
graph.nodes.insert("step_a".to_string(), step_a);
let mut step_b = Node::new("step_b");
step_b.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
step_b.attrs.insert(
"fidelity".to_string(),
AttrValue::String("full".to_string()),
);
step_b.attrs.insert(
"thread_id".to_string(),
AttrValue::String("shared-session".to_string()),
);
graph.nodes.insert("step_b".to_string(), step_b);
graph.edges.push(Edge::new("start", "step_a"));
graph.edges.push(Edge::new("step_a", "step_b"));
graph.edges.push(Edge::new("step_b", "exit"));
let captures = FidelityCaptures::new();
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register(
"fidelity_capture",
Box::new(FidelityCapturingHandler {
captures: captures.clone(),
}),
);
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
engine.run(&graph, &config).await.expect("run");
let thread_ids = captures.thread_ids.lock().unwrap();
assert_eq!(thread_ids[0].0, "step_a");
assert_eq!(thread_ids[0].1, Some("shared-session".to_string()));
assert_eq!(thread_ids[1].0, "step_b");
assert_eq!(thread_ids[1].1, Some("shared-session".to_string()));
}
#[tokio::test]
async fn fidelity_resume_degrades_full_to_summary_high() {
let mut graph = make_graph_with_start_exit("FidelityResumeTest");
let mut step_a = Node::new("step_a");
step_a.attrs.insert(
"fidelity".to_string(),
AttrValue::String("full".to_string()),
);
graph.nodes.insert("step_a".to_string(), step_a);
let mut step_b = Node::new("step_b");
step_b.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
step_b.attrs.insert(
"fidelity".to_string(),
AttrValue::String("full".to_string()),
);
graph.nodes.insert("step_b".to_string(), step_b);
graph.edges.push(Edge::new("start", "step_a"));
graph.edges.push(Edge::new("step_a", "step_b"));
graph.edges.push(Edge::new("step_b", "exit"));
let ctx = Context::new();
ctx.set("outcome", serde_json::json!("success"));
ctx.set("internal.fidelity", serde_json::json!("full"));
let mut outcomes = std::collections::HashMap::new();
outcomes.insert("start".to_string(), Outcome::success());
outcomes.insert("step_a".to_string(), Outcome::success());
let checkpoint = Checkpoint::from_context(
&ctx,
"step_a",
vec!["start".to_string(), "step_a".to_string()],
std::collections::HashMap::new(),
outcomes,
Some("step_b".to_string()),
std::collections::HashMap::new(),
std::collections::HashMap::new(),
std::collections::HashMap::new(),
);
let captures = FidelityCaptures::new();
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register(
"fidelity_capture",
Box::new(FidelityCapturingHandler {
captures: captures.clone(),
}),
);
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
engine
.run_from_checkpoint(&graph, &config, &checkpoint)
.await
.expect("resume should succeed");
let fidelities = captures.fidelities.lock().unwrap();
assert_eq!(fidelities[0].0, "step_b");
assert_eq!(
fidelities[0].1, "summary:high",
"first node after resume from full fidelity should be degraded to summary:high"
);
}
#[tokio::test]
async fn fidelity_resume_degrade_only_affects_first_hop() {
let mut graph = make_graph_with_start_exit("FidelityResumeSingleHopTest");
let mut step_a = Node::new("step_a");
step_a.attrs.insert(
"fidelity".to_string(),
AttrValue::String("full".to_string()),
);
graph.nodes.insert("step_a".to_string(), step_a);
let mut step_b = Node::new("step_b");
step_b.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
step_b.attrs.insert(
"fidelity".to_string(),
AttrValue::String("full".to_string()),
);
graph.nodes.insert("step_b".to_string(), step_b);
let mut step_c = Node::new("step_c");
step_c.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
step_c.attrs.insert(
"fidelity".to_string(),
AttrValue::String("full".to_string()),
);
graph.nodes.insert("step_c".to_string(), step_c);
graph.edges.push(Edge::new("start", "step_a"));
graph.edges.push(Edge::new("step_a", "step_b"));
graph.edges.push(Edge::new("step_b", "step_c"));
graph.edges.push(Edge::new("step_c", "exit"));
let ctx = Context::new();
ctx.set("outcome", serde_json::json!("success"));
ctx.set("internal.fidelity", serde_json::json!("full"));
let mut outcomes = std::collections::HashMap::new();
outcomes.insert("start".to_string(), Outcome::success());
outcomes.insert("step_a".to_string(), Outcome::success());
let checkpoint = Checkpoint::from_context(
&ctx,
"step_a",
vec!["start".to_string(), "step_a".to_string()],
std::collections::HashMap::new(),
outcomes,
Some("step_b".to_string()),
std::collections::HashMap::new(),
std::collections::HashMap::new(),
std::collections::HashMap::new(),
);
let captures = FidelityCaptures::new();
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register(
"fidelity_capture",
Box::new(FidelityCapturingHandler {
captures: captures.clone(),
}),
);
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
engine
.run_from_checkpoint(&graph, &config, &checkpoint)
.await
.expect("resume should succeed");
let fidelities = captures.fidelities.lock().unwrap();
assert_eq!(fidelities[0].0, "step_b");
assert_eq!(fidelities[0].1, "summary:high");
assert_eq!(fidelities[1].0, "step_c");
assert_eq!(fidelities[1].1, "full");
}
#[tokio::test]
async fn fidelity_resume_no_degrade_when_not_full() {
let mut graph = make_graph_with_start_exit("FidelityResumeNoDegrade");
let mut step_a = Node::new("step_a");
step_a.attrs.insert(
"fidelity".to_string(),
AttrValue::String("compact".to_string()),
);
graph.nodes.insert("step_a".to_string(), step_a);
let mut step_b = Node::new("step_b");
step_b.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
step_b.attrs.insert(
"fidelity".to_string(),
AttrValue::String("full".to_string()),
);
graph.nodes.insert("step_b".to_string(), step_b);
graph.edges.push(Edge::new("start", "step_a"));
graph.edges.push(Edge::new("step_a", "step_b"));
graph.edges.push(Edge::new("step_b", "exit"));
let ctx = Context::new();
ctx.set("outcome", serde_json::json!("success"));
ctx.set("internal.fidelity", serde_json::json!("compact"));
let mut outcomes = std::collections::HashMap::new();
outcomes.insert("start".to_string(), Outcome::success());
outcomes.insert("step_a".to_string(), Outcome::success());
let checkpoint = Checkpoint::from_context(
&ctx,
"step_a",
vec!["start".to_string(), "step_a".to_string()],
std::collections::HashMap::new(),
outcomes,
Some("step_b".to_string()),
std::collections::HashMap::new(),
std::collections::HashMap::new(),
std::collections::HashMap::new(),
);
let captures = FidelityCaptures::new();
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register(
"fidelity_capture",
Box::new(FidelityCapturingHandler {
captures: captures.clone(),
}),
);
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
engine
.run_from_checkpoint(&graph, &config, &checkpoint)
.await
.expect("resume should succeed");
let fidelities = captures.fidelities.lock().unwrap();
assert_eq!(fidelities[0].0, "step_b");
assert_eq!(fidelities[0].1, "full");
}
#[tokio::test]
async fn fidelity_stored_in_checkpoint_context() {
let mut graph = make_graph_with_start_exit("FidelityCheckpointTest");
graph.attrs.insert(
"default_fidelity".to_string(),
AttrValue::String("summary:low".to_string()),
);
let work = Node::new("work");
graph.nodes.insert("work".to_string(), work);
graph.edges.push(Edge::new("start", "work"));
graph.edges.push(Edge::new("work", "exit"));
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
engine.run(&graph, &config).await.expect("run");
let cp = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
assert_eq!(
cp.context_values.get("internal.fidelity"),
Some(&serde_json::json!("summary:low")),
"checkpoint should record the resolved fidelity"
);
}
#[tokio::test]
async fn fidelity_precedence_multi_node_pipeline() {
let mut graph = make_graph_with_start_exit("FidelityPrecedenceTest");
graph.attrs.insert(
"default_fidelity".to_string(),
AttrValue::String("truncate".to_string()),
);
let mut step_a = Node::new("step_a");
step_a.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
graph.nodes.insert("step_a".to_string(), step_a);
let mut step_b = Node::new("step_b");
step_b.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
step_b.attrs.insert(
"fidelity".to_string(),
AttrValue::String("summary:medium".to_string()),
);
graph.nodes.insert("step_b".to_string(), step_b);
let mut step_c = Node::new("step_c");
step_c.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
step_c.attrs.insert(
"fidelity".to_string(),
AttrValue::String("compact".to_string()),
);
graph.nodes.insert("step_c".to_string(), step_c);
graph.edges.push(Edge::new("start", "step_a"));
graph.edges.push(Edge::new("step_a", "step_b"));
let mut edge_b_c = Edge::new("step_b", "step_c");
edge_b_c.attrs.insert(
"fidelity".to_string(),
AttrValue::String("summary:high".to_string()),
);
graph.edges.push(edge_b_c);
graph.edges.push(Edge::new("step_c", "exit"));
let captures = FidelityCaptures::new();
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register(
"fidelity_capture",
Box::new(FidelityCapturingHandler {
captures: captures.clone(),
}),
);
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
engine.run(&graph, &config).await.expect("run");
let fidelities = captures.fidelities.lock().unwrap();
assert_eq!(fidelities[0].0, "step_a");
assert_eq!(fidelities[0].1, "truncate");
assert_eq!(fidelities[1].0, "step_b");
assert_eq!(fidelities[1].1, "summary:medium");
assert_eq!(fidelities[2].0, "step_c");
assert_eq!(fidelities[2].1, "summary:high");
}
#[tokio::test]
async fn fidelity_compact_preamble_includes_completed_stages_and_context() {
let mut graph = make_graph_with_start_exit("FidelityCompactContentTest");
graph.attrs.insert(
"goal".to_string(),
AttrValue::String("Build the widget".to_string()),
);
graph.attrs.insert(
"default_fidelity".to_string(),
AttrValue::String("compact".to_string()),
);
let mut step_a = Node::new("step_a");
step_a.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
graph.nodes.insert("step_a".to_string(), step_a);
let mut step_b = Node::new("step_b");
step_b.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
graph.nodes.insert("step_b".to_string(), step_b);
graph.edges.push(Edge::new("start", "step_a"));
graph.edges.push(Edge::new("step_a", "step_b"));
graph.edges.push(Edge::new("step_b", "exit"));
let captures = FidelityCaptures::new();
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register(
"fidelity_capture",
Box::new(FidelityCapturingHandler {
captures: captures.clone(),
}),
);
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
engine.run(&graph, &config).await.expect("run");
let preambles = captures.preambles.lock().unwrap();
// step_b's preamble should contain structured summary of completed work
let step_b_preamble = &preambles[1].1;
assert!(
step_b_preamble.contains("Build the widget"),
"compact preamble should contain the goal"
);
assert!(
step_b_preamble.contains("## Completed stages"),
"compact preamble should include completed stages section"
);
assert!(
step_b_preamble.contains("step_a"),
"compact preamble should mention completed node step_a"
);
}
#[tokio::test]
async fn fidelity_summary_low_excludes_context_values_in_pipeline() {
// summary:low should NOT include context values (only goal, run ID, stage count, recent stages).
// summary:medium should include context values.
// This verifies a behavioral difference between detail levels.
let mut graph_low = make_graph_with_start_exit("SummaryLowExcludesContext");
graph_low.attrs.insert(
"goal".to_string(),
AttrValue::String("Context exclusion test".to_string()),
);
graph_low.attrs.insert(
"default_fidelity".to_string(),
AttrValue::String("summary:low".to_string()),
);
let mut step_a_low = Node::new("step_a");
step_a_low.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
graph_low.nodes.insert("step_a".to_string(), step_a_low);
let mut step_b_low = Node::new("step_b");
step_b_low.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
graph_low.nodes.insert("step_b".to_string(), step_b_low);
graph_low.edges.push(Edge::new("start", "step_a"));
graph_low.edges.push(Edge::new("step_a", "step_b"));
graph_low.edges.push(Edge::new("step_b", "exit"));
let captures_low = FidelityCaptures::new();
let dir_low = tempfile::tempdir().unwrap();
let mut registry_low = HandlerRegistry::new(Box::new(StartHandler));
registry_low.register("start", Box::new(StartHandler));
registry_low.register("exit", Box::new(ExitHandler));
registry_low.register(
"fidelity_capture",
Box::new(FidelityCapturingHandler {
captures: captures_low.clone(),
}),
);
let engine_low =
WorkflowRunEngine::new(registry_low, Arc::new(EventEmitter::new()), local_env());
let config_low = RunConfig {
run_dir: dir_low.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
engine_low
.run(&graph_low, &config_low)
.await
.expect("run low");
{
let preambles_low = captures_low.preambles.lock().unwrap();
let low_preamble = &preambles_low[1].1;
// summary:low should not include "Context values:" section
assert!(
!low_preamble.contains("Context values:"),
"summary:low preamble should not include context values section"
);
}
// Now run summary:medium and verify it DOES include context values
let mut graph_med = make_graph_with_start_exit("SummaryMedIncludesContext");
graph_med.attrs.insert(
"goal".to_string(),
AttrValue::String("Context exclusion test".to_string()),
);
graph_med.attrs.insert(
"default_fidelity".to_string(),
AttrValue::String("summary:medium".to_string()),
);
let mut step_a_med = Node::new("step_a");
step_a_med.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
graph_med.nodes.insert("step_a".to_string(), step_a_med);
let mut step_b_med = Node::new("step_b");
step_b_med.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
graph_med.nodes.insert("step_b".to_string(), step_b_med);
graph_med.edges.push(Edge::new("start", "step_a"));
graph_med.edges.push(Edge::new("step_a", "step_b"));
graph_med.edges.push(Edge::new("step_b", "exit"));
let captures_med = FidelityCaptures::new();
let dir_med = tempfile::tempdir().unwrap();
let mut registry_med = HandlerRegistry::new(Box::new(StartHandler));
registry_med.register("start", Box::new(StartHandler));
registry_med.register("exit", Box::new(ExitHandler));
registry_med.register(
"fidelity_capture",
Box::new(FidelityCapturingHandler {
captures: captures_med.clone(),
}),
);
let engine_med =
WorkflowRunEngine::new(registry_med, Arc::new(EventEmitter::new()), local_env());
let config_med = RunConfig {
run_dir: dir_med.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
engine_med
.run(&graph_med, &config_med)
.await
.expect("run med");
let preambles_med = captures_med.preambles.lock().unwrap();
let med_preamble = &preambles_med[1].1;
// summary:medium should include stage details (unlike summary:low which omits them)
assert!(
med_preamble.contains("step_a"),
"summary:medium preamble should include completed stage step_a"
);
// Verify medium and low differ: medium shows more recent stages
let preambles_low = captures_low.preambles.lock().unwrap();
let low_preamble = &preambles_low[1].1;
assert!(
!low_preamble.contains("## Context"),
"summary:low preamble should not include context section"
);
}
#[tokio::test]
async fn fidelity_thread_id_fallback_to_previous_node_in_pipeline() {
// When no thread_id is set on the node, edge, graph, or class,
// the thread ID should fall back to the previous node's ID.
let mut graph = make_graph_with_start_exit("ThreadFallbackTest");
let mut step_a = Node::new("step_a");
step_a.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
graph.nodes.insert("step_a".to_string(), step_a);
let mut step_b = Node::new("step_b");
step_b.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
graph.nodes.insert("step_b".to_string(), step_b);
graph.edges.push(Edge::new("start", "step_a"));
graph.edges.push(Edge::new("step_a", "step_b"));
graph.edges.push(Edge::new("step_b", "exit"));
let captures = FidelityCaptures::new();
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register(
"fidelity_capture",
Box::new(FidelityCapturingHandler {
captures: captures.clone(),
}),
);
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
engine.run(&graph, &config).await.expect("run");
let thread_ids = captures.thread_ids.lock().unwrap();
// step_a should have previous node = start
assert_eq!(thread_ids[0].0, "step_a");
assert_eq!(thread_ids[0].1, Some("start".to_string()));
// step_b should have previous node = step_a
assert_eq!(thread_ids[1].0, "step_b");
assert_eq!(thread_ids[1].1, Some("step_a".to_string()));
}
#[tokio::test]
async fn fidelity_thread_id_from_node_class_in_pipeline() {
// When a node has classes (from subgraph derivation), thread_id resolves
// from the first class name per spec step 4.
let mut graph = make_graph_with_start_exit("ThreadClassTest");
let mut work = Node::new("work");
work.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
work.classes = vec!["planning".to_string(), "review".to_string()];
graph.nodes.insert("work".to_string(), work);
graph.edges.push(Edge::new("start", "work"));
graph.edges.push(Edge::new("work", "exit"));
let captures = FidelityCaptures::new();
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register(
"fidelity_capture",
Box::new(FidelityCapturingHandler {
captures: captures.clone(),
}),
);
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
engine.run(&graph, &config).await.expect("run");
let thread_ids = captures.thread_ids.lock().unwrap();
assert_eq!(thread_ids[0].0, "work");
assert_eq!(
thread_ids[0].1,
Some("planning".to_string()),
"thread_id should resolve from first class name"
);
}
#[tokio::test]
async fn fidelity_edge_thread_id_override_in_pipeline() {
// Edge thread_id should override the previous-node fallback.
let mut graph = make_graph_with_start_exit("EdgeThreadOverrideTest");
let mut work = Node::new("work");
work.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
graph.nodes.insert("work".to_string(), work);
let mut edge_to_work = Edge::new("start", "work");
edge_to_work.attrs.insert(
"thread_id".to_string(),
AttrValue::String("edge-session".to_string()),
);
graph.edges.push(edge_to_work);
graph.edges.push(Edge::new("work", "exit"));
let captures = FidelityCaptures::new();
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register(
"fidelity_capture",
Box::new(FidelityCapturingHandler {
captures: captures.clone(),
}),
);
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
engine.run(&graph, &config).await.expect("run");
let thread_ids = captures.thread_ids.lock().unwrap();
assert_eq!(thread_ids[0].0, "work");
assert_eq!(
thread_ids[0].1,
Some("edge-session".to_string()),
"edge thread_id should override the previous-node fallback"
);
}
#[tokio::test]
async fn fidelity_full_without_explicit_thread_id_uses_previous_node() {
// When fidelity=full but no explicit thread_id is set, thread resolution
// should still fall back to the previous node ID.
let mut graph = make_graph_with_start_exit("FullNoExplicitThreadTest");
let mut work = Node::new("work");
work.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
work.attrs.insert(
"fidelity".to_string(),
AttrValue::String("full".to_string()),
);
// No thread_id set explicitly
graph.nodes.insert("work".to_string(), work);
graph.edges.push(Edge::new("start", "work"));
graph.edges.push(Edge::new("work", "exit"));
let captures = FidelityCaptures::new();
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register(
"fidelity_capture",
Box::new(FidelityCapturingHandler {
captures: captures.clone(),
}),
);
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
engine.run(&graph, &config).await.expect("run");
let fidelities = captures.fidelities.lock().unwrap();
assert_eq!(fidelities[0].1, "full");
let thread_ids = captures.thread_ids.lock().unwrap();
assert_eq!(thread_ids[0].0, "work");
assert_eq!(
thread_ids[0].1,
Some("start".to_string()),
"full fidelity without explicit thread_id should fall back to previous node"
);
let preambles = captures.preambles.lock().unwrap();
assert_eq!(
preambles[0].1, "",
"full fidelity should produce empty preamble"
);
}
#[tokio::test]
async fn fidelity_from_parsed_dot_pipeline() {
// Parse a DOT file with fidelity attributes and run the pipeline.
let input = r#"digraph FidelityDotTest {
graph [goal="Test DOT fidelity", default_fidelity="truncate"]
start [shape=Mdiamond]
exit [shape=Msquare]
step_a [type="fidelity_capture"]
step_b [type="fidelity_capture", fidelity="summary:medium"]
step_c [type="fidelity_capture"]
start -> step_a -> step_b
step_b -> step_c [fidelity="summary:high"]
step_c -> exit
}"#;
let graph = parse(input).expect("parsing should succeed");
validate_or_raise(&graph, &[]).expect("validation should pass");
let captures = FidelityCaptures::new();
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register(
"fidelity_capture",
Box::new(FidelityCapturingHandler {
captures: captures.clone(),
}),
);
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
engine.run(&graph, &config).await.expect("run");
let fidelities = captures.fidelities.lock().unwrap();
// step_a: no node fidelity, no edge fidelity -> graph default "truncate"
assert_eq!(fidelities[0].0, "step_a");
assert_eq!(fidelities[0].1, "truncate");
// step_b: node fidelity "summary:medium" overrides graph default
assert_eq!(fidelities[1].0, "step_b");
assert_eq!(fidelities[1].1, "summary:medium");
// step_c: node has no fidelity but incoming edge has "summary:high" -> edge wins
assert_eq!(fidelities[2].0, "step_c");
assert_eq!(fidelities[2].1, "summary:high");
}
#[tokio::test]
async fn fidelity_checkpoint_roundtrip_preserves_fidelity() {
// Run a pipeline that sets a specific fidelity, save checkpoint,
// load it, and verify the fidelity value survives the roundtrip.
let mut graph = make_graph_with_start_exit("FidelityCheckpointRoundtripTest");
graph.attrs.insert(
"default_fidelity".to_string(),
AttrValue::String("summary:high".to_string()),
);
let work = Node::new("work");
graph.nodes.insert("work".to_string(), work);
graph.edges.push(Edge::new("start", "work"));
graph.edges.push(Edge::new("work", "exit"));
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
engine.run(&graph, &config).await.expect("run");
// Load, save, load again to verify roundtrip
let checkpoint_path = dir.path().join("checkpoint.json");
let cp1 = Checkpoint::load(&checkpoint_path).expect("first load");
assert_eq!(
cp1.context_values.get("internal.fidelity"),
Some(&serde_json::json!("summary:high")),
);
let roundtrip_path = dir.path().join("checkpoint_roundtrip.json");
cp1.save(&roundtrip_path).expect("save");
let cp2 = Checkpoint::load(&roundtrip_path).expect("second load");
assert_eq!(
cp2.context_values.get("internal.fidelity"),
Some(&serde_json::json!("summary:high")),
"fidelity should survive checkpoint save/load roundtrip"
);
}
#[tokio::test]
async fn fidelity_node_thread_id_overrides_edge_thread_id_in_pipeline() {
// When both node and edge have thread_id, the node's takes precedence (spec step 1 > step 2).
let mut graph = make_graph_with_start_exit("NodeOverridesEdgeThreadTest");
let mut work = Node::new("work");
work.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
work.attrs.insert(
"thread_id".to_string(),
AttrValue::String("node-thread".to_string()),
);
graph.nodes.insert("work".to_string(), work);
let mut edge_to_work = Edge::new("start", "work");
edge_to_work.attrs.insert(
"thread_id".to_string(),
AttrValue::String("edge-thread".to_string()),
);
graph.edges.push(edge_to_work);
graph.edges.push(Edge::new("work", "exit"));
let captures = FidelityCaptures::new();
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register(
"fidelity_capture",
Box::new(FidelityCapturingHandler {
captures: captures.clone(),
}),
);
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
engine.run(&graph, &config).await.expect("run");
let thread_ids = captures.thread_ids.lock().unwrap();
assert_eq!(thread_ids[0].0, "work");
assert_eq!(
thread_ids[0].1,
Some("node-thread".to_string()),
"node thread_id should take precedence over edge thread_id"
);
}
#[tokio::test]
async fn fidelity_resume_preserves_context_values_across_checkpoint() {
// After resuming from a checkpoint, context values from the checkpoint
// should be available to the resumed nodes. This tests that fidelity-related
// context survives the resume path.
let mut graph = make_graph_with_start_exit("FidelityResumeContextTest");
let mut step_a = Node::new("step_a");
step_a.attrs.insert(
"fidelity".to_string(),
AttrValue::String("compact".to_string()),
);
graph.nodes.insert("step_a".to_string(), step_a);
let mut step_b = Node::new("step_b");
step_b.attrs.insert(
"type".to_string(),
AttrValue::String("fidelity_capture".to_string()),
);
step_b.attrs.insert(
"fidelity".to_string(),
AttrValue::String("summary:low".to_string()),
);
graph.nodes.insert("step_b".to_string(), step_b);
graph.edges.push(Edge::new("start", "step_a"));
graph.edges.push(Edge::new("step_a", "step_b"));
graph.edges.push(Edge::new("step_b", "exit"));
let ctx = Context::new();
ctx.set("outcome", serde_json::json!("success"));
ctx.set("internal.fidelity", serde_json::json!("compact"));
ctx.set("context.custom_key", serde_json::json!("custom_value"));
let mut outcomes = std::collections::HashMap::new();
outcomes.insert("start".to_string(), Outcome::success());
outcomes.insert("step_a".to_string(), Outcome::success());
let checkpoint = Checkpoint::from_context(
&ctx,
"step_a",
vec!["start".to_string(), "step_a".to_string()],
std::collections::HashMap::new(),
outcomes,
Some("step_b".to_string()),
std::collections::HashMap::new(),
std::collections::HashMap::new(),
std::collections::HashMap::new(),
);
let captures = FidelityCaptures::new();
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register(
"fidelity_capture",
Box::new(FidelityCapturingHandler {
captures: captures.clone(),
}),
);
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
engine
.run_from_checkpoint(&graph, &config, &checkpoint)
.await
.expect("resume should succeed");
let fidelities = captures.fidelities.lock().unwrap();
assert_eq!(fidelities[0].0, "step_b");
assert_eq!(
fidelities[0].1, "summary:low",
"resumed node should use its own fidelity (no degrade since checkpoint was compact, not full)"
);
// Verify the final checkpoint still has the fidelity
let final_cp = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
assert_eq!(
final_cp.context_values.get("internal.fidelity"),
Some(&serde_json::json!("summary:low")),
);
}
// ===========================================================================
// 20. Real LLM pipeline tests (requires ANTHROPIC_API_KEY)
// ===========================================================================
mod real_llm {
use std::sync::Arc;
use async_trait::async_trait;
use fabro_workflows::context::Context;
use fabro_workflows::error::FabroError;
use fabro_workflows::graph::Node;
use fabro_workflows::handler::agent::{AgentHandler, CodergenBackend, CodergenResult};
use fabro_llm::client::Client;
use fabro_llm::types::{Message, Request};
struct LlmCodergenBackend {
client: Arc<Client>,
model: String,
}
#[async_trait]
impl CodergenBackend for LlmCodergenBackend {
async fn run(
&self,
_node: &Node,
prompt: &str,
_context: &Context,
_thread_id: Option<&str>,
_emitter: &Arc<EventEmitter>,
_stage_dir: &std::path::Path,
_sandbox: &Arc<dyn fabro_agent::Sandbox>,
_tool_hooks: Option<Arc<dyn fabro_agent::ToolHookCallback>>,
) -> Result<CodergenResult, FabroError> {
self.complete(prompt).await
}
async fn one_shot(
&self,
_node: &Node,
prompt: &str,
_system_prompt: Option<&str>,
_stage_dir: &std::path::Path,
) -> Result<CodergenResult, FabroError> {
self.complete(prompt).await
}
}
impl LlmCodergenBackend {
async fn complete(&self, prompt: &str) -> Result<CodergenResult, FabroError> {
let request = Request {
model: self.model.clone(),
messages: vec![Message::user(prompt)],
provider: Some("anthropic".to_string()),
tools: None,
tool_choice: None,
response_format: None,
temperature: Some(0.0),
top_p: None,
max_tokens: Some(200),
stop_sequences: None,
reasoning_effort: None,
metadata: None,
provider_options: None,
};
let response = self
.client
.complete(&request)
.await
.map_err(|e| FabroError::handler(e.to_string()))?;
Ok(CodergenResult::Text {
text: response.text(),
usage: None,
files_touched: Vec::new(),
last_file_touched: None,
})
}
}
async fn make_llm_client() -> Option<Arc<Client>> {
let _ = dotenvy::dotenv();
if std::env::var("ANTHROPIC_API_KEY").is_err() {
return None;
}
let client = Client::from_env()
.await
.expect("unified-llm client should initialize from env");
Some(Arc::new(client))
}
fn make_llm_backend(client: Arc<Client>) -> Box<LlmCodergenBackend> {
Box::new(LlmCodergenBackend {
client,
model: "claude-haiku-4-5".to_string(),
})
}
use super::local_env;
use fabro_workflows::checkpoint::Checkpoint;
use fabro_workflows::engine::{RunConfig, WorkflowRunEngine};
use fabro_workflows::event::EventEmitter;
use fabro_workflows::graph::{AttrValue, Edge, Graph};
use fabro_workflows::handler::exit::ExitHandler;
use fabro_workflows::handler::human::HumanHandler;
use fabro_workflows::handler::start::StartHandler;
use fabro_workflows::handler::HandlerRegistry;
use fabro_workflows::interviewer::auto_approve::AutoApproveInterviewer;
use fabro_workflows::outcome::StageStatus;
#[tokio::test]
#[ignore]
async fn real_llm_linear_pipeline() {
let client = if let Some(c) = make_llm_client().await {
c
} else {
eprintln!("Skipping: ANTHROPIC_API_KEY not set");
return;
};
let mut graph = Graph::new("RealLLMLinear");
graph.attrs.insert(
"goal".to_string(),
AttrValue::String("Describe a sorting algorithm".to_string()),
);
let mut start = Node::new("start");
start.attrs.insert(
"shape".to_string(),
AttrValue::String("Mdiamond".to_string()),
);
graph.nodes.insert("start".to_string(), start);
let mut exit = Node::new("exit");
exit.attrs.insert(
"shape".to_string(),
AttrValue::String("Msquare".to_string()),
);
graph.nodes.insert("exit".to_string(), exit);
let mut plan = Node::new("plan");
plan.attrs
.insert("shape".to_string(), AttrValue::String("box".to_string()));
plan.attrs.insert(
"prompt".to_string(),
AttrValue::String("Briefly describe quicksort in 2-3 sentences.".to_string()),
);
graph.nodes.insert("plan".to_string(), plan);
let mut review = Node::new("review");
review
.attrs
.insert("shape".to_string(), AttrValue::String("box".to_string()));
review.attrs.insert(
"prompt".to_string(),
AttrValue::String(
"Review the previous description and add one improvement suggestion.".to_string(),
),
);
graph.nodes.insert("review".to_string(), review);
graph.edges.push(Edge::new("start", "plan"));
graph.edges.push(Edge::new("plan", "review"));
graph.edges.push(Edge::new("review", "exit"));
let dir = tempfile::tempdir().unwrap();
let backend = make_llm_backend(client);
let mut registry = HandlerRegistry::new(Box::new(AgentHandler::new(Some(backend))));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register(
"agent",
Box::new(AgentHandler::new(Some(make_llm_backend(
make_llm_client().await.unwrap(),
)))),
);
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let outcome = tokio::time::timeout(
std::time::Duration::from_secs(120),
engine.run(&graph, &config),
)
.await
.expect("should not timeout")
.expect("real LLM pipeline should succeed");
assert_eq!(outcome.status, StageStatus::Success);
let checkpoint = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
assert!(checkpoint.completed_nodes.contains(&"plan".to_string()));
assert!(checkpoint.completed_nodes.contains(&"review".to_string()));
let last_stage = checkpoint
.context_values
.get("last_stage")
.and_then(|v| v.as_str());
assert_eq!(last_stage, Some("review"));
// Verify actual LLM responses were written
let plan_response =
std::fs::read_to_string(dir.path().join("nodes").join("plan").join("response.md"))
.unwrap();
assert!(
!plan_response.is_empty(),
"LLM should have generated a response"
);
assert!(
!plan_response.contains("[Simulated]"),
"response should be from real LLM, not simulated"
);
}
#[tokio::test]
#[ignore]
async fn real_llm_two_stage_pipeline() {
let client = if let Some(c) = make_llm_client().await {
c
} else {
eprintln!("Skipping: ANTHROPIC_API_KEY not set");
return;
};
let mut graph = Graph::new("RealLLMTwoStage");
graph.attrs.insert(
"goal".to_string(),
AttrValue::String("Generate and review".to_string()),
);
let mut start = Node::new("start");
start.attrs.insert(
"shape".to_string(),
AttrValue::String("Mdiamond".to_string()),
);
graph.nodes.insert("start".to_string(), start);
let mut exit = Node::new("exit");
exit.attrs.insert(
"shape".to_string(),
AttrValue::String("Msquare".to_string()),
);
graph.nodes.insert("exit".to_string(), exit);
let mut generate = Node::new("generate");
generate
.attrs
.insert("shape".to_string(), AttrValue::String("box".to_string()));
generate.attrs.insert(
"prompt".to_string(),
AttrValue::String("Write a haiku about programming.".to_string()),
);
graph.nodes.insert("generate".to_string(), generate);
let mut review = Node::new("review");
review
.attrs
.insert("shape".to_string(), AttrValue::String("box".to_string()));
review.attrs.insert(
"prompt".to_string(),
AttrValue::String("Rate the haiku on a scale of 1-10.".to_string()),
);
graph.nodes.insert("review".to_string(), review);
graph.edges.push(Edge::new("start", "generate"));
graph.edges.push(Edge::new("generate", "review"));
graph.edges.push(Edge::new("review", "exit"));
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(AgentHandler::new(Some(
make_llm_backend(Arc::clone(&client)),
))));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register(
"agent",
Box::new(AgentHandler::new(Some(make_llm_backend(client)))),
);
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let outcome = tokio::time::timeout(
std::time::Duration::from_secs(120),
engine.run(&graph, &config),
)
.await
.expect("should not timeout")
.expect("real LLM two-stage pipeline should succeed");
assert_eq!(outcome.status, StageStatus::Success);
let checkpoint = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
let last_stage = checkpoint
.context_values
.get("last_stage")
.and_then(|v| v.as_str());
assert_eq!(last_stage, Some("review"));
}
#[tokio::test]
#[ignore]
async fn real_llm_human_gate_auto_approve() {
let client = if let Some(c) = make_llm_client().await {
c
} else {
eprintln!("Skipping: ANTHROPIC_API_KEY not set");
return;
};
let mut graph = Graph::new("RealLLMGate");
graph.attrs.insert(
"goal".to_string(),
AttrValue::String("Write and approve".to_string()),
);
let mut start = Node::new("start");
start.attrs.insert(
"shape".to_string(),
AttrValue::String("Mdiamond".to_string()),
);
graph.nodes.insert("start".to_string(), start);
let mut exit = Node::new("exit");
exit.attrs.insert(
"shape".to_string(),
AttrValue::String("Msquare".to_string()),
);
graph.nodes.insert("exit".to_string(), exit);
let mut write = Node::new("write");
write
.attrs
.insert("shape".to_string(), AttrValue::String("box".to_string()));
write.attrs.insert(
"prompt".to_string(),
AttrValue::String("Write a one-line greeting.".to_string()),
);
graph.nodes.insert("write".to_string(), write);
let mut gate = Node::new("gate");
gate.attrs.insert(
"shape".to_string(),
AttrValue::String("hexagon".to_string()),
);
gate.attrs
.insert("type".to_string(), AttrValue::String("human".to_string()));
gate.attrs.insert(
"label".to_string(),
AttrValue::String("Approve?".to_string()),
);
graph.nodes.insert("gate".to_string(), gate);
let mut ship = Node::new("ship");
ship.attrs
.insert("shape".to_string(), AttrValue::String("box".to_string()));
ship.attrs.insert(
"prompt".to_string(),
AttrValue::String("Ship the greeting.".to_string()),
);
graph.nodes.insert("ship".to_string(), ship);
let mut revise = Node::new("revise");
revise
.attrs
.insert("shape".to_string(), AttrValue::String("box".to_string()));
revise.attrs.insert(
"prompt".to_string(),
AttrValue::String("Revise the greeting.".to_string()),
);
graph.nodes.insert("revise".to_string(), revise);
graph.edges.push(Edge::new("start", "write"));
graph.edges.push(Edge::new("write", "gate"));
let mut approve_edge = Edge::new("gate", "ship");
approve_edge.attrs.insert(
"label".to_string(),
AttrValue::String("[A] Approve".to_string()),
);
graph.edges.push(approve_edge);
let mut revise_edge = Edge::new("gate", "revise");
revise_edge.attrs.insert(
"label".to_string(),
AttrValue::String("[R] Revise".to_string()),
);
graph.edges.push(revise_edge);
graph.edges.push(Edge::new("ship", "exit"));
graph.edges.push(Edge::new("revise", "gate"));
let dir = tempfile::tempdir().unwrap();
let interviewer = Arc::new(AutoApproveInterviewer);
let mut registry = HandlerRegistry::new(Box::new(AgentHandler::new(Some(
make_llm_backend(Arc::clone(&client)),
))));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register(
"agent",
Box::new(AgentHandler::new(Some(make_llm_backend(client)))),
);
registry.register("human", Box::new(HumanHandler::new(interviewer)));
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let outcome = tokio::time::timeout(
std::time::Duration::from_secs(120),
engine.run(&graph, &config),
)
.await
.expect("should not timeout")
.expect("real LLM gate pipeline should succeed");
assert_eq!(outcome.status, StageStatus::Success);
let checkpoint = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
assert!(
checkpoint.completed_nodes.contains(&"write".to_string()),
"write should be completed"
);
assert!(
checkpoint.completed_nodes.contains(&"gate".to_string()),
"gate should be completed"
);
assert!(
checkpoint.completed_nodes.contains(&"ship".to_string()),
"ship should be completed (auto-approve selects first option)"
);
assert!(
!checkpoint.completed_nodes.contains(&"revise".to_string()),
"revise should NOT be traversed with auto-approve"
);
}
#[tokio::test]
#[ignore]
async fn real_llm_one_shot_pipeline() {
let client = if let Some(c) = make_llm_client().await {
c
} else {
eprintln!("Skipping: ANTHROPIC_API_KEY not set");
return;
};
let mut graph = Graph::new("RealLLMOneShot");
graph.attrs.insert(
"goal".to_string(),
AttrValue::String("Classify a fruit".to_string()),
);
let mut start = Node::new("start");
start.attrs.insert(
"shape".to_string(),
AttrValue::String("Mdiamond".to_string()),
);
graph.nodes.insert("start".to_string(), start);
let mut exit = Node::new("exit");
exit.attrs.insert(
"shape".to_string(),
AttrValue::String("Msquare".to_string()),
);
graph.nodes.insert("exit".to_string(), exit);
let mut classify = Node::new("classify");
classify
.attrs
.insert("shape".to_string(), AttrValue::String("tab".to_string()));
classify.attrs.insert(
"prompt".to_string(),
AttrValue::String(
"Reply with exactly one word: is an apple a fruit or vegetable?".to_string(),
),
);
classify.attrs.insert(
"model".to_string(),
AttrValue::String("claude-haiku-4-5".to_string()),
);
graph.nodes.insert("classify".to_string(), classify);
graph.edges.push(Edge::new("start", "classify"));
graph.edges.push(Edge::new("classify", "exit"));
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(AgentHandler::new(Some(
make_llm_backend(Arc::clone(&client)),
))));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register(
"prompt",
Box::new(fabro_workflows::handler::prompt::PromptHandler::new(Some(
make_llm_backend(client),
))),
);
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let outcome = tokio::time::timeout(
std::time::Duration::from_secs(30),
engine.run(&graph, &config),
)
.await
.expect("should not timeout")
.expect("one_shot pipeline should succeed");
assert_eq!(outcome.status, StageStatus::Success);
let response_path = dir
.path()
.join("nodes")
.join("classify")
.join("response.md");
let response = std::fs::read_to_string(&response_path).unwrap();
assert!(!response.is_empty(), "response.md should be non-empty");
}
}
// ---------------------------------------------------------------------------
// Wait.human freeform edge integration tests (Section 4.6)
// ---------------------------------------------------------------------------
/// Freeform-only human gate: free-text input routes through the freeform edge
/// and stores the text in human.gate.text context variable.
#[tokio::test]
async fn human_gate_freeform_only_routes_text() {
// Graph: start -> gate -> freeform_target -> exit
// gate has only a freeform edge (no fixed choices)
let mut graph = Graph::new("FreeformOnlyTest");
let mut start = Node::new("start");
start.attrs.insert(
"shape".to_string(),
AttrValue::String("Mdiamond".to_string()),
);
graph.nodes.insert("start".to_string(), start);
let mut exit = Node::new("exit");
exit.attrs.insert(
"shape".to_string(),
AttrValue::String("Msquare".to_string()),
);
graph.nodes.insert("exit".to_string(), exit);
let mut gate = Node::new("gate");
gate.attrs.insert(
"shape".to_string(),
AttrValue::String("hexagon".to_string()),
);
gate.attrs
.insert("type".to_string(), AttrValue::String("human".to_string()));
gate.attrs.insert(
"label".to_string(),
AttrValue::String("Enter feedback".to_string()),
);
graph.nodes.insert("gate".to_string(), gate);
graph
.nodes
.insert("freeform_target".to_string(), Node::new("freeform_target"));
graph.edges.push(Edge::new("start", "gate"));
let mut freeform_edge = Edge::new("gate", "freeform_target");
freeform_edge
.attrs
.insert("freeform".to_string(), AttrValue::Boolean(true));
graph.edges.push(freeform_edge);
graph.edges.push(Edge::new("freeform_target", "exit"));
let answers = VecDeque::from([Answer::text("my free text input")]);
let interviewer = Arc::new(QueueInterviewer::new(answers));
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("human", Box::new(HumanHandler::new(interviewer)));
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let outcome = engine
.run(&graph, &config)
.await
.expect("run should succeed");
assert_eq!(outcome.status, StageStatus::Success);
let checkpoint = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
assert!(
checkpoint
.completed_nodes
.contains(&"freeform_target".to_string()),
"should have routed through freeform_target"
);
assert_eq!(
checkpoint.context_values.get("human.gate.text"),
Some(&serde_json::json!("my free text input")),
"human.gate.text should contain the freeform input"
);
assert_eq!(
checkpoint.context_values.get("human.gate.selected"),
Some(&serde_json::json!("freeform")),
"human.gate.selected should be 'freeform'"
);
assert_eq!(
checkpoint.context_values.get("human.gate.label"),
Some(&serde_json::json!("my free text input")),
"human.gate.label should contain the freeform text"
);
}
/// Human gate with both fixed choices and a freeform edge:
/// when the answer matches a fixed choice, it routes to the fixed choice target.
#[tokio::test]
async fn human_gate_freeform_with_fixed_choice_match() {
// Graph: start -> gate -> {approve, reject, freeform_target} -> exit
// gate has fixed choices plus a freeform edge
// Answer selects "A" which matches "Approve" -> routes to approve
let mut graph = Graph::new("FreeformFixedMatchTest");
let mut start = Node::new("start");
start.attrs.insert(
"shape".to_string(),
AttrValue::String("Mdiamond".to_string()),
);
graph.nodes.insert("start".to_string(), start);
let mut exit = Node::new("exit");
exit.attrs.insert(
"shape".to_string(),
AttrValue::String("Msquare".to_string()),
);
graph.nodes.insert("exit".to_string(), exit);
let mut gate = Node::new("gate");
gate.attrs.insert(
"shape".to_string(),
AttrValue::String("hexagon".to_string()),
);
gate.attrs
.insert("type".to_string(), AttrValue::String("human".to_string()));
gate.attrs.insert(
"label".to_string(),
AttrValue::String("Review Changes".to_string()),
);
graph.nodes.insert("gate".to_string(), gate);
graph
.nodes
.insert("approve".to_string(), Node::new("approve"));
graph
.nodes
.insert("reject".to_string(), Node::new("reject"));
graph
.nodes
.insert("freeform_target".to_string(), Node::new("freeform_target"));
graph.edges.push(Edge::new("start", "gate"));
let mut e_approve = Edge::new("gate", "approve");
e_approve.attrs.insert(
"label".to_string(),
AttrValue::String("[A] Approve".to_string()),
);
graph.edges.push(e_approve);
let mut e_reject = Edge::new("gate", "reject");
e_reject.attrs.insert(
"label".to_string(),
AttrValue::String("[R] Reject".to_string()),
);
graph.edges.push(e_reject);
let mut freeform_edge = Edge::new("gate", "freeform_target");
freeform_edge
.attrs
.insert("freeform".to_string(), AttrValue::Boolean(true));
graph.edges.push(freeform_edge);
graph.edges.push(Edge::new("approve", "exit"));
graph.edges.push(Edge::new("reject", "exit"));
graph.edges.push(Edge::new("freeform_target", "exit"));
// Answer selects "A" which matches the Approve choice
let answers = VecDeque::from([Answer {
value: AnswerValue::Selected("A".to_string()),
selected_option: None,
selected_options: Vec::new(),
text: None,
}]);
let interviewer = Arc::new(QueueInterviewer::new(answers));
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("human", Box::new(HumanHandler::new(interviewer)));
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let outcome = engine
.run(&graph, &config)
.await
.expect("run should succeed");
assert_eq!(outcome.status, StageStatus::Success);
let checkpoint = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
assert!(
checkpoint.completed_nodes.contains(&"approve".to_string()),
"fixed choice match should route to approve"
);
assert!(
!checkpoint
.completed_nodes
.contains(&"freeform_target".to_string()),
"should NOT route through freeform when fixed choice matches"
);
}
/// Human gate with both fixed choices and a freeform edge:
/// when the answer does NOT match any fixed choice, it falls through to the freeform edge.
#[tokio::test]
async fn human_gate_freeform_fallback_on_unmatched_text() {
// Graph: start -> gate -> {approve, reject, freeform_target} -> exit
// gate has fixed choices plus a freeform edge
// Answer is free text that doesn't match any choice -> routes to freeform_target
let mut graph = Graph::new("FreeformFallbackTest");
let mut start = Node::new("start");
start.attrs.insert(
"shape".to_string(),
AttrValue::String("Mdiamond".to_string()),
);
graph.nodes.insert("start".to_string(), start);
let mut exit = Node::new("exit");
exit.attrs.insert(
"shape".to_string(),
AttrValue::String("Msquare".to_string()),
);
graph.nodes.insert("exit".to_string(), exit);
let mut gate = Node::new("gate");
gate.attrs.insert(
"shape".to_string(),
AttrValue::String("hexagon".to_string()),
);
gate.attrs
.insert("type".to_string(), AttrValue::String("human".to_string()));
gate.attrs.insert(
"label".to_string(),
AttrValue::String("Review Changes".to_string()),
);
graph.nodes.insert("gate".to_string(), gate);
graph
.nodes
.insert("approve".to_string(), Node::new("approve"));
graph
.nodes
.insert("reject".to_string(), Node::new("reject"));
graph
.nodes
.insert("freeform_target".to_string(), Node::new("freeform_target"));
graph.edges.push(Edge::new("start", "gate"));
let mut e_approve = Edge::new("gate", "approve");
e_approve.attrs.insert(
"label".to_string(),
AttrValue::String("[A] Approve".to_string()),
);
graph.edges.push(e_approve);
let mut e_reject = Edge::new("gate", "reject");
e_reject.attrs.insert(
"label".to_string(),
AttrValue::String("[R] Reject".to_string()),
);
graph.edges.push(e_reject);
let mut freeform_edge = Edge::new("gate", "freeform_target");
freeform_edge
.attrs
.insert("freeform".to_string(), AttrValue::Boolean(true));
graph.edges.push(freeform_edge);
graph.edges.push(Edge::new("approve", "exit"));
graph.edges.push(Edge::new("reject", "exit"));
graph.edges.push(Edge::new("freeform_target", "exit"));
// Free-text answer that doesn't match any fixed choice
let answers = VecDeque::from([Answer::text("I need more context before deciding")]);
let interviewer = Arc::new(QueueInterviewer::new(answers));
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("human", Box::new(HumanHandler::new(interviewer)));
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let outcome = engine
.run(&graph, &config)
.await
.expect("run should succeed");
assert_eq!(outcome.status, StageStatus::Success);
let checkpoint = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
assert!(
checkpoint
.completed_nodes
.contains(&"freeform_target".to_string()),
"unmatched text should fall through to freeform_target"
);
assert!(
!checkpoint.completed_nodes.contains(&"approve".to_string()),
"should NOT route to approve"
);
assert!(
!checkpoint.completed_nodes.contains(&"reject".to_string()),
"should NOT route to reject"
);
assert_eq!(
checkpoint.context_values.get("human.gate.text"),
Some(&serde_json::json!("I need more context before deciding")),
"human.gate.text should contain the freeform input"
);
assert_eq!(
checkpoint.context_values.get("human.gate.selected"),
Some(&serde_json::json!("freeform")),
"human.gate.selected should be 'freeform' for freeform fallback"
);
assert_eq!(
checkpoint.context_values.get("human.gate.label"),
Some(&serde_json::json!("I need more context before deciding")),
"human.gate.label should contain the freeform text"
);
}
/// Verifies that the Question presented to the interviewer has `allow_freeform=true`
/// when a freeform edge is present on the human gate.
#[tokio::test]
async fn human_gate_freeform_sets_allow_freeform_on_question() {
// Graph: start -> gate -> {approve, freeform_target} -> exit
// gate has a fixed choice plus a freeform edge
// We use RecordingInterviewer to capture the question and verify allow_freeform
let mut graph = Graph::new("AllowFreeformTest");
let mut start = Node::new("start");
start.attrs.insert(
"shape".to_string(),
AttrValue::String("Mdiamond".to_string()),
);
graph.nodes.insert("start".to_string(), start);
let mut exit = Node::new("exit");
exit.attrs.insert(
"shape".to_string(),
AttrValue::String("Msquare".to_string()),
);
graph.nodes.insert("exit".to_string(), exit);
let mut gate = Node::new("gate");
gate.attrs.insert(
"shape".to_string(),
AttrValue::String("hexagon".to_string()),
);
gate.attrs
.insert("type".to_string(), AttrValue::String("human".to_string()));
gate.attrs.insert(
"label".to_string(),
AttrValue::String("Pick or type".to_string()),
);
graph.nodes.insert("gate".to_string(), gate);
graph
.nodes
.insert("approve".to_string(), Node::new("approve"));
graph
.nodes
.insert("freeform_target".to_string(), Node::new("freeform_target"));
graph.edges.push(Edge::new("start", "gate"));
let mut e_approve = Edge::new("gate", "approve");
e_approve.attrs.insert(
"label".to_string(),
AttrValue::String("[A] Approve".to_string()),
);
graph.edges.push(e_approve);
let mut freeform_edge = Edge::new("gate", "freeform_target");
freeform_edge
.attrs
.insert("freeform".to_string(), AttrValue::Boolean(true));
graph.edges.push(freeform_edge);
graph.edges.push(Edge::new("approve", "exit"));
graph.edges.push(Edge::new("freeform_target", "exit"));
let answers = VecDeque::from([Answer {
value: AnswerValue::Selected("A".to_string()),
selected_option: None,
selected_options: Vec::new(),
text: None,
}]);
let inner = QueueInterviewer::new(answers);
let recorder = Arc::new(RecordingInterviewer::new(Box::new(inner)));
let interviewer: Arc<dyn Interviewer> = recorder.clone();
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("human", Box::new(HumanHandler::new(interviewer)));
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let outcome = engine
.run(&graph, &config)
.await
.expect("run should succeed");
assert_eq!(outcome.status, StageStatus::Success);
let recordings = recorder.recordings();
assert_eq!(
recordings.len(),
1,
"should have recorded exactly one question"
);
assert!(
recordings[0].0.allow_freeform,
"Question should have allow_freeform=true when a freeform edge is present"
);
}
/// Verifies that the Question presented to the interviewer has `allow_freeform=false`
/// when no freeform edge is present on the human gate (fixed choices only).
#[tokio::test]
async fn human_gate_without_freeform_sets_allow_freeform_false() {
// Graph: start -> gate -> {approve, reject} -> exit
// gate has only fixed choices, no freeform edge
let mut graph = Graph::new("NoFreeformTest");
let mut start = Node::new("start");
start.attrs.insert(
"shape".to_string(),
AttrValue::String("Mdiamond".to_string()),
);
graph.nodes.insert("start".to_string(), start);
let mut exit = Node::new("exit");
exit.attrs.insert(
"shape".to_string(),
AttrValue::String("Msquare".to_string()),
);
graph.nodes.insert("exit".to_string(), exit);
let mut gate = Node::new("gate");
gate.attrs.insert(
"shape".to_string(),
AttrValue::String("hexagon".to_string()),
);
gate.attrs
.insert("type".to_string(), AttrValue::String("human".to_string()));
gate.attrs.insert(
"label".to_string(),
AttrValue::String("Pick one".to_string()),
);
graph.nodes.insert("gate".to_string(), gate);
graph
.nodes
.insert("approve".to_string(), Node::new("approve"));
graph
.nodes
.insert("reject".to_string(), Node::new("reject"));
graph.edges.push(Edge::new("start", "gate"));
let mut e_approve = Edge::new("gate", "approve");
e_approve.attrs.insert(
"label".to_string(),
AttrValue::String("[A] Approve".to_string()),
);
graph.edges.push(e_approve);
let mut e_reject = Edge::new("gate", "reject");
e_reject.attrs.insert(
"label".to_string(),
AttrValue::String("[R] Reject".to_string()),
);
graph.edges.push(e_reject);
graph.edges.push(Edge::new("approve", "exit"));
graph.edges.push(Edge::new("reject", "exit"));
let answers = VecDeque::from([Answer {
value: AnswerValue::Selected("A".to_string()),
selected_option: None,
selected_options: Vec::new(),
text: None,
}]);
let inner = QueueInterviewer::new(answers);
let recorder = Arc::new(RecordingInterviewer::new(Box::new(inner)));
let interviewer: Arc<dyn Interviewer> = recorder.clone();
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("human", Box::new(HumanHandler::new(interviewer)));
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let outcome = engine
.run(&graph, &config)
.await
.expect("run should succeed");
assert_eq!(outcome.status, StageStatus::Success);
let recordings = recorder.recordings();
assert_eq!(
recordings.len(),
1,
"should have recorded exactly one question"
);
assert!(
!recordings[0].0.allow_freeform,
"Question should have allow_freeform=false when no freeform edge is present"
);
}
// ---------------------------------------------------------------------------
// Subgraph features (Section 2.10)
// ---------------------------------------------------------------------------
#[test]
fn subgraph_node_defaults_scoped_to_subgraph() {
let input = r#"digraph SubgraphDefaults {
graph [goal="Test subgraph defaults"]
start [shape=Mdiamond]
exit [shape=Msquare]
subgraph cluster_loop {
label = "Loop A"
node [thread_id="loop-a", timeout="900s"]
plan [label="Plan next step"]
implement [label="Implement", timeout="1800s"]
}
outside [label="Outside node"]
start -> plan -> implement -> outside -> exit
}"#;
let graph = parse(input).expect("parsing should succeed");
// Plan inherits both thread_id and timeout from subgraph defaults
let plan = &graph.nodes["plan"];
assert_eq!(plan.thread_id(), Some("loop-a"));
assert_eq!(plan.timeout(), Some(std::time::Duration::from_secs(900)));
// Implement inherits thread_id but overrides timeout
let implement = &graph.nodes["implement"];
assert_eq!(implement.thread_id(), Some("loop-a"));
assert_eq!(
implement.timeout(),
Some(std::time::Duration::from_secs(1800))
);
// Outside node should NOT have subgraph defaults
let outside = &graph.nodes["outside"];
assert_eq!(outside.thread_id(), None);
assert_eq!(outside.timeout(), None);
}
#[test]
fn subgraph_class_derived_from_label() {
let input = r#"digraph SubgraphClass {
graph [goal="Test class derivation"]
start [shape=Mdiamond]
exit [shape=Msquare]
subgraph cluster_loop {
label = "Loop A"
plan [label="Plan"]
implement [label="Implement"]
}
start -> plan -> implement -> exit
}"#;
let graph = parse(input).expect("parsing should succeed");
// Nodes inside subgraph receive derived class "loop-a"
assert!(graph.nodes["plan"].classes.contains(&"loop-a".to_string()));
assert!(graph.nodes["implement"]
.classes
.contains(&"loop-a".to_string()));
// Nodes outside subgraph do not get the class
assert!(!graph.nodes["start"].classes.contains(&"loop-a".to_string()));
assert!(!graph.nodes["exit"].classes.contains(&"loop-a".to_string()));
}
#[test]
fn subgraph_class_derivation_strips_special_chars() {
let input = r#"digraph SubgraphClassStrip {
graph [goal="Test class derivation with special chars"]
subgraph cluster_review {
label = "Code Review!!!"
reviewer [label="Reviewer"]
}
}"#;
let graph = parse(input).expect("parsing should succeed");
// "Code Review!!!" -> lowercase "code review!!!" -> spaces to hyphens "code-review!!!"
// -> strip non-alphanumeric except hyphens -> "code-review"
assert!(graph.nodes["reviewer"]
.classes
.contains(&"code-review".to_string()));
}
#[test]
fn subgraph_scoping_does_not_leak_to_outer_scope() {
let input = r#"digraph SubgraphScoping {
graph [goal="Test scoping"]
node [timeout="300s"]
subgraph cluster_inner {
label = "Inner"
node [timeout="900s"]
inner_node [label="Inner"]
}
outer_node [label="Outer"]
}"#;
let graph = parse(input).expect("parsing should succeed");
// Inner node gets the subgraph-scoped timeout of 900s
let inner = &graph.nodes["inner_node"];
assert_eq!(inner.timeout(), Some(std::time::Duration::from_secs(900)));
// Outer node gets the graph-level default of 300s, not the subgraph's 900s
let outer = &graph.nodes["outer_node"];
assert_eq!(outer.timeout(), Some(std::time::Duration::from_secs(300)));
}
#[test]
fn subgraph_global_defaults_plus_subgraph_defaults() {
let input = r#"digraph SubgraphMerge {
graph [goal="Test merged defaults"]
node [shape=box, timeout="300s"]
subgraph cluster_loop {
label = "Loop"
node [thread_id="loop-thread"]
step [label="Step"]
}
plain [label="Plain"]
}"#;
let graph = parse(input).expect("parsing should succeed");
// Step should have both the global shape=box + timeout=300s and subgraph thread_id
let step = &graph.nodes["step"];
assert_eq!(step.shape(), "box");
assert_eq!(step.thread_id(), Some("loop-thread"));
assert_eq!(step.timeout(), Some(std::time::Duration::from_secs(300)));
// Plain should have the global defaults but no thread_id
let plain = &graph.nodes["plain"];
assert_eq!(plain.shape(), "box");
assert_eq!(plain.thread_id(), None);
assert_eq!(plain.timeout(), Some(std::time::Duration::from_secs(300)));
}
#[test]
fn subgraph_edges_inherit_class() {
let input = r#"digraph SubgraphEdgeClass {
graph [goal="Test edge nodes get class"]
subgraph cluster_loop {
label = "My Loop"
a [label="A"]
b [label="B"]
a -> b
}
}"#;
let graph = parse(input).expect("parsing should succeed");
// Both nodes referenced in edges within the subgraph get the derived class
assert!(graph.nodes["a"].classes.contains(&"my-loop".to_string()));
assert!(graph.nodes["b"].classes.contains(&"my-loop".to_string()));
}
#[test]
fn subgraph_without_label_no_class_derived() {
let input = r#"digraph SubgraphNoLabel {
graph [goal="Test subgraph without label"]
subgraph cluster_unnamed {
node [timeout="600s"]
worker [label="Worker"]
}
}"#;
let graph = parse(input).expect("parsing should succeed");
// No label means no class should be derived
let worker = &graph.nodes["worker"];
assert!(worker.classes.is_empty());
// But the default should still apply
assert_eq!(worker.timeout(), Some(std::time::Duration::from_secs(600)));
}
// ---------------------------------------------------------------------------
// Hook System E2E Tests
// ---------------------------------------------------------------------------
/// Helper: create a WorkflowRunEngine with hooks configured from HookDefinitions.
fn engine_with_hooks(hooks: Vec<fabro_workflows::hook::HookDefinition>) -> WorkflowRunEngine {
let registry = make_linear_registry();
let emitter = Arc::new(EventEmitter::new());
let sandbox = local_env();
let mut engine = WorkflowRunEngine::new(registry, emitter, sandbox);
if !hooks.is_empty() {
let config = fabro_workflows::hook::HookConfig { hooks };
let runner = fabro_workflows::hook::HookRunner::new(config);
engine.set_hook_runner(Arc::new(runner));
}
engine
}
/// Helper: create a WorkflowRunEngine with hooks and event capture.
fn engine_with_hooks_and_events(
hooks: Vec<fabro_workflows::hook::HookDefinition>,
) -> (
WorkflowRunEngine,
Arc<std::sync::Mutex<Vec<WorkflowRunEvent>>>,
) {
let registry = make_linear_registry();
let mut emitter = EventEmitter::new();
let events = collect_events(&mut emitter);
let sandbox = local_env();
let mut engine = WorkflowRunEngine::new(registry, Arc::new(emitter), sandbox);
if !hooks.is_empty() {
let config = fabro_workflows::hook::HookConfig { hooks };
let runner = fabro_workflows::hook::HookRunner::new(config);
engine.set_hook_runner(Arc::new(runner));
}
(engine, events)
}
fn make_run_config(dir: &std::path::Path) -> RunConfig {
RunConfig {
run_dir: dir.to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "hook-test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
}
}
fn make_hook(
event: fabro_workflows::hook::HookEvent,
command: &str,
) -> fabro_workflows::hook::HookDefinition {
fabro_workflows::hook::HookDefinition {
name: None,
event,
command: Some(command.into()),
hook_type: None,
matcher: None,
blocking: None,
timeout_ms: Some(5000),
sandbox: Some(false), // run on host for test reliability
}
}
fn simple_linear_dot() -> &'static str {
r#"digraph HookTest {
graph [goal="Test hooks"]
start [shape=Mdiamond]
exit [shape=Msquare]
work [shape=box, label="Work", prompt="Do work"]
start -> work -> exit
}"#
}
fn two_step_dot() -> &'static str {
r#"digraph HookTest {
graph [goal="Test hooks"]
start [shape=Mdiamond]
exit [shape=Msquare]
step1 [shape=box, label="Step1", prompt="First"]
step2 [shape=box, label="Step2", prompt="Second"]
start -> step1 -> step2 -> exit
}"#
}
fn branching_dot() -> &'static str {
r#"digraph HookTest {
graph [goal="Test routing"]
start [shape=Mdiamond]
exit [shape=Msquare]
plan [shape=box, label="Plan", prompt="Plan it"]
pathA [shape=box, label="PathA", prompt="Path A"]
pathB [shape=box, label="PathB", prompt="Path B"]
start -> plan
plan -> pathA [label="A"]
plan -> pathB [label="B"]
pathA -> exit
pathB -> exit
}"#
}
// --- RunStart hook tests ---
#[tokio::test]
async fn hook_run_start_proceed_allows_run() {
let hooks = vec![make_hook(
fabro_workflows::hook::HookEvent::RunStart,
"exit 0",
)];
let engine = engine_with_hooks(hooks);
let graph = parse(simple_linear_dot()).unwrap();
let dir = tempfile::tempdir().unwrap();
let config = make_run_config(dir.path());
let outcome = engine.run(&graph, &config).await.unwrap();
assert_eq!(outcome.status, StageStatus::Success);
}
#[tokio::test]
async fn hook_run_start_block_prevents_run() {
let hooks = vec![make_hook(
fabro_workflows::hook::HookEvent::RunStart,
"exit 1",
)];
let (engine, events) = engine_with_hooks_and_events(hooks);
let graph = parse(simple_linear_dot()).unwrap();
let dir = tempfile::tempdir().unwrap();
let config = make_run_config(dir.path());
let result = engine.run(&graph, &config).await;
assert!(result.is_err(), "RunStart block should cause error");
let err = result.unwrap_err();
assert!(
err.to_string().contains("hook"),
"Error should mention hook: {err}"
);
// WorkflowRunStarted should still have been emitted (it fires before the hook)
let captured = events.lock().unwrap();
assert!(
captured
.iter()
.any(|e| matches!(e, WorkflowRunEvent::WorkflowRunStarted { .. })),
"WorkflowRunStarted should be emitted before hook blocks"
);
// But no StageStarted — the run never reached node execution
assert!(
!captured
.iter()
.any(|e| matches!(e, WorkflowRunEvent::StageStarted { .. })),
"No stage should start when RunStart hook blocks"
);
}
#[tokio::test]
async fn hook_run_start_block_with_json_reason() {
// Hook that outputs JSON with a reason
let hooks = vec![make_hook(
fabro_workflows::hook::HookEvent::RunStart,
r#"echo '{"decision":"block","reason":"policy violation"}'; exit 2"#,
)];
let engine = engine_with_hooks(hooks);
let graph = parse(simple_linear_dot()).unwrap();
let dir = tempfile::tempdir().unwrap();
let config = make_run_config(dir.path());
let result = engine.run(&graph, &config).await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
err.to_string().contains("policy violation"),
"Error should contain JSON reason: {err}"
);
}
// --- StageStart hook tests ---
#[tokio::test]
async fn hook_stage_start_proceed_allows_execution() {
let hooks = vec![make_hook(
fabro_workflows::hook::HookEvent::StageStart,
"exit 0",
)];
let engine = engine_with_hooks(hooks);
let graph = parse(simple_linear_dot()).unwrap();
let dir = tempfile::tempdir().unwrap();
let config = make_run_config(dir.path());
let outcome = engine.run(&graph, &config).await.unwrap();
assert_eq!(outcome.status, StageStatus::Success);
// Work node should have executed (response.md exists)
assert!(
dir.path()
.join("nodes")
.join("work")
.join("response.md")
.exists(),
"response.md should exist when StageStart hook proceeds"
);
}
#[tokio::test]
async fn hook_stage_start_skip_bypasses_node() {
// Hook that outputs skip decision as JSON
let hooks = vec![make_hook(
fabro_workflows::hook::HookEvent::StageStart,
r#"echo '{"decision":"skip","reason":"not needed"}'; exit 0"#,
)];
let (engine, events) = engine_with_hooks_and_events(hooks);
let graph = parse(simple_linear_dot()).unwrap();
let dir = tempfile::tempdir().unwrap();
let config = make_run_config(dir.path());
let outcome = engine.run(&graph, &config).await.unwrap();
// When the only work node is skipped, the final outcome reflects that
assert_eq!(outcome.status, StageStatus::Skipped);
// response.md should NOT exist for the work node (it was skipped)
assert!(
!dir.path()
.join("nodes")
.join("work")
.join("response.md")
.exists(),
"response.md should not exist when StageStart hook skips node"
);
// StageStarted should have been emitted
let captured = events.lock().unwrap();
let stage_starts: Vec<_> = captured
.iter()
.filter(|e| {
matches!(e, WorkflowRunEvent::StageStarted { handler_type, .. }
if handler_type.as_deref() != Some("start") && handler_type.as_deref() != Some("exit"))
})
.collect();
assert!(
!stage_starts.is_empty(),
"StageStarted should be emitted before hook skips"
);
}
#[tokio::test]
async fn hook_stage_start_block_aborts_run() {
let hooks = vec![make_hook(
fabro_workflows::hook::HookEvent::StageStart,
"exit 1",
)];
let engine = engine_with_hooks(hooks);
let graph = parse(simple_linear_dot()).unwrap();
let dir = tempfile::tempdir().unwrap();
let config = make_run_config(dir.path());
let result = engine.run(&graph, &config).await;
assert!(result.is_err(), "StageStart block should abort the run");
}
#[tokio::test]
async fn hook_stage_start_matcher_filters_by_node_id() {
// Hook that only matches nodes with "step2" in their ID
let mut hook = make_hook(
fabro_workflows::hook::HookEvent::StageStart,
r#"echo '{"decision":"skip","reason":"filtered"}'"#,
);
hook.matcher = Some("step2".into());
let hooks = vec![hook];
let engine = engine_with_hooks(hooks);
let graph = parse(two_step_dot()).unwrap();
let dir = tempfile::tempdir().unwrap();
let config = make_run_config(dir.path());
let outcome = engine.run(&graph, &config).await.unwrap();
// step2 is the last completed node and was skipped
assert_eq!(outcome.status, StageStatus::Skipped);
// step1 should have executed (response.md exists)
assert!(
dir.path()
.join("nodes")
.join("step1")
.join("response.md")
.exists(),
"step1 should execute because matcher doesn't match it"
);
// step2 should have been skipped (no response.md)
assert!(
!dir.path()
.join("nodes")
.join("step2")
.join("response.md")
.exists(),
"step2 should be skipped because matcher matches it"
);
}
#[tokio::test]
async fn hook_stage_start_matcher_no_match_proceeds() {
// Hook with matcher that matches nothing
let mut hook = make_hook(fabro_workflows::hook::HookEvent::StageStart, "exit 1");
hook.matcher = Some("nonexistent_node".into());
let hooks = vec![hook];
let engine = engine_with_hooks(hooks);
let graph = parse(simple_linear_dot()).unwrap();
let dir = tempfile::tempdir().unwrap();
let config = make_run_config(dir.path());
let outcome = engine.run(&graph, &config).await.unwrap();
assert_eq!(outcome.status, StageStatus::Success);
}
// --- StageComplete hook tests ---
#[tokio::test]
async fn hook_stage_complete_fires_after_success() {
let dir = tempfile::tempdir().unwrap();
let marker = dir.path().join("stage_complete_marker.txt");
let hooks = vec![make_hook(
fabro_workflows::hook::HookEvent::StageComplete,
&format!("echo $FABRO_NODE_ID >> {}", marker.display()),
)];
let engine = engine_with_hooks(hooks);
let graph = parse(two_step_dot()).unwrap();
let config = make_run_config(dir.path());
let outcome = engine.run(&graph, &config).await.unwrap();
assert_eq!(outcome.status, StageStatus::Success);
// Marker file should exist and contain node IDs
assert!(
marker.exists(),
"StageComplete hook should have written marker file"
);
let content = std::fs::read_to_string(&marker).unwrap();
// start, step1, step2, exit all complete — hook fires for each
assert!(
content.contains("step1"),
"Marker should contain step1: {content}"
);
assert!(
content.contains("step2"),
"Marker should contain step2: {content}"
);
}
#[tokio::test]
async fn hook_stage_complete_failure_does_not_block_pipeline() {
// Non-blocking hook that fails should not affect the pipeline
let hooks = vec![make_hook(
fabro_workflows::hook::HookEvent::StageComplete,
"exit 1",
)];
let engine = engine_with_hooks(hooks);
let graph = parse(simple_linear_dot()).unwrap();
let dir = tempfile::tempdir().unwrap();
let config = make_run_config(dir.path());
let outcome = engine.run(&graph, &config).await.unwrap();
assert_eq!(
outcome.status,
StageStatus::Success,
"Non-blocking StageComplete hook failure should not block pipeline"
);
}
// --- RunComplete hook tests ---
#[tokio::test]
async fn hook_run_complete_fires_on_success() {
let dir = tempfile::tempdir().unwrap();
let marker = dir.path().join("run_complete_marker.txt");
let hooks = vec![make_hook(
fabro_workflows::hook::HookEvent::RunComplete,
&format!("echo done > {}", marker.display()),
)];
let engine = engine_with_hooks(hooks);
let graph = parse(simple_linear_dot()).unwrap();
let config = make_run_config(dir.path());
let outcome = engine.run(&graph, &config).await.unwrap();
assert_eq!(outcome.status, StageStatus::Success);
assert!(
marker.exists(),
"RunComplete hook should have written marker file"
);
let content = std::fs::read_to_string(&marker).unwrap();
assert_eq!(content.trim(), "done");
}
#[tokio::test]
async fn hook_run_complete_does_not_fire_on_blocked_run() {
let dir = tempfile::tempdir().unwrap();
let marker = dir.path().join("run_complete_should_not_exist.txt");
let hooks = vec![
make_hook(
fabro_workflows::hook::HookEvent::RunStart,
"exit 1", // block the run
),
make_hook(
fabro_workflows::hook::HookEvent::RunComplete,
&format!("echo done > {}", marker.display()),
),
];
let engine = engine_with_hooks(hooks);
let graph = parse(simple_linear_dot()).unwrap();
let config = make_run_config(dir.path());
let _ = engine.run(&graph, &config).await;
assert!(
!marker.exists(),
"RunComplete hook should not fire when run is blocked by RunStart"
);
}
// --- RunFailed hook tests ---
#[tokio::test]
async fn hook_run_failed_fires_on_stage_block() {
let dir = tempfile::tempdir().unwrap();
let marker = dir.path().join("run_failed_marker.txt");
let hooks = vec![
make_hook(
fabro_workflows::hook::HookEvent::StageStart,
"exit 1", // block during stage
),
make_hook(
fabro_workflows::hook::HookEvent::RunFailed,
&format!("echo failed > {}", marker.display()),
),
];
let engine = engine_with_hooks(hooks);
let graph = parse(simple_linear_dot()).unwrap();
let config = make_run_config(dir.path());
let _ = engine.run(&graph, &config).await;
// RunFailed may or may not fire depending on the error path — a StageStart
// block causes an engine error, which doesn't go through the normal
// WorkflowRunFailed event. Let's just verify no panic occurs.
}
// --- Environment variables ---
#[tokio::test]
async fn hook_receives_env_vars() {
let dir = tempfile::tempdir().unwrap();
let env_file = dir.path().join("hook_env.txt");
let hooks = vec![make_hook(
fabro_workflows::hook::HookEvent::StageComplete,
&format!(
"echo \"event=$FABRO_EVENT run=$FABRO_RUN_ID wf=$FABRO_WORKFLOW node=$FABRO_NODE_ID\" >> {}",
env_file.display()
),
)];
let engine = engine_with_hooks(hooks);
let graph = parse(simple_linear_dot()).unwrap();
let config = make_run_config(dir.path());
engine.run(&graph, &config).await.unwrap();
assert!(env_file.exists(), "Env file should be written by hook");
let content = std::fs::read_to_string(&env_file).unwrap();
// Should contain lines like: event=stage_complete run=hook-test-run wf=HookTest node=work
let lines: Vec<&str> = content.lines().collect();
let work_line = lines.iter().find(|l| l.contains("node=work"));
assert!(
work_line.is_some(),
"Should have a line for node=work, got: {content}"
);
let line = work_line.unwrap();
assert!(
line.contains("event=stage_complete"),
"FABRO_EVENT should be set: {line}"
);
assert!(
line.contains("run=hook-test-run"),
"FABRO_RUN_ID should be set: {line}"
);
assert!(
line.contains("wf=HookTest"),
"FABRO_WORKFLOW should be set: {line}"
);
}
// --- Multiple hooks for same event ---
#[tokio::test]
async fn multiple_hooks_same_event_all_fire() {
let dir = tempfile::tempdir().unwrap();
let marker1 = dir.path().join("hook1.txt");
let marker2 = dir.path().join("hook2.txt");
let hooks = vec![
make_hook(
fabro_workflows::hook::HookEvent::StageComplete,
&format!("echo hook1 > {}", marker1.display()),
),
make_hook(
fabro_workflows::hook::HookEvent::StageComplete,
&format!("echo hook2 > {}", marker2.display()),
),
];
let engine = engine_with_hooks(hooks);
let graph = parse(simple_linear_dot()).unwrap();
let config = make_run_config(dir.path());
engine.run(&graph, &config).await.unwrap();
assert!(marker1.exists(), "First hook should have fired");
assert!(marker2.exists(), "Second hook should have fired");
}
// --- No hooks configured (baseline) ---
#[tokio::test]
async fn no_hooks_configured_runs_normally() {
let engine = engine_with_hooks(vec![]);
let graph = parse(simple_linear_dot()).unwrap();
let dir = tempfile::tempdir().unwrap();
let config = make_run_config(dir.path());
let outcome = engine.run(&graph, &config).await.unwrap();
assert_eq!(outcome.status, StageStatus::Success);
}
// --- EdgeSelected hook tests ---
#[tokio::test]
async fn hook_edge_selected_override_redirects_routing() {
// Hook that overrides edge routing to pathB when it would go to pathA
let mut hook = make_hook(
fabro_workflows::hook::HookEvent::EdgeSelected,
// Override routing to pathB
r#"echo '{"decision":"override","edge_to":"pathB"}'"#,
);
// Only match edges going FROM plan
hook.matcher = Some("^plan$".into());
let hooks = vec![hook];
let (engine, events) = engine_with_hooks_and_events(hooks);
let graph = parse(branching_dot()).unwrap();
let dir = tempfile::tempdir().unwrap();
let config = make_run_config(dir.path());
let outcome = engine.run(&graph, &config).await.unwrap();
assert_eq!(outcome.status, StageStatus::Success);
// Verify pathB was executed (override worked)
let captured = events.lock().unwrap();
let completed_nodes: Vec<String> = captured
.iter()
.filter_map(|e| match e {
WorkflowRunEvent::StageCompleted { node_id, .. } => Some(node_id.clone()),
_ => None,
})
.collect();
assert!(
completed_nodes.contains(&"pathB".to_string()),
"pathB should have been executed due to override: {completed_nodes:?}"
);
}
#[tokio::test]
async fn hook_edge_selected_block_aborts_run() {
let mut hook = make_hook(fabro_workflows::hook::HookEvent::EdgeSelected, "exit 1");
hook.matcher = Some("^plan$".into());
let hooks = vec![hook];
let engine = engine_with_hooks(hooks);
let graph = parse(branching_dot()).unwrap();
let dir = tempfile::tempdir().unwrap();
let config = make_run_config(dir.path());
let result = engine.run(&graph, &config).await;
assert!(result.is_err(), "EdgeSelected block should abort the run");
}
// --- CheckpointSaved hook ---
#[tokio::test]
async fn hook_checkpoint_saved_fires() {
let dir = tempfile::tempdir().unwrap();
let marker = dir.path().join("checkpoint_marker.txt");
let hooks = vec![make_hook(
fabro_workflows::hook::HookEvent::CheckpointSaved,
&format!("echo $FABRO_NODE_ID >> {}", marker.display()),
)];
let engine = engine_with_hooks(hooks);
let graph = parse(simple_linear_dot()).unwrap();
let config = make_run_config(dir.path());
let outcome = engine.run(&graph, &config).await.unwrap();
assert_eq!(outcome.status, StageStatus::Success);
// Checkpoint is saved after each node
assert!(marker.exists(), "CheckpointSaved hook should have fired");
let content = std::fs::read_to_string(&marker).unwrap();
assert!(
content.contains("work"),
"Should contain 'work' node checkpoint: {content}"
);
}
// --- StageStart with JSON skip via exit code 2 ---
#[tokio::test]
async fn hook_stage_start_exit_2_blocks() {
let hooks = vec![make_hook(
fabro_workflows::hook::HookEvent::StageStart,
"exit 2",
)];
let engine = engine_with_hooks(hooks);
let graph = parse(simple_linear_dot()).unwrap();
let dir = tempfile::tempdir().unwrap();
let config = make_run_config(dir.path());
// exit 2 without JSON defaults to Block
let result = engine.run(&graph, &config).await;
assert!(result.is_err(), "exit 2 should block");
}
// --- Config merge tests (server + run) ---
#[tokio::test]
async fn hook_config_merge_concatenates() {
use fabro_workflows::hook::{HookConfig, HookDefinition, HookEvent};
let server_hooks = HookConfig {
hooks: vec![HookDefinition {
name: Some("server-hook".into()),
event: HookEvent::RunStart,
command: Some("exit 0".into()),
hook_type: None,
matcher: None,
blocking: None,
timeout_ms: None,
sandbox: Some(false),
}],
};
let run_hooks = HookConfig {
hooks: vec![HookDefinition {
name: Some("run-hook".into()),
event: HookEvent::StageComplete,
command: Some("exit 0".into()),
hook_type: None,
matcher: None,
blocking: None,
timeout_ms: None,
sandbox: Some(false),
}],
};
let merged = server_hooks.merge(run_hooks);
assert_eq!(merged.hooks.len(), 2);
assert_eq!(merged.hooks[0].name.as_deref(), Some("server-hook"));
assert_eq!(merged.hooks[1].name.as_deref(), Some("run-hook"));
}
#[tokio::test]
async fn hook_config_merge_run_overrides_by_name() {
use fabro_workflows::hook::{HookConfig, HookDefinition, HookEvent};
let server_hooks = HookConfig {
hooks: vec![HookDefinition {
name: Some("shared".into()),
event: HookEvent::RunStart,
command: Some("exit 1".into()), // would block
hook_type: None,
matcher: None,
blocking: None,
timeout_ms: None,
sandbox: Some(false),
}],
};
let run_hooks = HookConfig {
hooks: vec![HookDefinition {
name: Some("shared".into()),
event: HookEvent::RunStart,
command: Some("exit 0".into()), // allows
hook_type: None,
matcher: None,
blocking: None,
timeout_ms: None,
sandbox: Some(false),
}],
};
let merged = server_hooks.merge(run_hooks);
assert_eq!(merged.hooks.len(), 1);
// Run config wins — command should be "exit 0"
assert_eq!(merged.hooks[0].command.as_deref(), Some("exit 0"));
// Verify it actually works end-to-end
let engine = engine_with_hooks(merged.hooks);
let graph = parse(simple_linear_dot()).unwrap();
let dir = tempfile::tempdir().unwrap();
let config = make_run_config(dir.path());
let outcome = engine.run(&graph, &config).await.unwrap();
assert_eq!(outcome.status, StageStatus::Success);
}
// --- TOML config parsing integration ---
#[test]
fn hook_toml_run_config_parsing() {
let toml = r#"
version = 1
goal = "Test hooks in run config"
graph = "test.fabro"
[[hooks]]
event = "stage_start"
command = "./scripts/pre-check.sh"
matcher = "agent_loop"
blocking = true
timeout_ms = 30000
sandbox = false
[[hooks]]
event = "run_complete"
command = "echo done"
"#;
let cfg: fabro_workflows::cli::run_config::WorkflowRunConfig = toml::from_str(toml).unwrap();
assert_eq!(cfg.hooks.len(), 2);
assert_eq!(
cfg.hooks[0].event,
fabro_workflows::hook::HookEvent::StageStart
);
assert_eq!(cfg.hooks[0].matcher.as_deref(), Some("agent_loop"));
assert!(cfg.hooks[0].is_blocking());
assert!(!cfg.hooks[0].runs_in_sandbox());
assert_eq!(
cfg.hooks[0].timeout(),
std::time::Duration::from_millis(30000)
);
assert_eq!(
cfg.hooks[1].event,
fabro_workflows::hook::HookEvent::RunComplete
);
assert!(!cfg.hooks[1].is_blocking()); // RunComplete non-blocking by default
}
// --- Blocking vs non-blocking behavior ---
#[tokio::test]
async fn hook_blocking_override_makes_non_blocking_event_blocking() {
// StageComplete is non-blocking by default, but force it to blocking
let mut hook = make_hook(fabro_workflows::hook::HookEvent::StageComplete, "exit 1");
hook.blocking = Some(true);
let hooks = vec![hook];
let engine = engine_with_hooks(hooks);
let graph = parse(simple_linear_dot()).unwrap();
let dir = tempfile::tempdir().unwrap();
let config = make_run_config(dir.path());
// This test verifies that the blocking override is respected
// Note: StageComplete hooks run AFTER execution, so they use the
// non-blocking path in the engine (the engine doesn't check blocking
// for StageComplete since it's always after the fact). This is correct
// behavior — the blocking flag only affects the runner's execution
// strategy (sequential vs parallel), not the engine's decision handling.
let outcome = engine.run(&graph, &config).await.unwrap();
assert_eq!(outcome.status, StageStatus::Success);
}
#[tokio::test]
async fn hook_non_blocking_override_on_blocking_event() {
// RunStart is blocking by default, but force it to non-blocking
let mut hook = make_hook(fabro_workflows::hook::HookEvent::RunStart, "exit 1");
hook.blocking = Some(false);
let hooks = vec![hook];
let engine = engine_with_hooks(hooks);
let graph = parse(simple_linear_dot()).unwrap();
let dir = tempfile::tempdir().unwrap();
let config = make_run_config(dir.path());
// With blocking=false, the RunStart hook failure should NOT block the run
// because the runner treats it as non-blocking (doesn't merge decisions)
let outcome = engine.run(&graph, &config).await.unwrap();
assert_eq!(outcome.status, StageStatus::Success);
}
// --- Regex matcher tests ---
#[tokio::test]
async fn hook_matcher_regex_pattern() {
// Hook matches any node starting with "step"
let mut hook = make_hook(
fabro_workflows::hook::HookEvent::StageStart,
r#"echo '{"decision":"skip","reason":"regex match"}'"#,
);
hook.matcher = Some("^step".into());
let hooks = vec![hook];
let engine = engine_with_hooks(hooks);
let graph = parse(two_step_dot()).unwrap();
let dir = tempfile::tempdir().unwrap();
let config = make_run_config(dir.path());
let outcome = engine.run(&graph, &config).await.unwrap();
// Both step nodes were skipped, so the last outcome is Skipped
assert_eq!(outcome.status, StageStatus::Skipped);
// Both step1 and step2 should be skipped
assert!(
!dir.path()
.join("nodes")
.join("step1")
.join("response.md")
.exists(),
"step1 should be skipped by regex ^step"
);
assert!(
!dir.path()
.join("nodes")
.join("step2")
.join("response.md")
.exists(),
"step2 should be skipped by regex ^step"
);
}
// --- JSON decision parsing from hook stdout ---
#[tokio::test]
async fn hook_json_proceed_explicit() {
let hooks = vec![make_hook(
fabro_workflows::hook::HookEvent::RunStart,
r#"echo '{"decision":"proceed"}'"#,
)];
let engine = engine_with_hooks(hooks);
let graph = parse(simple_linear_dot()).unwrap();
let dir = tempfile::tempdir().unwrap();
let config = make_run_config(dir.path());
let outcome = engine.run(&graph, &config).await.unwrap();
assert_eq!(outcome.status, StageStatus::Success);
}
#[tokio::test]
async fn hook_json_block_with_reason() {
let hooks = vec![make_hook(
fabro_workflows::hook::HookEvent::RunStart,
r#"echo '{"decision":"block","reason":"forbidden by policy"}'; exit 2"#,
)];
let engine = engine_with_hooks(hooks);
let graph = parse(simple_linear_dot()).unwrap();
let dir = tempfile::tempdir().unwrap();
let config = make_run_config(dir.path());
let result = engine.run(&graph, &config).await;
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("forbidden by policy"));
}
// --- Sandbox field tests ---
#[tokio::test]
async fn hook_sandbox_false_runs_on_host() {
let dir = tempfile::tempdir().unwrap();
let marker = dir.path().join("host_hook.txt");
let mut hook = make_hook(
fabro_workflows::hook::HookEvent::RunComplete,
&format!("echo host > {}", marker.display()),
);
hook.sandbox = Some(false);
let hooks = vec![hook];
let engine = engine_with_hooks(hooks);
let graph = parse(simple_linear_dot()).unwrap();
let config = make_run_config(dir.path());
engine.run(&graph, &config).await.unwrap();
assert!(marker.exists(), "Host hook should write marker file");
assert_eq!(std::fs::read_to_string(&marker).unwrap().trim(), "host");
}
// --- Prompt and Agent hook TOML parsing ---
#[test]
fn hook_toml_prompt_and_agent_parsing() {
let toml = r#"
version = 1
goal = "Test prompt/agent hooks"
graph = "test.fabro"
[[hooks]]
event = "stage_start"
type = "prompt"
prompt = "Should this stage proceed?"
model = "haiku"
[[hooks]]
event = "run_complete"
type = "agent"
prompt = "Verify all tests pass."
model = "sonnet"
max_tool_rounds = 10
timeout_ms = 120000
"#;
let cfg: fabro_workflows::cli::run_config::WorkflowRunConfig = toml::from_str(toml).unwrap();
assert_eq!(cfg.hooks.len(), 2);
// Prompt hook
assert_eq!(
cfg.hooks[0].event,
fabro_workflows::hook::HookEvent::StageStart
);
assert!(matches!(
cfg.hooks[0].resolved_hook_type().as_deref(),
Some(fabro_workflows::hook::HookType::Prompt { prompt, model })
if prompt == "Should this stage proceed?" && *model == Some("haiku".into())
));
assert_eq!(
cfg.hooks[0].timeout(),
std::time::Duration::from_millis(30000)
);
// Agent hook
assert_eq!(
cfg.hooks[1].event,
fabro_workflows::hook::HookEvent::RunComplete
);
assert!(matches!(
cfg.hooks[1].resolved_hook_type().as_deref(),
Some(fabro_workflows::hook::HookType::Agent { prompt, model, max_tool_rounds })
if prompt == "Verify all tests pass."
&& *model == Some("sonnet".into())
&& *max_tool_rounds == Some(10)
));
assert_eq!(
cfg.hooks[1].timeout(),
std::time::Duration::from_millis(120000)
);
}
// --- Prompt/Agent hook E2E with real LLM ---
#[tokio::test]
#[ignore = "requires ANTHROPIC_API_KEY"]
async fn hook_prompt_proceed_allows_run() {
dotenvy::dotenv().ok();
let hooks = vec![fabro_workflows::hook::HookDefinition {
name: Some("prompt-proceed".into()),
event: fabro_workflows::hook::HookEvent::RunStart,
command: None,
hook_type: Some(fabro_workflows::hook::HookType::Prompt {
prompt: "A workflow is starting. Always approve. Respond with {\"ok\": true}.".into(),
model: Some("haiku".into()),
}),
matcher: None,
blocking: None,
timeout_ms: Some(30000),
sandbox: None,
}];
let engine = engine_with_hooks(hooks);
let graph = parse(simple_linear_dot()).unwrap();
let dir = tempfile::tempdir().unwrap();
let config = make_run_config(dir.path());
let outcome = engine.run(&graph, &config).await.unwrap();
assert_eq!(outcome.status, StageStatus::Success);
}
#[tokio::test]
#[ignore = "requires ANTHROPIC_API_KEY"]
async fn hook_prompt_block_prevents_run() {
dotenvy::dotenv().ok();
// Use a factual question that evaluates to false: "Is 2+2=5?"
let hooks = vec![fabro_workflows::hook::HookDefinition {
name: Some("prompt-block".into()),
event: fabro_workflows::hook::HookEvent::RunStart,
command: None,
hook_type: Some(fabro_workflows::hook::HookType::Prompt {
prompt: "Check: is 2+2 equal to 5? If the statement is true, respond {\"ok\": true}. If false, respond {\"ok\": false, \"reason\": \"math check failed\"}.".into(),
model: Some("haiku".into()),
}),
matcher: None,
blocking: None,
timeout_ms: Some(30000),
sandbox: None,
}];
let engine = engine_with_hooks(hooks);
let graph = parse(simple_linear_dot()).unwrap();
let dir = tempfile::tempdir().unwrap();
let config = make_run_config(dir.path());
let result = engine.run(&graph, &config).await;
assert!(
result.is_err(),
"Prompt hook block should cause error, got: {result:?}"
);
}
#[tokio::test]
#[ignore = "requires ANTHROPIC_API_KEY"]
async fn hook_agent_proceed_allows_run() {
dotenvy::dotenv().ok();
let hooks = vec![fabro_workflows::hook::HookDefinition {
name: Some("agent-proceed".into()),
event: fabro_workflows::hook::HookEvent::RunStart,
command: None,
hook_type: Some(fabro_workflows::hook::HookType::Agent {
prompt: "A workflow is starting. Always approve. Respond with {\"ok\": true}. Do not use any tools.".into(),
model: Some("haiku".into()),
max_tool_rounds: Some(1),
}),
matcher: None,
blocking: None,
timeout_ms: Some(60000),
sandbox: None,
}];
let engine = engine_with_hooks(hooks);
let graph = parse(simple_linear_dot()).unwrap();
let dir = tempfile::tempdir().unwrap();
let config = make_run_config(dir.path());
let outcome = engine.run(&graph, &config).await.unwrap();
assert_eq!(outcome.status, StageStatus::Success);
}
#[tokio::test]
#[ignore = "requires ANTHROPIC_API_KEY"]
async fn hook_agent_with_tool_use() {
dotenvy::dotenv().ok();
let dir = tempfile::tempdir().unwrap();
let marker = dir.path().join("hook_check.txt");
std::fs::write(&marker, "READY").unwrap();
let hooks = vec![fabro_workflows::hook::HookDefinition {
name: Some("agent-tools".into()),
event: fabro_workflows::hook::HookEvent::RunStart,
command: None,
hook_type: Some(fabro_workflows::hook::HookType::Agent {
prompt: format!(
"Read the file at {} using the read_file tool. If it contains 'READY', respond with {{\"ok\": true}}. Otherwise respond with {{\"ok\": false, \"reason\": \"not ready\"}}.",
marker.display()
),
model: Some("haiku".into()),
max_tool_rounds: Some(5),
}),
matcher: None,
blocking: None,
timeout_ms: Some(60000),
sandbox: None,
}];
let engine = engine_with_hooks(hooks);
let graph = parse(simple_linear_dot()).unwrap();
let config = make_run_config(dir.path());
let outcome = engine.run(&graph, &config).await.unwrap();
assert_eq!(outcome.status, StageStatus::Success);
}
// --- Events emitted correctly alongside hooks ---
#[tokio::test]
async fn hooks_do_not_duplicate_workflow_events() {
let hooks = vec![
make_hook(fabro_workflows::hook::HookEvent::RunStart, "exit 0"),
make_hook(fabro_workflows::hook::HookEvent::StageStart, "exit 0"),
make_hook(fabro_workflows::hook::HookEvent::StageComplete, "exit 0"),
make_hook(fabro_workflows::hook::HookEvent::RunComplete, "exit 0"),
];
let (engine, events) = engine_with_hooks_and_events(hooks);
let graph = parse(simple_linear_dot()).unwrap();
let dir = tempfile::tempdir().unwrap();
let config = make_run_config(dir.path());
engine.run(&graph, &config).await.unwrap();
let captured = events.lock().unwrap();
// Count WorkflowRunStarted — should be exactly 1
let run_started = captured
.iter()
.filter(|e| matches!(e, WorkflowRunEvent::WorkflowRunStarted { .. }))
.count();
assert_eq!(run_started, 1, "Should have exactly 1 WorkflowRunStarted");
// Count WorkflowRunCompleted — should be exactly 1
let run_completed = captured
.iter()
.filter(|e| matches!(e, WorkflowRunEvent::WorkflowRunCompleted { .. }))
.count();
assert_eq!(
run_completed, 1,
"Should have exactly 1 WorkflowRunCompleted"
);
// No WorkflowRunFailed
let run_failed = captured
.iter()
.filter(|e| matches!(e, WorkflowRunEvent::WorkflowRunFailed { .. }))
.count();
assert_eq!(run_failed, 0, "Should have 0 WorkflowRunFailed");
}
// ---------------------------------------------------------------------------
// E2E test with real LLM
// ---------------------------------------------------------------------------
#[tokio::test]
#[ignore = "requires ANTHROPIC_API_KEY"]
async fn arc_e2e_with_real_llm() {
dotenvy::dotenv().ok();
let dir = tempfile::tempdir().unwrap();
let dir_path = dir.path().to_str().unwrap().to_string();
let dot = format!(
r#"digraph E2E {{
graph [goal="Create a test file"]
start [shape=Mdiamond]
exit [shape=Msquare]
work [
shape=box,
label="Work",
prompt="Create a file called hello.txt in {dir_path} containing exactly 'Hello from LLM'. Do not output anything else.",
goal_gate=true
]
start -> work -> exit
}}"#
);
let graph = parse(&dot).expect("parse should succeed");
validate_or_raise(&graph, &[]).expect("validation should pass");
let interviewer: Arc<dyn Interviewer> = Arc::new(AutoApproveInterviewer);
let model = "claude-haiku-4-5".to_string();
let registry = default_registry(interviewer, move || {
Some(Box::new(AgentApiBackend::new(
model.clone(),
Provider::Anthropic,
Vec::new(),
))
as Box<dyn fabro_workflows::handler::agent::CodergenBackend>)
});
let run_dir = tempfile::tempdir().unwrap();
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: run_dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let outcome = engine
.run(&graph, &config)
.await
.expect("run should succeed");
// 1. Pipeline completed successfully
assert_eq!(outcome.status, StageStatus::Success);
// 2. Artifacts exist
let work_dir = run_dir.path().join("nodes").join("work");
assert!(
work_dir.join("prompt.md").exists(),
"prompt.md should exist"
);
assert!(
work_dir.join("response.md").exists(),
"response.md should exist"
);
assert!(
work_dir.join("status.json").exists(),
"status.json should exist"
);
// 3. Goal gate: check checkpoint node outcomes
let checkpoint =
Checkpoint::load(&run_dir.path().join("checkpoint.json")).expect("checkpoint should load");
let work_outcome = checkpoint
.node_outcomes
.get("work")
.expect("work outcome should exist");
assert!(
work_outcome.status == StageStatus::Success
|| work_outcome.status == StageStatus::PartialSuccess,
"work goal gate should be Success or PartialSuccess, got {:?}",
work_outcome.status
);
// 4. Checkpoint: completed_nodes contains "work"
assert!(
checkpoint.completed_nodes.contains(&"work".to_string()),
"completed_nodes should contain 'work'"
);
}
// ---------------------------------------------------------------------------
// Fidelity preamble injection: verify prompt.md contains preamble + prompt
// for each fidelity mode, using script → codergen pipeline with no live LLM.
// ---------------------------------------------------------------------------
/// Build a `start -> run_tests (script) -> report (codergen) -> exit` pipeline
/// with the given fidelity and goal, then return the contents of `report/prompt.md`.
async fn run_fidelity_prompt_pipeline(fidelity: &str) -> String {
let mut graph = Graph::new("FidelityPromptTest");
graph.attrs.insert(
"goal".to_string(),
AttrValue::String("Validate the build".to_string()),
);
graph.attrs.insert(
"default_fidelity".to_string(),
AttrValue::String(fidelity.to_string()),
);
let mut start = Node::new("start");
start.attrs.insert(
"shape".to_string(),
AttrValue::String("Mdiamond".to_string()),
);
graph.nodes.insert("start".to_string(), start);
let mut exit = Node::new("exit");
exit.attrs.insert(
"shape".to_string(),
AttrValue::String("Msquare".to_string()),
);
graph.nodes.insert("exit".to_string(), exit);
// Script node that produces test output via stdout
let mut run_tests = Node::new("run_tests");
run_tests.attrs.insert(
"shape".to_string(),
AttrValue::String("parallelogram".to_string()),
);
run_tests.attrs.insert(
"script".to_string(),
AttrValue::String("echo '10 passed, 0 failed'".to_string()),
);
graph.nodes.insert("run_tests".to_string(), run_tests);
// Codergen node that should receive the preamble
let mut report = Node::new("report");
report
.attrs
.insert("shape".to_string(), AttrValue::String("box".to_string()));
report.attrs.insert(
"prompt".to_string(),
AttrValue::String("Summarize the test results".to_string()),
);
graph.nodes.insert("report".to_string(), report);
graph.edges.push(Edge::new("start", "run_tests"));
graph.edges.push(Edge::new("run_tests", "report"));
graph.edges.push(Edge::new("report", "exit"));
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("command", Box::new(CommandHandler));
registry.register(
"agent",
Box::new(AgentHandler::new(Some(Box::new(MockCodergenBackend)))),
);
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
engine
.run(&graph, &config)
.await
.expect("pipeline should succeed");
std::fs::read_to_string(dir.path().join("nodes").join("report").join("prompt.md"))
.expect("report/prompt.md should exist")
}
#[tokio::test]
async fn fidelity_prompt_compact() {
let prompt = run_fidelity_prompt_pipeline("compact").await;
// Preamble should contain goal, completed stages with handler details, and context
assert!(
prompt.contains("Validate the build"),
"compact: should contain goal"
);
assert!(
prompt.contains("## Completed stages"),
"compact: should list completed stages"
);
assert!(
prompt.contains("**run_tests**"),
"compact: should mention run_tests node in bold"
);
assert!(
prompt.contains("Script:"),
"compact: should show script sub-item for run_tests"
);
assert!(
prompt.contains("Stdout:"),
"compact: should show stdout sub-item for run_tests"
);
// Original prompt at the end
assert!(
prompt.ends_with("Summarize the test results"),
"compact: should end with original prompt, got:\n{prompt}"
);
}
#[tokio::test]
async fn fidelity_prompt_truncate() {
let prompt = run_fidelity_prompt_pipeline("truncate").await;
// Truncate is minimal: goal + run ID only, no completed stages
assert!(
prompt.contains("Validate the build"),
"truncate: should contain goal"
);
assert!(
!prompt.contains("Completed stages:"),
"truncate: should NOT list completed stages"
);
// Original prompt at the end
assert!(
prompt.ends_with("Summarize the test results"),
"truncate: should end with original prompt, got:\n{prompt}"
);
}
#[tokio::test]
async fn fidelity_prompt_summary_low() {
let prompt = run_fidelity_prompt_pipeline("summary:low").await;
// summary:low includes goal, stage count, recent stages, but NOT context values
assert!(
prompt.contains("Validate the build"),
"summary:low: should contain goal"
);
assert!(
!prompt.contains("Context values:"),
"summary:low: should NOT include context values"
);
// Original prompt at the end
assert!(
prompt.ends_with("Summarize the test results"),
"summary:low: should end with original prompt, got:\n{prompt}"
);
}
#[tokio::test]
async fn fidelity_prompt_summary_medium() {
let prompt = run_fidelity_prompt_pipeline("summary:medium").await;
// summary:medium includes goal, stages, and compact handler details
assert!(
prompt.contains("Validate the build"),
"summary:medium: should contain goal"
);
assert!(
prompt.contains("run_tests"),
"summary:medium: should mention run_tests"
);
assert!(
prompt.contains("Script:"),
"summary:medium: should show script sub-item for run_tests"
);
assert!(
prompt.contains("Stdout:"),
"summary:medium: should show stdout sub-item for run_tests"
);
// Original prompt at the end
assert!(
prompt.ends_with("Summarize the test results"),
"summary:medium: should end with original prompt, got:\n{prompt}"
);
}
#[tokio::test]
async fn fidelity_prompt_summary_high() {
let prompt = run_fidelity_prompt_pipeline("summary:high").await;
// summary:high includes goal, all stages as ## Stage headings
assert!(
prompt.contains("Validate the build"),
"summary:high: should contain goal"
);
assert!(
prompt.contains("## Stage: run_tests"),
"summary:high: should have stage heading for run_tests"
);
assert!(
!prompt.contains("## Stage: start"),
"summary:high: should not have stage heading for meta start node"
);
assert!(
prompt.contains("Pipeline progress:"),
"summary:high: should show pipeline progress"
);
// Original prompt at the end
assert!(
prompt.ends_with("Summarize the test results"),
"summary:high: should end with original prompt, got:\n{prompt}"
);
}
#[tokio::test]
async fn fidelity_prompt_full_has_no_preamble() {
let prompt = run_fidelity_prompt_pipeline("full").await;
// Full fidelity produces empty preamble — prompt is just the original
assert_eq!(
prompt, "Summarize the test results",
"full: should be bare prompt with no preamble, got:\n{prompt}"
);
}
// ---------------------------------------------------------------------------
// Artifact offloading integration test
// ---------------------------------------------------------------------------
#[tokio::test]
async fn large_context_values_are_offloaded_to_artifact_store() {
// Pipeline: start -> big_output -> exit
// big_output uses LargeOutputHandler which returns a >100KB context_update.
let mut graph = make_graph_with_start_exit("ArtifactOffload");
graph.attrs.insert(
"goal".to_string(),
AttrValue::String("Test artifact offloading".to_string()),
);
let mut big_output = Node::new("big_output");
big_output.attrs.insert(
"label".to_string(),
AttrValue::String("Big Output".to_string()),
);
graph.nodes.insert("big_output".to_string(), big_output);
graph.edges.push(Edge::new("start", "big_output"));
graph.edges.push(Edge::new("big_output", "exit"));
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(LargeOutputHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
let mut emitter = EventEmitter::new();
let events = collect_events(&mut emitter);
let engine = WorkflowRunEngine::new(registry, Arc::new(emitter), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let outcome = engine
.run(&graph, &config)
.await
.expect("pipeline should succeed");
assert_eq!(outcome.status, StageStatus::Success);
// The checkpoint context should contain an artifact pointer, not the full value
let checkpoint =
fabro_workflows::checkpoint::Checkpoint::load(&dir.path().join("checkpoint.json"))
.expect("checkpoint should load");
let pointer_value = checkpoint
.context_values
.get("response.big_output")
.expect("context should have response.big_output");
let pointer_str = pointer_value.as_str().expect("pointer should be a string");
assert!(
pointer_str.starts_with("file://"),
"value should be an artifact pointer, got: {pointer_str}"
);
// The artifact file should exist on disk
let artifact_file = dir
.path()
.join("artifacts")
.join("values")
.join("response.big_output.json");
assert!(
artifact_file.exists(),
"artifact file should exist at {artifact_file:?}"
);
// The artifact file should contain the original large value
let artifact_content =
std::fs::read_to_string(&artifact_file).expect("should read artifact file");
let artifact_value: serde_json::Value =
serde_json::from_str(&artifact_content).expect("should parse artifact JSON");
let artifact_str = artifact_value.as_str().expect("should be a string");
assert_eq!(
artifact_str.len(),
150 * 1024,
"artifact should contain the original 150KB value"
);
// WorkflowRunCompleted event should report artifact_count > 0
let evts = events.lock().unwrap();
let completed_event = evts
.iter()
.find(|e| matches!(e, WorkflowRunEvent::WorkflowRunCompleted { .. }))
.expect("should have WorkflowRunCompleted event");
if let WorkflowRunEvent::WorkflowRunCompleted { artifact_count, .. } = completed_event {
assert!(
*artifact_count > 0,
"artifact_count should be > 0, got {artifact_count}"
);
}
}
// ---------------------------------------------------------------------------
// Artifact sync to remote sandboxs
// ---------------------------------------------------------------------------
/// A mock sandbox where `file_exists` always returns false,
/// simulating a remote container that doesn't have local artifact files.
struct RemoteMockEnv {
working_dir: String,
written: std::sync::Mutex<Vec<(String, String)>>,
}
impl RemoteMockEnv {
fn new(working_dir: &str) -> Self {
Self {
working_dir: working_dir.to_string(),
written: std::sync::Mutex::new(Vec::new()),
}
}
}
#[async_trait::async_trait]
impl fabro_agent::Sandbox for RemoteMockEnv {
async fn read_file(
&self,
_path: &str,
_offset: Option<usize>,
_limit: Option<usize>,
) -> std::result::Result<String, String> {
Err("not implemented".to_string())
}
async fn write_file(&self, path: &str, content: &str) -> std::result::Result<(), String> {
self.written
.lock()
.unwrap()
.push((path.to_string(), content.to_string()));
Ok(())
}
async fn delete_file(&self, _path: &str) -> std::result::Result<(), String> {
Err("not implemented".to_string())
}
async fn file_exists(&self, _path: &str) -> std::result::Result<bool, String> {
Ok(false)
}
async fn list_directory(
&self,
_path: &str,
_depth: Option<usize>,
) -> std::result::Result<Vec<fabro_agent::DirEntry>, String> {
Err("not implemented".to_string())
}
async fn exec_command(
&self,
_command: &str,
_timeout_ms: u64,
_working_dir: Option<&str>,
_env_vars: Option<&std::collections::HashMap<String, String>>,
_cancel_token: Option<tokio_util::sync::CancellationToken>,
) -> std::result::Result<fabro_agent::ExecResult, String> {
Err("not implemented".to_string())
}
async fn grep(
&self,
_pattern: &str,
_path: &str,
_options: &fabro_agent::GrepOptions,
) -> std::result::Result<Vec<String>, String> {
Err("not implemented".to_string())
}
async fn glob(
&self,
_pattern: &str,
_path: Option<&str>,
) -> std::result::Result<Vec<String>, String> {
Err("not implemented".to_string())
}
async fn initialize(&self) -> std::result::Result<(), String> {
Ok(())
}
async fn cleanup(&self) -> std::result::Result<(), String> {
Ok(())
}
async fn download_file_to_local(
&self,
_: &str,
_: &std::path::Path,
) -> std::result::Result<(), String> {
Err("not implemented".to_string())
}
async fn upload_file_from_local(
&self,
_: &std::path::Path,
_: &str,
) -> std::result::Result<(), String> {
Err("not implemented".to_string())
}
fn working_directory(&self) -> &str {
&self.working_dir
}
fn platform(&self) -> &str {
"linux"
}
fn os_version(&self) -> String {
"Linux 5.15".to_string()
}
}
#[tokio::test]
async fn artifact_pointers_rewritten_for_remote_sandbox() {
// Pipeline: start -> big_output -> exit
// big_output uses LargeOutputHandler which returns a >100KB context_update.
// RemoteMockEnv simulates a container where local files don't exist.
let mut graph = make_graph_with_start_exit("ArtifactSync");
graph.attrs.insert(
"goal".to_string(),
AttrValue::String("Test artifact sync to remote env".to_string()),
);
let mut big_output = Node::new("big_output");
big_output.attrs.insert(
"label".to_string(),
AttrValue::String("Big Output".to_string()),
);
graph.nodes.insert("big_output".to_string(), big_output);
graph.edges.push(Edge::new("start", "big_output"));
graph.edges.push(Edge::new("big_output", "exit"));
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(LargeOutputHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
let remote_env = Arc::new(RemoteMockEnv::new("/sandbox"));
let engine =
WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), remote_env.clone());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let outcome = engine
.run(&graph, &config)
.await
.expect("pipeline should succeed");
assert_eq!(outcome.status, StageStatus::Success);
// The checkpoint context should contain a pointer rewritten for the remote env
let checkpoint =
Checkpoint::load(&dir.path().join("checkpoint.json")).expect("checkpoint should load");
let pointer_value = checkpoint
.context_values
.get("response.big_output")
.expect("context should have response.big_output");
let pointer_str = pointer_value.as_str().expect("pointer should be a string");
assert!(
pointer_str.starts_with("file:///sandbox/.fabro/artifacts/"),
"pointer should reference remote path, got: {pointer_str}"
);
// The RemoteMockEnv should have received exactly one write with >100KB content
let written = remote_env.written.lock().unwrap();
assert_eq!(written.len(), 1, "should have written 1 artifact");
assert!(
written[0].1.len() > 100 * 1024,
"written content should be >100KB, got {} bytes",
written[0].1.len()
);
}
// ---------------------------------------------------------------------------
// Node directory visit-count naming
// ---------------------------------------------------------------------------
/// Verify that revisited nodes get distinct stage directories:
/// visit 1 → `nodes/{id}/`
/// visit 2 → `nodes/{id}-attempt_2/`
#[tokio::test]
async fn node_dir_uses_visit_count_on_revisit() {
// Handler that fails on first call, succeeds on second.
struct FailOnceHandler {
call_count: std::sync::atomic::AtomicU32,
}
#[async_trait::async_trait]
impl Handler for FailOnceHandler {
async fn execute(
&self,
_node: &Node,
_context: &fabro_workflows::context::Context,
_graph: &Graph,
_run_dir: &Path,
_services: &fabro_workflows::handler::EngineServices,
) -> Result<Outcome, fabro_workflows::error::FabroError> {
let n = self
.call_count
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
if n == 0 {
Ok(Outcome::fail_classify("first attempt fails"))
} else {
Ok(Outcome::success())
}
}
}
// Graph: start -> gated_work -> exit
// gated_work has goal_gate=true, retry_target=start
// First visit fails → goal gate unsatisfied → retries from start
// Second visit succeeds → pipeline completes
let mut graph = Graph::new("VisitCountTest");
let mut start = Node::new("start");
start.attrs.insert(
"shape".to_string(),
AttrValue::String("Mdiamond".to_string()),
);
graph.nodes.insert("start".to_string(), start);
let mut exit = Node::new("exit");
exit.attrs.insert(
"shape".to_string(),
AttrValue::String("Msquare".to_string()),
);
graph.nodes.insert("exit".to_string(), exit);
let mut gated_work = Node::new("gated_work");
gated_work
.attrs
.insert("goal_gate".to_string(), AttrValue::Boolean(true));
gated_work
.attrs
.insert("max_retries".to_string(), AttrValue::Integer(0));
gated_work.attrs.insert(
"retry_target".to_string(),
AttrValue::String("start".to_string()),
);
gated_work.attrs.insert(
"type".to_string(),
AttrValue::String("fail_once".to_string()),
);
graph.nodes.insert("gated_work".to_string(), gated_work);
graph.edges.push(Edge::new("start", "gated_work"));
graph.edges.push(Edge::new("gated_work", "exit"));
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register(
"fail_once",
Box::new(FailOnceHandler {
call_count: std::sync::atomic::AtomicU32::new(0),
}),
);
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let outcome = engine
.run(&graph, &config)
.await
.expect("pipeline should succeed");
assert_eq!(outcome.status, StageStatus::Success);
// First visit: nodes/gated_work/status.json
let first = dir
.path()
.join("nodes")
.join("gated_work")
.join("status.json");
assert!(
first.exists(),
"first visit directory should exist at {}",
first.display()
);
// Second visit: nodes/gated_work-visit_2/status.json
let second = dir
.path()
.join("nodes")
.join("gated_work-visit_2")
.join("status.json");
assert!(
second.exists(),
"second visit directory should exist at {}",
second.display()
);
// Verify distinct content (first = fail, second = success)
let first_json: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&first).unwrap()).unwrap();
let second_json: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&second).unwrap()).unwrap();
assert_eq!(first_json["status"], "fail");
assert_eq!(second_json["status"], "success");
}
// ---------------------------------------------------------------------------
// CLI Backend end-to-end tests
// ---------------------------------------------------------------------------
use fabro_workflows::cli::cli_backend::{AgentCliBackend, BackendRouter};
/// A mock sandbox for CLI backend e2e tests.
/// Records all exec_command and write_file calls, and returns configurable
/// responses based on command content.
struct CliTestEnv {
/// All commands passed to exec_command, in order.
commands: std::sync::Mutex<Vec<String>>,
/// All (path, content) pairs from write_file.
written_files: std::sync::Mutex<Vec<(String, String)>>,
/// The stdout to return when the CLI command (not git) is executed.
cli_stdout: String,
/// Files returned by "git diff --name-only" AFTER the CLI runs.
/// First call returns empty (before), second returns these (after).
git_diff_call_count: std::sync::atomic::AtomicU32,
git_diff_after: String,
}
impl CliTestEnv {
fn new(cli_stdout: &str) -> Self {
Self {
commands: std::sync::Mutex::new(Vec::new()),
written_files: std::sync::Mutex::new(Vec::new()),
cli_stdout: cli_stdout.to_string(),
git_diff_call_count: std::sync::atomic::AtomicU32::new(0),
git_diff_after: String::new(),
}
}
fn with_git_diff_after(mut self, files: &str) -> Self {
self.git_diff_after = files.to_string();
self
}
fn recorded_commands(&self) -> Vec<String> {
self.commands.lock().unwrap().clone()
}
fn recorded_written_files(&self) -> Vec<(String, String)> {
self.written_files.lock().unwrap().clone()
}
}
#[async_trait::async_trait]
impl fabro_agent::Sandbox for CliTestEnv {
async fn read_file(
&self,
_path: &str,
_offset: Option<usize>,
_limit: Option<usize>,
) -> Result<String, String> {
Ok(String::new())
}
async fn write_file(&self, path: &str, content: &str) -> Result<(), String> {
self.written_files
.lock()
.unwrap()
.push((path.to_string(), content.to_string()));
Ok(())
}
async fn delete_file(&self, _path: &str) -> Result<(), String> {
Ok(())
}
async fn file_exists(&self, _path: &str) -> Result<bool, String> {
Ok(false)
}
async fn list_directory(
&self,
_path: &str,
_depth: Option<usize>,
) -> Result<Vec<fabro_agent::DirEntry>, String> {
Ok(vec![])
}
async fn exec_command(
&self,
command: &str,
_timeout_ms: u64,
_working_dir: Option<&str>,
_env_vars: Option<&std::collections::HashMap<String, String>>,
_cancel_token: Option<tokio_util::sync::CancellationToken>,
) -> Result<fabro_agent::ExecResult, String> {
self.commands.lock().unwrap().push(command.to_string());
// git diff calls: first pair returns empty (before), second pair returns configured files
if command.starts_with("git diff") || command.starts_with("git ls-files") {
let call_num = self
.git_diff_call_count
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
// Calls 0,1 = before snapshot (empty), calls 2,3 = after snapshot
let stdout = if call_num >= 2 && command.starts_with("git diff") {
self.git_diff_after.clone()
} else {
String::new()
};
return Ok(fabro_agent::ExecResult {
stdout,
stderr: String::new(),
exit_code: 0,
timed_out: false,
duration_ms: 5,
});
}
// Background launch: return PID
if command.contains("echo $!") {
return Ok(fabro_agent::ExecResult {
stdout: "12345\n".into(),
stderr: String::new(),
exit_code: 0,
timed_out: false,
duration_ms: 1,
});
}
// Poll for completion: return exit code 0 immediately
if command.contains("exit_code") && command.contains("echo running") {
return Ok(fabro_agent::ExecResult {
stdout: "0\n".into(),
stderr: String::new(),
exit_code: 0,
timed_out: false,
duration_ms: 1,
});
}
// Read stdout file
if command.starts_with("cat") && command.contains("stdout.log") {
return Ok(fabro_agent::ExecResult {
stdout: self.cli_stdout.clone(),
stderr: String::new(),
exit_code: 0,
timed_out: false,
duration_ms: 1,
});
}
// Read stderr file
if command.starts_with("cat") && command.contains("stderr.log") {
return Ok(fabro_agent::ExecResult {
stdout: String::new(),
stderr: String::new(),
exit_code: 0,
timed_out: false,
duration_ms: 1,
});
}
// Cleanup temp files
if command.starts_with("rm -f") {
return Ok(fabro_agent::ExecResult {
stdout: String::new(),
stderr: String::new(),
exit_code: 0,
timed_out: false,
duration_ms: 1,
});
}
// Fallback
Ok(fabro_agent::ExecResult {
stdout: self.cli_stdout.clone(),
stderr: String::new(),
exit_code: 0,
timed_out: false,
duration_ms: 100,
})
}
async fn grep(
&self,
_pattern: &str,
_path: &str,
_options: &fabro_agent::GrepOptions,
) -> Result<Vec<String>, String> {
Ok(vec![])
}
async fn glob(&self, _pattern: &str, _path: Option<&str>) -> Result<Vec<String>, String> {
Ok(vec![])
}
async fn initialize(&self) -> Result<(), String> {
Ok(())
}
async fn cleanup(&self) -> Result<(), String> {
Ok(())
}
async fn download_file_to_local(&self, _: &str, _: &std::path::Path) -> Result<(), String> {
Err("not implemented".to_string())
}
async fn upload_file_from_local(&self, _: &std::path::Path, _: &str) -> Result<(), String> {
Err("not implemented".to_string())
}
fn working_directory(&self) -> &str {
"/tmp/test"
}
fn platform(&self) -> &str {
"darwin"
}
fn os_version(&self) -> String {
"Darwin 24.0.0".into()
}
}
// -- Cycle 8: AgentCliBackend::run() e2e via mock Sandbox --
#[tokio::test]
async fn cli_backend_run_writes_prompt_and_calls_exec() {
let claude_output = r#"{"type":"result","result":"I fixed the bug.","usage":{"input_tokens":500,"output_tokens":200}}"#;
let test_env = Arc::new(CliTestEnv::new(claude_output));
let env: Arc<dyn fabro_agent::Sandbox> = test_env.clone();
let backend = AgentCliBackend::new("claude-opus-4-6".into(), Provider::Anthropic)
.with_poll_interval(Duration::from_millis(10));
let node = Node::new("fix_code");
let context = Context::new();
let emitter = Arc::new(EventEmitter::new());
let dir = tempfile::tempdir().unwrap();
let result = backend
.run(
&node,
"Fix the authentication bug",
&context,
None,
&emitter,
dir.path(),
&env,
None,
)
.await
.expect("CLI backend should succeed");
// Verify prompt was written
let written = test_env.recorded_written_files();
let prompt_file = written
.iter()
.find(|(path, _)| path.contains("_prompt.txt"))
.expect("should write a prompt file");
assert!(
prompt_file.0.starts_with("/tmp/fabro_cli_") && prompt_file.0.ends_with("_prompt.txt"),
"prompt path should use UUID prefix: {}",
prompt_file.0
);
assert_eq!(prompt_file.1, "Fix the authentication bug");
// Verify the CLI command was called (now wrapped in background launch)
let commands = test_env.recorded_commands();
let cli_cmd = commands
.iter()
.find(|c| c.contains("claude") && c.contains("echo $!"))
.expect("should launch claude CLI in background");
assert!(cli_cmd.contains("-p"), "should use pipe mode");
assert!(
cli_cmd.contains("claude-opus-4-6"),
"should use correct model"
);
assert!(
cli_cmd.contains("_prompt.txt"),
"should reference prompt file"
);
// Verify parsed response
match result {
CodergenResult::Text {
text,
usage,
files_touched,
..
} => {
assert_eq!(text, "I fixed the bug.");
let usage = usage.expect("should have usage");
assert_eq!(usage.input_tokens, 500);
assert_eq!(usage.output_tokens, 200);
assert!(files_touched.is_empty(), "no files changed before/after");
}
CodergenResult::Full(_) => panic!("expected Text result, got Full"),
}
}
#[tokio::test]
async fn cli_backend_run_detects_changed_files() {
let claude_output = r#"{"type":"result","result":"Created new file.","usage":{"input_tokens":100,"output_tokens":50}}"#;
let env: Arc<dyn fabro_agent::Sandbox> =
Arc::new(CliTestEnv::new(claude_output).with_git_diff_after("src/main.rs\nsrc/lib.rs\n"));
let backend = AgentCliBackend::new("claude-opus-4-6".into(), Provider::Anthropic)
.with_poll_interval(Duration::from_millis(10));
let node = Node::new("implement");
let context = Context::new();
let emitter = Arc::new(EventEmitter::new());
let dir = tempfile::tempdir().unwrap();
let result = backend
.run(
&node,
"Add a new feature",
&context,
None,
&emitter,
dir.path(),
&env,
None,
)
.await
.expect("CLI backend should succeed");
match result {
CodergenResult::Text { files_touched, .. } => {
assert_eq!(files_touched, vec!["src/lib.rs", "src/main.rs"]);
}
CodergenResult::Full(_) => panic!("expected Text result"),
}
}
#[tokio::test]
async fn cli_backend_run_with_codex_provider() {
let codex_output = "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_0\",\"type\":\"agent_message\",\"text\":\"Implemented the feature.\"}}\n{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":300,\"output_tokens\":150}}";
let test_env = Arc::new(CliTestEnv::new(codex_output));
let env: Arc<dyn fabro_agent::Sandbox> = test_env.clone();
let backend = AgentCliBackend::new("gpt-5.3-codex".into(), Provider::OpenAi)
.with_poll_interval(Duration::from_millis(10));
let node = Node::new("implement");
let context = Context::new();
let emitter = Arc::new(EventEmitter::new());
let dir = tempfile::tempdir().unwrap();
let result = backend
.run(
&node,
"Build the API",
&context,
None,
&emitter,
dir.path(),
&env,
None,
)
.await
.expect("CLI backend should succeed");
// Verify codex command was called (now wrapped in background launch)
let commands = test_env.recorded_commands();
let cli_cmd = commands
.iter()
.find(|c| c.contains("codex") && c.contains("echo $!"))
.expect("should launch codex CLI in background");
assert!(cli_cmd.contains("exec --json"), "should use exec mode");
assert!(
cli_cmd.contains("gpt-5.3-codex"),
"should use correct model"
);
match result {
CodergenResult::Text { text, usage, .. } => {
assert_eq!(text, "Implemented the feature.");
let usage = usage.expect("should have usage");
assert_eq!(usage.input_tokens, 300);
assert_eq!(usage.output_tokens, 150);
}
CodergenResult::Full(_) => panic!("expected Text result"),
}
}
#[tokio::test]
async fn cli_backend_run_fails_on_nonzero_exit() {
let env = Arc::new(CliTestEnv::new(""));
// Override exec_command to return non-zero for the CLI call
struct FailingCliEnv;
#[async_trait::async_trait]
impl fabro_agent::Sandbox for FailingCliEnv {
async fn read_file(
&self,
_: &str,
_: Option<usize>,
_: Option<usize>,
) -> Result<String, String> {
Ok(String::new())
}
async fn write_file(&self, _: &str, _: &str) -> Result<(), String> {
Ok(())
}
async fn delete_file(&self, _: &str) -> Result<(), String> {
Ok(())
}
async fn file_exists(&self, _: &str) -> Result<bool, String> {
Ok(false)
}
async fn list_directory(
&self,
_: &str,
_: Option<usize>,
) -> Result<Vec<fabro_agent::DirEntry>, String> {
Ok(vec![])
}
async fn exec_command(
&self,
command: &str,
_: u64,
_: Option<&str>,
_: Option<&std::collections::HashMap<String, String>>,
_: Option<tokio_util::sync::CancellationToken>,
) -> Result<fabro_agent::ExecResult, String> {
if command.starts_with("git") {
return Ok(fabro_agent::ExecResult {
stdout: String::new(),
stderr: String::new(),
exit_code: 0,
timed_out: false,
duration_ms: 0,
});
}
// Background launch: return PID
if command.contains("echo $!") {
return Ok(fabro_agent::ExecResult {
stdout: "12345\n".into(),
stderr: String::new(),
exit_code: 0,
timed_out: false,
duration_ms: 0,
});
}
// Poll: return non-zero exit code
if command.contains("exit_code") && command.contains("echo running") {
return Ok(fabro_agent::ExecResult {
stdout: "127\n".into(),
stderr: String::new(),
exit_code: 0,
timed_out: false,
duration_ms: 0,
});
}
// Read stderr file
if command.starts_with("cat") && command.contains("stderr.log") {
return Ok(fabro_agent::ExecResult {
stdout: "command not found: claude".into(),
stderr: String::new(),
exit_code: 0,
timed_out: false,
duration_ms: 0,
});
}
Ok(fabro_agent::ExecResult {
stdout: String::new(),
stderr: String::new(),
exit_code: 0,
timed_out: false,
duration_ms: 0,
})
}
async fn grep(
&self,
_: &str,
_: &str,
_: &fabro_agent::GrepOptions,
) -> Result<Vec<String>, String> {
Ok(vec![])
}
async fn glob(&self, _: &str, _: Option<&str>) -> Result<Vec<String>, String> {
Ok(vec![])
}
async fn initialize(&self) -> Result<(), String> {
Ok(())
}
async fn cleanup(&self) -> Result<(), String> {
Ok(())
}
async fn download_file_to_local(&self, _: &str, _: &std::path::Path) -> Result<(), String> {
Err("not implemented".to_string())
}
async fn upload_file_from_local(&self, _: &std::path::Path, _: &str) -> Result<(), String> {
Err("not implemented".to_string())
}
fn working_directory(&self) -> &str {
"/tmp"
}
fn platform(&self) -> &str {
"darwin"
}
fn os_version(&self) -> String {
"Darwin 24.0.0".into()
}
}
let failing_env: Arc<dyn fabro_agent::Sandbox> = Arc::new(FailingCliEnv);
let backend = AgentCliBackend::new("claude-opus-4-6".into(), Provider::Anthropic)
.with_poll_interval(Duration::from_millis(10));
let node = Node::new("step");
let context = Context::new();
let emitter = Arc::new(EventEmitter::new());
let dir = tempfile::tempdir().unwrap();
let _ = env; // unused, just for the above struct
let result = backend
.run(
&node,
"do something",
&context,
None,
&emitter,
dir.path(),
&failing_env,
None,
)
.await;
let err = match result {
Err(e) => e,
Ok(_) => panic!("should fail on non-zero exit"),
};
assert!(
err.to_string().contains("exited with code 127"),
"error: {err}"
);
assert!(
err.to_string().contains("command not found"),
"error: {err}"
);
}
#[tokio::test]
async fn cli_backend_run_fails_on_unparseable_output() {
let env: Arc<dyn fabro_agent::Sandbox> = Arc::new(CliTestEnv::new("this is not json at all"));
let backend = AgentCliBackend::new("claude-opus-4-6".into(), Provider::Anthropic)
.with_poll_interval(Duration::from_millis(10));
let node = Node::new("step");
let context = Context::new();
let emitter = Arc::new(EventEmitter::new());
let dir = tempfile::tempdir().unwrap();
let result = backend
.run(
&node,
"do something",
&context,
None,
&emitter,
dir.path(),
&env,
None,
)
.await;
let err = match result {
Err(e) => e,
Ok(_) => panic!("should fail on unparseable output"),
};
assert!(
err.to_string().contains("Failed to parse CLI output"),
"error: {err}"
);
}
#[tokio::test]
async fn cli_backend_run_uses_node_model_override() {
let claude_output =
r#"{"type":"result","result":"ok","usage":{"input_tokens":10,"output_tokens":5}}"#;
let test_env = Arc::new(CliTestEnv::new(claude_output));
let env: Arc<dyn fabro_agent::Sandbox> = test_env.clone();
let backend = AgentCliBackend::new("default-model".into(), Provider::Anthropic)
.with_poll_interval(Duration::from_millis(10));
let mut node = Node::new("step");
node.attrs.insert(
"model".to_string(),
AttrValue::String("claude-sonnet-4-5".to_string()),
);
let context = Context::new();
let emitter = Arc::new(EventEmitter::new());
let dir = tempfile::tempdir().unwrap();
backend
.run(
&node,
"test",
&context,
None,
&emitter,
dir.path(),
&env,
None,
)
.await
.expect("should succeed");
let commands = test_env.recorded_commands();
let cli_cmd = commands
.iter()
.find(|c| c.contains("claude") && c.contains("echo $!"))
.unwrap();
assert!(
cli_cmd.contains("claude-sonnet-4-5"),
"should use node's model override, not default: {cli_cmd}"
);
assert!(
!cli_cmd.contains("default-model"),
"should NOT use default model: {cli_cmd}"
);
}
#[tokio::test]
async fn cli_backend_run_uses_node_provider_override() {
let codex_output = "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_0\",\"type\":\"agent_message\",\"text\":\"ok\"}}\n{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}";
let test_env = Arc::new(CliTestEnv::new(codex_output));
let env: Arc<dyn fabro_agent::Sandbox> = test_env.clone();
let backend = AgentCliBackend::new("default-model".into(), Provider::Anthropic)
.with_poll_interval(Duration::from_millis(10));
let mut node = Node::new("step");
node.attrs.insert(
"provider".to_string(),
AttrValue::String("openai".to_string()),
);
node.attrs.insert(
"model".to_string(),
AttrValue::String("gpt-5.3-codex".to_string()),
);
let context = Context::new();
let emitter = Arc::new(EventEmitter::new());
let dir = tempfile::tempdir().unwrap();
backend
.run(
&node,
"test",
&context,
None,
&emitter,
dir.path(),
&env,
None,
)
.await
.expect("should succeed");
let commands = test_env.recorded_commands();
let cli_cmd = commands
.iter()
.find(|c| c.contains("codex") && c.contains("echo $!"))
.expect("should launch codex based on provider override");
assert!(cli_cmd.contains("gpt-5.3-codex"));
}
#[tokio::test]
async fn cli_backend_run_writes_provider_used_json() {
let claude_output =
r#"{"type":"result","result":"done","usage":{"input_tokens":10,"output_tokens":5}}"#;
let env: Arc<dyn fabro_agent::Sandbox> = Arc::new(CliTestEnv::new(claude_output));
let backend = AgentCliBackend::new("claude-opus-4-6".into(), Provider::Anthropic)
.with_poll_interval(Duration::from_millis(10));
let node = Node::new("step");
let context = Context::new();
let emitter = Arc::new(EventEmitter::new());
let dir = tempfile::tempdir().unwrap();
backend
.run(
&node,
"test",
&context,
None,
&emitter,
dir.path(),
&env,
None,
)
.await
.expect("should succeed");
let provider_path = dir.path().join("provider_used.json");
assert!(provider_path.exists(), "should write provider_used.json");
let provider_json: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&provider_path).unwrap()).unwrap();
assert_eq!(provider_json["mode"], "cli");
assert_eq!(provider_json["provider"], "anthropic");
assert_eq!(provider_json["model"], "claude-opus-4-6");
assert!(provider_json["command"]
.as_str()
.unwrap()
.contains("claude"));
}
// -- BackendRouter e2e: delegates to correct backend --
#[tokio::test]
async fn backend_router_delegates_to_cli_for_cli_node() {
let claude_output = r#"{"type":"result","result":"CLI response","usage":{"input_tokens":10,"output_tokens":5}}"#;
let env: Arc<dyn fabro_agent::Sandbox> = Arc::new(CliTestEnv::new(claude_output));
let api_backend = Box::new(MockCodergenBackend); // would return "Response for ..."
let cli = AgentCliBackend::new("claude-opus-4-6".into(), Provider::Anthropic)
.with_poll_interval(Duration::from_millis(10));
let router = BackendRouter::new(api_backend, cli);
let mut node = Node::new("cli_step");
node.attrs
.insert("backend".to_string(), AttrValue::String("cli".to_string()));
node.attrs.insert(
"prompt".to_string(),
AttrValue::String("Fix the bug".to_string()),
);
let context = Context::new();
let emitter = Arc::new(EventEmitter::new());
let dir = tempfile::tempdir().unwrap();
let result = router
.run(
&node,
"Fix the bug",
&context,
None,
&emitter,
dir.path(),
&env,
None,
)
.await
.expect("router should succeed");
match result {
CodergenResult::Text { text, .. } => {
assert_eq!(
text, "CLI response",
"should use CLI backend response, not mock API"
);
}
CodergenResult::Full(_) => panic!("expected Text result"),
}
}
#[tokio::test]
async fn backend_router_delegates_to_api_for_normal_node() {
let env = local_env();
let api_backend = Box::new(MockCodergenBackend);
let cli = AgentCliBackend::new("claude-opus-4-6".into(), Provider::Anthropic)
.with_poll_interval(Duration::from_millis(10));
let router = BackendRouter::new(api_backend, cli);
let mut node = Node::new("api_step");
node.attrs.insert(
"prompt".to_string(),
AttrValue::String("Plan the work".to_string()),
);
let context = Context::new();
let emitter = Arc::new(EventEmitter::new());
let dir = tempfile::tempdir().unwrap();
let result = router
.run(
&node,
"Plan the work",
&context,
None,
&emitter,
dir.path(),
&env,
None,
)
.await
.expect("router should succeed");
match result {
CodergenResult::Text { text, .. } => {
assert!(
text.starts_with("Response for api_step"),
"should use API mock response: {text}"
);
}
CodergenResult::Full(_) => panic!("expected Text result"),
}
}
#[tokio::test]
async fn backend_router_delegates_to_cli_for_backend_attr() {
let codex_output = "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_0\",\"type\":\"agent_message\",\"text\":\"Codex did it\"}}\n{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}";
let env: Arc<dyn fabro_agent::Sandbox> = Arc::new(CliTestEnv::new(codex_output));
let api_backend = Box::new(MockCodergenBackend);
let cli = AgentCliBackend::new("gpt-5.3-codex".into(), Provider::OpenAi)
.with_poll_interval(Duration::from_millis(10));
let router = BackendRouter::new(api_backend, cli);
let mut node = Node::new("codex_step");
node.attrs
.insert("backend".to_string(), AttrValue::String("cli".to_string()));
node.attrs.insert(
"provider".to_string(),
AttrValue::String("openai".to_string()),
);
let context = Context::new();
let emitter = Arc::new(EventEmitter::new());
let dir = tempfile::tempdir().unwrap();
let result = router
.run(
&node,
"Build it",
&context,
None,
&emitter,
dir.path(),
&env,
None,
)
.await
.expect("router should succeed");
match result {
CodergenResult::Text { text, .. } => {
assert_eq!(
text, "Codex did it",
"should route to CLI backend for backend=cli"
);
}
CodergenResult::Full(_) => panic!("expected Text result"),
}
}
// -- Full pipeline e2e with BackendRouter --
#[tokio::test]
async fn full_pipeline_with_cli_backend_node() {
// Pipeline: start -> api_work -> cli_work -> exit
// api_work uses MockCodergenBackend (API), cli_work has backend="cli"
let claude_output = r#"{"type":"result","result":"CLI completed the task.","usage":{"input_tokens":100,"output_tokens":50}}"#;
let env: Arc<dyn fabro_agent::Sandbox> = Arc::new(CliTestEnv::new(claude_output));
let mut graph = Graph::new("CliPipelineTest");
let mut start = Node::new("start");
start.attrs.insert(
"shape".to_string(),
AttrValue::String("Mdiamond".to_string()),
);
graph.nodes.insert("start".to_string(), start);
let mut exit = Node::new("exit");
exit.attrs.insert(
"shape".to_string(),
AttrValue::String("Msquare".to_string()),
);
graph.nodes.insert("exit".to_string(), exit);
let mut api_work = Node::new("api_work");
api_work
.attrs
.insert("shape".to_string(), AttrValue::String("box".to_string()));
api_work.attrs.insert(
"prompt".to_string(),
AttrValue::String("Plan the work".to_string()),
);
graph.nodes.insert("api_work".to_string(), api_work);
let mut cli_work = Node::new("cli_work");
cli_work
.attrs
.insert("shape".to_string(), AttrValue::String("box".to_string()));
cli_work.attrs.insert(
"prompt".to_string(),
AttrValue::String("Implement via CLI".to_string()),
);
cli_work
.attrs
.insert("backend".to_string(), AttrValue::String("cli".to_string()));
graph.nodes.insert("cli_work".to_string(), cli_work);
graph.edges.push(Edge::new("start", "api_work"));
graph.edges.push(Edge::new("api_work", "cli_work"));
graph.edges.push(Edge::new("cli_work", "exit"));
// Build engine with BackendRouter
let api = MockCodergenBackend;
let cli = AgentCliBackend::new("claude-opus-4-6".into(), Provider::Anthropic)
.with_poll_interval(Duration::from_millis(10));
let router = BackendRouter::new(Box::new(api), cli);
let codergen_handler = AgentHandler::new(Some(Box::new(router)));
let mut registry = HandlerRegistry::new(Box::new(codergen_handler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register(
"agent",
Box::new(AgentHandler::new(Some(Box::new({
// Second BackendRouter for the "agent" handler
let api2 = MockCodergenBackend;
let cli2 = AgentCliBackend::new("claude-opus-4-6".into(), Provider::Anthropic)
.with_poll_interval(Duration::from_millis(10));
BackendRouter::new(Box::new(api2), cli2)
})))),
);
let dir = tempfile::tempdir().unwrap();
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), env);
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let outcome = engine
.run(&graph, &config)
.await
.expect("pipeline should succeed");
assert_eq!(outcome.status, StageStatus::Success);
// Verify api_work used mock (its response.md should contain "Response for")
let api_response = std::fs::read_to_string(
dir.path()
.join("nodes")
.join("api_work")
.join("response.md"),
)
.unwrap();
assert!(
api_response.starts_with("Response for api_work"),
"API node should use mock: {api_response}"
);
// Verify cli_work used CLI backend (its response.md should contain CLI response)
let cli_response = std::fs::read_to_string(
dir.path()
.join("nodes")
.join("cli_work")
.join("response.md"),
)
.unwrap();
assert_eq!(
cli_response, "CLI completed the task.",
"CLI node should use CLI backend: {cli_response}"
);
// Verify cli_work wrote provider_used.json with mode=cli
let provider_json: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(
dir.path()
.join("nodes")
.join("cli_work")
.join("provider_used.json"),
)
.unwrap(),
)
.unwrap();
assert_eq!(provider_json["mode"], "cli");
}
// -- Stylesheet applies backend property to nodes in a full pipeline --
#[tokio::test]
async fn stylesheet_backend_property_routes_to_cli() {
let claude_output = r#"{"type":"result","result":"Styled CLI response.","usage":{"input_tokens":10,"output_tokens":5}}"#;
let env: Arc<dyn fabro_agent::Sandbox> = Arc::new(CliTestEnv::new(claude_output));
let mut graph = Graph::new("StylesheetTest");
graph.attrs.insert(
"model_stylesheet".to_string(),
AttrValue::String(".cli-node { backend: cli; }".to_string()),
);
let mut start = Node::new("start");
start.attrs.insert(
"shape".to_string(),
AttrValue::String("Mdiamond".to_string()),
);
graph.nodes.insert("start".to_string(), start);
let mut exit = Node::new("exit");
exit.attrs.insert(
"shape".to_string(),
AttrValue::String("Msquare".to_string()),
);
graph.nodes.insert("exit".to_string(), exit);
let mut work = Node::new("work");
work.attrs
.insert("shape".to_string(), AttrValue::String("box".to_string()));
work.attrs.insert(
"prompt".to_string(),
AttrValue::String("Do work".to_string()),
);
work.classes.push("cli-node".to_string());
graph.nodes.insert("work".to_string(), work);
graph.edges.push(Edge::new("start", "work"));
graph.edges.push(Edge::new("work", "exit"));
// Apply stylesheet
let ss = parse_stylesheet(graph.model_stylesheet()).unwrap();
apply_stylesheet(&ss, &mut graph);
// Verify the stylesheet applied the backend property
assert_eq!(
graph.nodes["work"].backend(),
Some("cli"),
"stylesheet should set backend=cli on .cli-node"
);
// Run the pipeline
let api = MockCodergenBackend;
let cli = AgentCliBackend::new("claude-opus-4-6".into(), Provider::Anthropic)
.with_poll_interval(Duration::from_millis(10));
let router = BackendRouter::new(Box::new(api), cli);
let mut registry = HandlerRegistry::new(Box::new(AgentHandler::new(Some(Box::new(router)))));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
let api2 = MockCodergenBackend;
let cli2 = AgentCliBackend::new("claude-opus-4-6".into(), Provider::Anthropic)
.with_poll_interval(Duration::from_millis(10));
let router2 = BackendRouter::new(Box::new(api2), cli2);
registry.register(
"agent",
Box::new(AgentHandler::new(Some(Box::new(router2)))),
);
let dir = tempfile::tempdir().unwrap();
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), env);
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let outcome = engine
.run(&graph, &config)
.await
.expect("pipeline should succeed");
assert_eq!(outcome.status, StageStatus::Success);
let response =
std::fs::read_to_string(dir.path().join("nodes").join("work").join("response.md")).unwrap();
assert_eq!(
response, "Styled CLI response.",
"stylesheet-driven node should use CLI backend"
);
}
// ---------------------------------------------------------------------------
// Real CLI backend e2e tests (require actual CLI tools installed)
// ---------------------------------------------------------------------------
use fabro_workflows::cli::cli_backend::parse_cli_response;
/// Run a real CLI tool via LocalSandbox and verify the full flow.
async fn run_real_cli_test(provider: Provider, model: &str) {
let env = local_env();
let backend = AgentCliBackend::new(model.to_string(), provider);
let mut node = Node::new("real_cli_test");
node.attrs.insert(
"prompt".to_string(),
AttrValue::String("What is 2+2? Reply with just the number.".to_string()),
);
let context = Context::new();
let emitter = Arc::new(EventEmitter::new());
let dir = tempfile::tempdir().unwrap();
let result = backend
.run(
&node,
"What is 2+2? Reply with just the number.",
&context,
None,
&emitter,
dir.path(),
&env,
None,
)
.await
.unwrap_or_else(|_| panic!("CLI backend ({provider}/{model}) should succeed"));
match result {
CodergenResult::Text { text, usage, .. } => {
assert!(
text.contains('4'),
"{provider}/{model}: expected response to contain '4', got: {text}"
);
let usage = usage.unwrap_or_else(|| panic!("{provider}/{model}: should have usage"));
assert!(
usage.input_tokens > 0,
"{provider}/{model}: input_tokens should be > 0, got {}",
usage.input_tokens
);
}
CodergenResult::Full(_) => panic!("expected Text result from {provider}/{model}"),
}
// Verify log files were written
let provider_path = dir.path().join("provider_used.json");
assert!(
provider_path.exists(),
"{provider}/{model}: provider_used.json should exist"
);
let provider_json: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&provider_path).unwrap()).unwrap();
assert_eq!(provider_json["mode"], "cli");
assert_eq!(provider_json["provider"], provider.as_str());
// Verify CLI output was streamed to stage_dir during poll
let stdout_log = dir.path().join("cli_stdout.log");
assert!(
stdout_log.exists(),
"{provider}/{model}: cli_stdout.log should be written during poll"
);
let stdout_content = std::fs::read_to_string(&stdout_log).unwrap();
assert!(
!stdout_content.is_empty(),
"{provider}/{model}: cli_stdout.log should not be empty"
);
}
#[tokio::test]
#[ignore] // requires `claude` CLI installed
async fn real_cli_claude() {
run_real_cli_test(Provider::Anthropic, "haiku").await;
}
#[tokio::test]
#[ignore] // requires `codex` CLI installed and OpenAI auth
async fn real_cli_codex() {
run_real_cli_test(Provider::OpenAi, "").await;
}
#[tokio::test]
#[ignore] // requires `gemini` CLI installed and Google auth
async fn real_cli_gemini() {
run_real_cli_test(Provider::Gemini, "gemini-2.5-flash").await;
}
/// Verify parse_cli_response works against real Claude CLI output captured from stream-json.
#[test]
fn parse_real_claude_stream_json() {
// Real output captured from: claude -p --output-format stream-json --model haiku "What is 2+2?"
let output = r#"{"type":"system","subtype":"init","cwd":"/tmp","session_id":"abc"}
{"type":"assistant","message":{"content":[{"type":"text","text":"4"}]}}
{"type":"result","subtype":"success","is_error":false,"duration_ms":2000,"num_turns":1,"result":"4","usage":{"input_tokens":9,"output_tokens":5}}"#;
let response = parse_cli_response(Provider::Anthropic, output).unwrap();
assert_eq!(response.text, "4");
assert_eq!(response.input_tokens, 9);
assert_eq!(response.output_tokens, 5);
}
/// Verify parse_cli_response works against real Codex CLI output.
#[test]
fn parse_real_codex_ndjson() {
// Real output captured from: echo "What is 2+2?" | codex exec --json
let output = r#"{"type":"thread.started","thread_id":"019ca1ec-1e86-79b2-b2b2-b1d963f1aea2"}
{"type":"turn.started"}
{"type":"item.completed","item":{"id":"item_0","type":"reasoning","text":"**Confirming simple numeric reply**"}}
{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"4"}}
{"type":"turn.completed","usage":{"input_tokens":7999,"cached_input_tokens":7040,"output_tokens":33}}"#;
let response = parse_cli_response(Provider::OpenAi, output).unwrap();
assert_eq!(response.text, "4");
assert_eq!(response.input_tokens, 7999);
assert_eq!(response.output_tokens, 33);
}
/// Verify parse_cli_response works against real Gemini CLI output.
#[test]
fn parse_real_gemini_json() {
// Real output captured from: gemini "What is 2+2?" -m gemini-2.5-flash --sandbox -o json
let output = r#"{"session_id":"abc","response":"4","stats":{"models":{"gemini-2.5-flash":{"api":{"totalRequests":1,"totalErrors":0,"totalLatencyMs":618},"tokens":{"input":123,"prompt":8911,"candidates":1,"total":8912,"cached":8788,"thoughts":0,"tool":0}}},"tools":{"totalCalls":0},"files":{"totalLinesAdded":0,"totalLinesRemoved":0}}}"#;
let response = parse_cli_response(Provider::Gemini, output).unwrap();
assert_eq!(response.text, "4");
assert_eq!(response.input_tokens, 123);
assert_eq!(response.output_tokens, 1);
}
// ---------------------------------------------------------------------------
// Git checkpoint e2e (Local)
// ---------------------------------------------------------------------------
use fabro_workflows::handler::fan_in::FanInHandler;
use fabro_workflows::handler::parallel::ParallelHandler;
/// A handler that writes a file named `{node_id}.txt` into the sandbox's
/// working directory. Used to verify git worktree isolation in parallel branches.
struct FileWriterHandler;
#[async_trait::async_trait]
impl Handler for FileWriterHandler {
async fn execute(
&self,
node: &Node,
_context: &Context,
_graph: &Graph,
_run_dir: &Path,
services: &fabro_workflows::handler::EngineServices,
) -> Result<Outcome, FabroError> {
let work_dir = services.sandbox.working_directory().to_string();
let file_path = format!("{}/{}.txt", work_dir, node.id);
services
.sandbox
.write_file(&file_path, &format!("written by {}", node.id))
.await
.map_err(|e| FabroError::handler(format!("write_file failed: {e}")))?;
Ok(Outcome::success())
}
}
/// End-to-end test: pipeline with git checkpointing enabled emits `CheckpointCompleted`
/// events with valid commit SHAs and writes `diff.patch` per stage.
#[tokio::test]
async fn git_checkpoint_host_emits_events_and_diff_patch() {
// 1. Create a temporary git repo with an initial commit
let repo = tempfile::tempdir().unwrap();
std::process::Command::new("git")
.args(["init"])
.current_dir(repo.path())
.output()
.unwrap();
std::process::Command::new("git")
.args([
"-c",
"user.name=test",
"-c",
"user.email=test@test",
"commit",
"--allow-empty",
"-m",
"init",
])
.current_dir(repo.path())
.output()
.unwrap();
// 2. Create a branch and worktree (like cli/run.rs setup_worktree)
let base_sha = {
let out = std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(repo.path())
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout).trim().to_string()
};
std::process::Command::new("git")
.args(["branch", "fabro/run/test-docker", "HEAD"])
.current_dir(repo.path())
.output()
.unwrap();
let worktree_path = repo.path().join("worktree");
std::process::Command::new("git")
.args(["worktree", "add"])
.arg(&worktree_path)
.arg("fabro/run/test-docker")
.current_dir(repo.path())
.output()
.unwrap();
// Write a file in the worktree so there's something to commit
std::fs::write(worktree_path.join("hello.txt"), "from docker test").unwrap();
// 3. Build a simple pipeline: start -> work -> exit
let mut graph = Graph::new("DockerGitCheckpoint");
graph.attrs.insert(
"goal".to_string(),
AttrValue::String("Test Host git checkpoint".to_string()),
);
let mut start = Node::new("start");
start.attrs.insert(
"shape".to_string(),
AttrValue::String("Mdiamond".to_string()),
);
graph.nodes.insert("start".to_string(), start);
let mut exit = Node::new("exit");
exit.attrs.insert(
"shape".to_string(),
AttrValue::String("Msquare".to_string()),
);
graph.nodes.insert("exit".to_string(), exit);
let mut work = Node::new("work");
work.attrs
.insert("label".to_string(), AttrValue::String("Work".to_string()));
graph.nodes.insert("work".to_string(), work);
graph.edges.push(Edge::new("start", "work"));
graph.edges.push(Edge::new("work", "exit"));
// 4. Set up event collection and engine
let run_dir = tempfile::tempdir().unwrap();
let mut emitter = EventEmitter::new();
let events = collect_events(&mut emitter);
let env: Arc<dyn fabro_agent::Sandbox> =
Arc::new(fabro_agent::LocalSandbox::new(worktree_path.clone()));
let mut registry = HandlerRegistry::new(Box::new(ContextSetterHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
let engine = WorkflowRunEngine::new(registry, Arc::new(emitter), env);
let config = RunConfig {
run_dir: run_dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-docker".into(),
git_checkpoint_enabled: true,
host_repo_path: Some(worktree_path.clone()),
base_sha: Some(base_sha.clone()),
run_branch: Some("fabro/run/test-docker".to_string()),
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
// 5. Run pipeline
let outcome = engine
.run(&graph, &config)
.await
.expect("pipeline should succeed");
assert_eq!(outcome.status, StageStatus::Success);
// 6. Assert CheckpointCompleted events with git SHAs were emitted
let events = events.lock().unwrap();
let git_events: Vec<_> = events
.iter()
.filter_map(|e| {
if let WorkflowRunEvent::CheckpointCompleted {
node_id,
git_commit_sha: Some(sha),
..
} = e
{
Some((node_id.clone(), sha.clone()))
} else {
None
}
})
.collect();
// work node gets a checkpoint commit (start is skipped, exit is terminal)
assert!(
!git_events.is_empty(),
"expected at least 1 CheckpointCompleted event with SHA, got {}",
git_events.len()
);
assert!(
!git_events.iter().any(|(id, _)| id == "start"),
"start node should not have a git checkpoint"
);
// Each SHA should be a valid 40-char hex string
assert!(
git_events
.iter()
.all(|(_, sha)| sha.len() == 40 && sha.chars().all(|c| c.is_ascii_hexdigit())),
"all SHAs should be 40-char hex, got: {git_events:?}"
);
// 7. diff.patch is NOT written for the start node (git checkpoint skipped)
let start_diff = run_dir
.path()
.join("nodes")
.join("start")
.join("diff.patch");
assert!(
!start_diff.exists(),
"diff.patch should not exist for start node (git checkpoint skipped)"
);
// 8. Verify checkpoint.json has git_commit_sha
let checkpoint =
Checkpoint::load(&run_dir.path().join("checkpoint.json")).expect("checkpoint should load");
assert!(
checkpoint.git_commit_sha.is_some(),
"checkpoint should have git_commit_sha"
);
// 9. Assert final.patch exists and contains the changes
let final_patch = run_dir.path().join("final.patch");
assert!(final_patch.exists(), "final.patch should exist in run_dir");
let patch_content = std::fs::read_to_string(&final_patch).unwrap();
assert!(
patch_content.contains("hello.txt"),
"final.patch should contain hello.txt changes"
);
// Cleanup worktree
let _ = std::process::Command::new("git")
.args(["worktree", "remove", "--force"])
.arg(&worktree_path)
.current_dir(repo.path())
.output();
}
/// End-to-end test: pipeline with git checkpointing enabled + `meta_branch` writes
/// shadow branch with checkpoint data and includes `Fabro-Checkpoint` trailer in run-branch commits.
#[tokio::test]
async fn git_checkpoint_host_writes_shadow_branch() {
use fabro_workflows::git::MetadataStore;
// 1. Create a temporary git repo with an initial commit
let repo = tempfile::tempdir().unwrap();
std::process::Command::new("git")
.args(["init"])
.current_dir(repo.path())
.output()
.unwrap();
std::process::Command::new("git")
.args([
"-c",
"user.name=test",
"-c",
"user.email=test@test",
"commit",
"--allow-empty",
"-m",
"init",
])
.current_dir(repo.path())
.output()
.unwrap();
// 2. Create a branch and worktree
let run_id = "test-shadow";
let base_sha = {
let out = std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(repo.path())
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout).trim().to_string()
};
std::process::Command::new("git")
.args(["branch", &format!("fabro/run/{run_id}"), "HEAD"])
.current_dir(repo.path())
.output()
.unwrap();
let worktree_path = repo.path().join("worktree");
std::process::Command::new("git")
.args(["worktree", "add"])
.arg(&worktree_path)
.arg(format!("fabro/run/{run_id}"))
.current_dir(repo.path())
.output()
.unwrap();
// Write a file in the worktree so there's something to commit
std::fs::write(worktree_path.join("shadow_test.txt"), "shadow branch test").unwrap();
// 3. Build a simple pipeline: start -> work -> exit
let mut graph = Graph::new("ShadowBranchTest");
graph.attrs.insert(
"goal".to_string(),
AttrValue::String("Test shadow branch".to_string()),
);
let mut start = Node::new("start");
start.attrs.insert(
"shape".to_string(),
AttrValue::String("Mdiamond".to_string()),
);
graph.nodes.insert("start".to_string(), start);
let mut exit = Node::new("exit");
exit.attrs.insert(
"shape".to_string(),
AttrValue::String("Msquare".to_string()),
);
graph.nodes.insert("exit".to_string(), exit);
let mut work = Node::new("work");
work.attrs
.insert("label".to_string(), AttrValue::String("Work".to_string()));
graph.nodes.insert("work".to_string(), work);
graph.edges.push(Edge::new("start", "work"));
graph.edges.push(Edge::new("work", "exit"));
// 4. Set up engine with meta_branch
let run_dir = tempfile::tempdir().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> =
Arc::new(fabro_agent::LocalSandbox::new(worktree_path.clone()));
let mut registry = HandlerRegistry::new(Box::new(ContextSetterHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
let engine = WorkflowRunEngine::new(registry, Arc::new(emitter), env);
let meta_branch = MetadataStore::branch_name(run_id);
let config = RunConfig {
run_dir: run_dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: run_id.into(),
git_checkpoint_enabled: true,
host_repo_path: Some(worktree_path.clone()),
base_sha: Some(base_sha),
run_branch: Some(format!("fabro/run/{run_id}")),
meta_branch: Some(meta_branch),
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
// 5. Run pipeline
let outcome = engine
.run(&graph, &config)
.await
.expect("pipeline should succeed");
assert_eq!(outcome.status, StageStatus::Success);
// 6. Assert shadow branch has checkpoint data on the host repo
let checkpoint = MetadataStore::read_checkpoint(repo.path(), run_id)
.expect("read_checkpoint should not error")
.expect("shadow branch should contain checkpoint data");
assert!(
!checkpoint.completed_nodes.is_empty(),
"checkpoint should have completed nodes"
);
assert!(
checkpoint.completed_nodes.contains(&"work".to_string()),
"checkpoint should contain the 'work' node"
);
// 7. Assert run-branch commit has Fabro-Checkpoint trailer pointing to shadow SHA
let output = std::process::Command::new("git")
.args(["log", "--format=%B", "-1"])
.current_dir(&worktree_path)
.output()
.unwrap();
let commit_msg = String::from_utf8_lossy(&output.stdout).trim().to_string();
assert!(
commit_msg.contains("Fabro-Checkpoint:"),
"run-branch commit should have Fabro-Checkpoint trailer, got:\n{commit_msg}"
);
assert!(
commit_msg.contains("Fabro-Run:"),
"run-branch commit should have Fabro-Run trailer, got:\n{commit_msg}"
);
assert!(
commit_msg.contains("Fabro-Completed:"),
"run-branch commit should have Fabro-Completed trailer, got:\n{commit_msg}"
);
// 8. Verify round-trip: shadow checkpoint's completed_nodes matches expected
let manifest = MetadataStore::read_manifest(repo.path(), run_id)
.expect("read_manifest should not error")
.expect("shadow branch should contain manifest");
assert_eq!(manifest.run_id, run_id);
// Cleanup worktree
let _ = std::process::Command::new("git")
.args(["worktree", "remove", "--force"])
.arg(&worktree_path)
.current_dir(repo.path())
.output();
}
// ---------------------------------------------------------------------------
// Host e2e: parallel git branching with worktree isolation
// ---------------------------------------------------------------------------
/// End-to-end: parallel branches get isolated worktrees, fan-in fast-forwards to winner.
///
/// Pipeline: start -> fan_out -> {branch_a, branch_b} -> fan_in -> exit
///
/// Each branch writes a unique file. After fan-in, only the winner's file should
/// be present in the main worktree.
#[tokio::test]
async fn parallel_git_branching_host_e2e() {
// 1. Create a temporary git repo with an initial commit
let repo = tempfile::tempdir().unwrap();
std::process::Command::new("git")
.args(["init"])
.current_dir(repo.path())
.output()
.unwrap();
std::process::Command::new("git")
.args([
"-c",
"user.name=test",
"-c",
"user.email=test@test",
"commit",
"--allow-empty",
"-m",
"init",
])
.current_dir(repo.path())
.output()
.unwrap();
// 2. Set up run branch and worktree (same as cli/run.rs)
let base_sha = {
let out = std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(repo.path())
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout).trim().to_string()
};
let run_id = "par-git-test";
let run_branch = format!("fabro/run/{run_id}");
std::process::Command::new("git")
.args(["branch", &run_branch, "HEAD"])
.current_dir(repo.path())
.output()
.unwrap();
let worktree_path = repo.path().join("worktree");
std::process::Command::new("git")
.args(["worktree", "add"])
.arg(&worktree_path)
.arg(&run_branch)
.current_dir(repo.path())
.output()
.unwrap();
// 3. Build pipeline: start -> fan_out -> {branch_a, branch_b} -> fan_in -> exit
let mut graph = Graph::new("ParallelGitBranching");
graph.attrs.insert(
"goal".to_string(),
AttrValue::String("Test parallel git branching".to_string()),
);
let mut start = Node::new("start");
start.attrs.insert(
"shape".to_string(),
AttrValue::String("Mdiamond".to_string()),
);
graph.nodes.insert("start".to_string(), start);
let mut fan_out = Node::new("fan_out");
fan_out.attrs.insert(
"shape".to_string(),
AttrValue::String("component".to_string()),
);
graph.nodes.insert("fan_out".to_string(), fan_out);
let branch_a = Node::new("branch_a");
graph.nodes.insert("branch_a".to_string(), branch_a);
let branch_b = Node::new("branch_b");
graph.nodes.insert("branch_b".to_string(), branch_b);
let mut fan_in = Node::new("fan_in");
fan_in.attrs.insert(
"shape".to_string(),
AttrValue::String("tripleoctagon".to_string()),
);
graph.nodes.insert("fan_in".to_string(), fan_in);
let mut exit = Node::new("exit");
exit.attrs.insert(
"shape".to_string(),
AttrValue::String("Msquare".to_string()),
);
graph.nodes.insert("exit".to_string(), exit);
graph.edges.push(Edge::new("start", "fan_out"));
graph.edges.push(Edge::new("fan_out", "branch_a"));
graph.edges.push(Edge::new("fan_out", "branch_b"));
graph.edges.push(Edge::new("branch_a", "fan_in"));
graph.edges.push(Edge::new("branch_b", "fan_in"));
graph.edges.push(Edge::new("fan_in", "exit"));
// 4. Set up engine with FileWriterHandler for branches
let run_dir = tempfile::tempdir().unwrap();
let mut emitter = EventEmitter::new();
let events = collect_events(&mut emitter);
let env: Arc<dyn fabro_agent::Sandbox> =
Arc::new(fabro_agent::LocalSandbox::new(worktree_path.clone()));
let mut registry = HandlerRegistry::new(Box::new(FileWriterHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("parallel", Box::new(ParallelHandler));
registry.register(
"parallel.fan_in",
Box::new(FanInHandler::new(None)), // heuristic select — picks branch_a (lexical tiebreak)
);
let engine = WorkflowRunEngine::new(registry, Arc::new(emitter), env);
let config = RunConfig {
run_dir: run_dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: run_id.into(),
git_checkpoint_enabled: true,
host_repo_path: Some(worktree_path.clone()),
base_sha: Some(base_sha.clone()),
run_branch: Some(run_branch.clone()),
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
// 5. Run pipeline
let outcome = engine
.run(&graph, &config)
.await
.expect("parallel pipeline should succeed");
assert_eq!(
outcome.status,
StageStatus::Success,
"pipeline failed: {:?}",
outcome.failure_reason()
);
// 6. Verify parallel.results has head_sha for each branch
let checkpoint =
Checkpoint::load(&run_dir.path().join("checkpoint.json")).expect("checkpoint should load");
let parallel_results = checkpoint
.context_values
.get("parallel.results")
.expect("parallel.results should be in context");
let results_arr = parallel_results.as_array().expect("should be an array");
assert_eq!(results_arr.len(), 2, "should have 2 branch results");
// Both branches should have head_sha
let branch_a_result = results_arr
.iter()
.find(|v| v.get("id").and_then(|v| v.as_str()) == Some("branch_a"))
.expect("branch_a result should exist");
let branch_b_result = results_arr
.iter()
.find(|v| v.get("id").and_then(|v| v.as_str()) == Some("branch_b"))
.expect("branch_b result should exist");
let sha_a = branch_a_result
.get("head_sha")
.and_then(|v| v.as_str())
.expect("branch_a should have head_sha");
let sha_b = branch_b_result
.get("head_sha")
.and_then(|v| v.as_str())
.expect("branch_b should have head_sha");
assert_eq!(sha_a.len(), 40, "SHA should be 40 hex chars");
assert_eq!(sha_b.len(), 40, "SHA should be 40 hex chars");
assert_ne!(sha_a, sha_b, "branch SHAs should differ");
// 7. Verify fan_in selected a winner and set best_head_sha
let best_id = checkpoint
.context_values
.get("parallel.fan_in.best_id")
.and_then(|v| v.as_str().map(String::from))
.expect("fan_in should have selected a best_id");
let best_head_sha = checkpoint
.context_values
.get("parallel.fan_in.best_head_sha")
.and_then(|v| v.as_str().map(String::from))
.expect("fan_in should have set best_head_sha");
// Heuristic select with both success: lexical tiebreak picks "branch_a"
assert_eq!(
best_id, "branch_a",
"heuristic should pick branch_a (lexical)"
);
// 8. Verify winner's file is in the main worktree, loser's is NOT
let winner_file = worktree_path.join(format!("{best_id}.txt"));
assert!(
winner_file.exists(),
"winner's file ({best_id}.txt) should exist in main worktree after ff-merge"
);
let winner_content = std::fs::read_to_string(&winner_file).unwrap();
assert!(
winner_content.contains(&format!("written by {best_id}")),
"winner's file should have correct content"
);
let loser_id = if best_id == "branch_a" {
"branch_b"
} else {
"branch_a"
};
let loser_file = worktree_path.join(format!("{loser_id}.txt"));
assert!(
!loser_file.exists(),
"loser's file ({loser_id}.txt) should NOT exist in main worktree"
);
// 9. Verify the main worktree HEAD matches the winner's head_sha
let main_head = {
let out = std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(&worktree_path)
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout).trim().to_string()
};
// After fan-in ff-only + engine's own checkpoint commits, HEAD should be a
// descendant of best_head_sha.
let is_ancestor = std::process::Command::new("git")
.args(["merge-base", "--is-ancestor", &best_head_sha, &main_head])
.current_dir(&worktree_path)
.output()
.unwrap();
assert!(
is_ancestor.status.success(),
"best_head_sha ({best_head_sha}) should be an ancestor of current HEAD ({main_head})"
);
// 10. Verify parallel branch refs still exist (for debugging)
let branch_ref_a = format!("fabro/run/parallel/{run_id}/fan-out/pass1/branch-a");
let ref_check = std::process::Command::new("git")
.args(["rev-parse", "--verify", &branch_ref_a])
.current_dir(repo.path())
.output()
.unwrap();
assert!(
ref_check.status.success(),
"parallel branch ref should still exist for debugging"
);
// 11. Verify final.patch contains the winner's changes
let final_patch = run_dir.path().join("final.patch");
assert!(final_patch.exists(), "final.patch should exist in run_dir");
let patch_content = std::fs::read_to_string(&final_patch).unwrap();
assert!(
patch_content.contains(&format!("{best_id}.txt")),
"final.patch should contain winner's file"
);
assert!(
!patch_content.contains(&format!("{loser_id}.txt")),
"final.patch should NOT contain loser's file"
);
// 12. Verify events
let events = events.lock().unwrap();
let parallel_started: Vec<_> = events
.iter()
.filter(|e| matches!(e, WorkflowRunEvent::ParallelStarted { .. }))
.collect();
assert_eq!(
parallel_started.len(),
1,
"should have exactly one ParallelStarted event"
);
let parallel_completed: Vec<_> = events
.iter()
.filter(|e| matches!(e, WorkflowRunEvent::ParallelCompleted { .. }))
.collect();
assert_eq!(
parallel_completed.len(),
1,
"should have exactly one ParallelCompleted event"
);
// Cleanup
let _ = std::process::Command::new("git")
.args(["worktree", "remove", "--force"])
.arg(&worktree_path)
.current_dir(repo.path())
.output();
}
/// When a node produces no file changes, `diff.patch` should NOT be written.
#[tokio::test]
async fn git_checkpoint_host_skips_empty_diff_patch() {
let repo = tempfile::tempdir().unwrap();
std::process::Command::new("git")
.args(["init"])
.current_dir(repo.path())
.output()
.unwrap();
std::process::Command::new("git")
.args([
"-c",
"user.name=test",
"-c",
"user.email=test@test",
"commit",
"--allow-empty",
"-m",
"init",
])
.current_dir(repo.path())
.output()
.unwrap();
let base_sha = {
let out = std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(repo.path())
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout).trim().to_string()
};
std::process::Command::new("git")
.args(["branch", "fabro/run/empty-diff", "HEAD"])
.current_dir(repo.path())
.output()
.unwrap();
let worktree_path = repo.path().join("worktree");
std::process::Command::new("git")
.args(["worktree", "add"])
.arg(&worktree_path)
.arg("fabro/run/empty-diff")
.current_dir(repo.path())
.output()
.unwrap();
// No files written — handler is a no-op
let mut graph = Graph::new("EmptyDiff");
graph.attrs.insert(
"goal".to_string(),
AttrValue::String("Test empty diff skip".to_string()),
);
let mut start = Node::new("start");
start.attrs.insert(
"shape".to_string(),
AttrValue::String("Mdiamond".to_string()),
);
graph.nodes.insert("start".to_string(), start);
let mut exit = Node::new("exit");
exit.attrs.insert(
"shape".to_string(),
AttrValue::String("Msquare".to_string()),
);
graph.nodes.insert("exit".to_string(), exit);
let mut work = Node::new("work");
work.attrs
.insert("label".to_string(), AttrValue::String("Work".to_string()));
graph.nodes.insert("work".to_string(), work);
graph.edges.push(Edge::new("start", "work"));
graph.edges.push(Edge::new("work", "exit"));
let run_dir = tempfile::tempdir().unwrap();
let mut emitter = EventEmitter::new();
let _events = collect_events(&mut emitter);
let env: Arc<dyn fabro_agent::Sandbox> =
Arc::new(fabro_agent::LocalSandbox::new(worktree_path.clone()));
let mut registry = HandlerRegistry::new(Box::new(ContextSetterHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
let engine = WorkflowRunEngine::new(registry, Arc::new(emitter), env);
let config = RunConfig {
run_dir: run_dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "empty-diff".into(),
git_checkpoint_enabled: true,
host_repo_path: Some(worktree_path.clone()),
base_sha: Some(base_sha.clone()),
run_branch: Some("fabro/run/empty-diff".to_string()),
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let outcome = engine
.run(&graph, &config)
.await
.expect("pipeline should succeed");
assert_eq!(outcome.status, StageStatus::Success);
// diff.patch should NOT exist for the "work" node (no file changes)
let work_diff = run_dir.path().join("nodes").join("work").join("diff.patch");
assert!(
!work_diff.exists(),
"diff.patch should not exist when there are no changes"
);
// final.patch should NOT exist either
let final_patch = run_dir.path().join("final.patch");
assert!(
!final_patch.exists(),
"final.patch should not exist when there are no changes"
);
// Cleanup
let _ = std::process::Command::new("git")
.args(["worktree", "remove", "--force"])
.arg(&worktree_path)
.current_dir(repo.path())
.output();
}
// ---------------------------------------------------------------------------
// Failure Signatures & Circuit Breaker E2E Tests
// ---------------------------------------------------------------------------
/// Handler that always fails with a fixed deterministic reason.
struct DeterministicFailHandler {
reason: String,
}
impl DeterministicFailHandler {
fn new(reason: &str) -> Self {
Self {
reason: reason.to_string(),
}
}
}
#[async_trait::async_trait]
impl Handler for DeterministicFailHandler {
async fn execute(
&self,
_node: &Node,
_context: &Context,
_graph: &Graph,
_run_dir: &Path,
_services: &fabro_workflows::handler::EngineServices,
) -> Result<Outcome, FabroError> {
Ok(Outcome::fail_classify(&self.reason))
}
}
/// Handler that always fails with a transient_infra classification.
struct TransientInfraFailHandler;
#[async_trait::async_trait]
impl Handler for TransientInfraFailHandler {
async fn execute(
&self,
_node: &Node,
_context: &Context,
_graph: &Graph,
_run_dir: &Path,
_services: &fabro_workflows::handler::EngineServices,
) -> Result<Outcome, FabroError> {
Ok(Outcome::fail_classify("connection refused"))
}
}
/// Handler that provides an explicit `failure_signature` hint via FailureDetail.
struct SignatureHintHandler;
#[async_trait::async_trait]
impl Handler for SignatureHintHandler {
async fn execute(
&self,
_node: &Node,
_context: &Context,
_graph: &Graph,
_run_dir: &Path,
_services: &fabro_workflows::handler::EngineServices,
) -> Result<Outcome, FabroError> {
Ok(
Outcome::fail_classify("error at line 42 in commit abc123def0")
.with_signature(Some("custom-grouping-key")),
)
}
}
/// Handler that fails with varying reasons each call (truly different after normalization).
struct VaryingReasonFailHandler {
counter: std::sync::atomic::AtomicU32,
}
static E2E_VARYING_REASONS: &[&str] = &[
"syntax error in module alpha",
"type mismatch in module beta",
"missing field in module gamma",
"undefined reference in module delta",
"assertion failed in module epsilon",
"panic in module zeta",
"out of bounds in module eta",
"null pointer in module theta",
"stack overflow in module iota",
"deadlock in module kappa",
];
#[async_trait::async_trait]
impl Handler for VaryingReasonFailHandler {
async fn execute(
&self,
_node: &Node,
_context: &Context,
_graph: &Graph,
_run_dir: &Path,
_services: &fabro_workflows::handler::EngineServices,
) -> Result<Outcome, FabroError> {
let n = self
.counter
.fetch_add(1, std::sync::atomic::Ordering::SeqCst) as usize;
Ok(Outcome::fail_classify(
E2E_VARYING_REASONS[n % E2E_VARYING_REASONS.len()],
))
}
}
/// Handler that succeeds on the Nth call (0-indexed). Fails deterministically before that.
struct SucceedOnNthHandler {
succeed_on: u32,
counter: std::sync::atomic::AtomicU32,
}
#[async_trait::async_trait]
impl Handler for SucceedOnNthHandler {
async fn execute(
&self,
_node: &Node,
_context: &Context,
_graph: &Graph,
_run_dir: &Path,
_services: &fabro_workflows::handler::EngineServices,
) -> Result<Outcome, FabroError> {
let n = self
.counter
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
if n >= self.succeed_on {
Ok(Outcome::success())
} else {
Ok(Outcome::fail_classify("not yet ready"))
}
}
}
/// Build a pipeline: start -> work -> (fail loop back to work, success to exit)
/// This creates a self-loop where work keeps retrying via edge routing.
fn circuit_breaker_self_loop_graph(signature_limit: Option<i64>) -> Graph {
let mut graph = make_graph_with_start_exit("CircuitBreakerSelfLoop");
graph
.attrs
.insert("default_max_retry".to_string(), AttrValue::Integer(0));
// High visit limit so the circuit breaker fires first
graph
.attrs
.insert("max_node_visits".to_string(), AttrValue::Integer(100));
if let Some(limit) = signature_limit {
graph.attrs.insert(
"loop_restart_signature_limit".to_string(),
AttrValue::Integer(limit),
);
}
let mut work = Node::new("work");
work.attrs.insert(
"type".to_string(),
AttrValue::String("test_handler".to_string()),
);
work.attrs
.insert("max_retries".to_string(), AttrValue::Integer(0));
graph.nodes.insert("work".to_string(), work);
graph.edges.push(Edge::new("start", "work"));
let mut fail_edge = Edge::new("work", "work");
fail_edge.attrs.insert(
"condition".to_string(),
AttrValue::String("outcome=fail".to_string()),
);
graph.edges.push(fail_edge);
let mut ok_edge = Edge::new("work", "exit");
ok_edge.attrs.insert(
"condition".to_string(),
AttrValue::String("outcome=success".to_string()),
);
graph.edges.push(ok_edge);
graph
}
/// Build a pipeline: start -> work -> (fail: loop_restart to start, success: exit)
/// This uses loop_restart edges for full pipeline restarts.
fn circuit_breaker_restart_graph(signature_limit: Option<i64>) -> Graph {
let mut graph = make_graph_with_start_exit("CircuitBreakerRestart");
graph
.attrs
.insert("default_max_retry".to_string(), AttrValue::Integer(0));
graph
.attrs
.insert("max_node_visits".to_string(), AttrValue::Integer(100));
if let Some(limit) = signature_limit {
graph.attrs.insert(
"loop_restart_signature_limit".to_string(),
AttrValue::Integer(limit),
);
}
let mut work = Node::new("work");
work.attrs.insert(
"type".to_string(),
AttrValue::String("test_handler".to_string()),
);
work.attrs
.insert("max_retries".to_string(), AttrValue::Integer(0));
graph.nodes.insert("work".to_string(), work);
graph.edges.push(Edge::new("start", "work"));
let mut restart_edge = Edge::new("work", "start");
restart_edge.attrs.insert(
"condition".to_string(),
AttrValue::String("outcome=fail".to_string()),
);
restart_edge
.attrs
.insert("loop_restart".to_string(), AttrValue::Boolean(true));
graph.edges.push(restart_edge);
let mut ok_edge = Edge::new("work", "exit");
ok_edge.attrs.insert(
"condition".to_string(),
AttrValue::String("outcome=success".to_string()),
);
graph.edges.push(ok_edge);
graph
}
// --- E2E Test: normalize_failure_reason produces stable signatures ---
#[test]
fn e2e_normalize_failure_reason_strips_variable_data() {
use fabro_workflows::error::normalize_failure_reason;
// Two error messages that differ only in line numbers and hex hashes
// should normalize to the same string.
let reason_a = "Error at line 42 in commit abc123def0: assertion failed";
let reason_b = "Error at line 999 in commit deadbeef01: assertion failed";
assert_eq!(
normalize_failure_reason(reason_a),
normalize_failure_reason(reason_b),
"errors differing only in line numbers and hashes should normalize identically"
);
// Different semantic errors should NOT normalize to the same string.
let reason_c = "syntax error in module alpha";
let reason_d = "type mismatch in module beta";
assert_ne!(
normalize_failure_reason(reason_c),
normalize_failure_reason(reason_d),
"semantically different errors should produce different normalized forms"
);
}
// --- E2E Test: FailureSignature composite key format ---
#[test]
fn e2e_failure_signature_composite_key() {
use fabro_workflows::error::{FailureClass, FailureSignature};
let sig = FailureSignature::new(
"verify",
FailureClass::Deterministic,
None,
Some("assertion failed at line 42"),
);
let sig_str = sig.to_string();
// Verify format: node_id|failure_class|normalized_reason
assert!(sig_str.starts_with("verify|deterministic|"));
// Line number should be normalized away
assert!(
sig_str.contains("<n>"),
"line numbers should be normalized: {sig_str}"
);
assert!(
!sig_str.contains("42"),
"raw digits should be replaced: {sig_str}"
);
}
// --- E2E Test: signature_hint takes priority over failure_reason ---
#[test]
fn e2e_failure_signature_hint_priority() {
use fabro_workflows::error::{FailureClass, FailureSignature};
let sig = FailureSignature::new(
"build",
FailureClass::Deterministic,
Some("custom-key-abc"),
Some("raw error with line 123 and hash deadbeef"),
);
// The hint should be used, not the raw reason
assert_eq!(sig.to_string(), "build|deterministic|custom-key-abc");
}
// --- E2E Test: is_signature_tracked only for deterministic + structural ---
#[test]
fn e2e_only_deterministic_and_structural_tracked() {
use fabro_workflows::error::FailureClass;
// These should be tracked
assert!(FailureClass::Deterministic.is_signature_tracked());
assert!(FailureClass::Structural.is_signature_tracked());
// These should NOT be tracked (transient failures retry naturally)
assert!(!FailureClass::TransientInfra.is_signature_tracked());
assert!(!FailureClass::BudgetExhausted.is_signature_tracked());
assert!(!FailureClass::Canceled.is_signature_tracked());
assert!(!FailureClass::CompilationLoop.is_signature_tracked());
}
// --- E2E Test: loop_restart_signature_limit graph attribute ---
#[test]
fn e2e_loop_restart_signature_limit_from_graph_attr() {
let graph = circuit_breaker_self_loop_graph(Some(5));
assert_eq!(graph.loop_restart_signature_limit(), 5);
let graph_default = circuit_breaker_self_loop_graph(None);
assert_eq!(graph_default.loop_restart_signature_limit(), 3);
}
// --- E2E Test: deterministic failure in self-loop triggers circuit breaker ---
#[tokio::test]
async fn e2e_circuit_breaker_deterministic_self_loop() {
let dir = tempfile::tempdir().unwrap();
let graph = circuit_breaker_self_loop_graph(Some(3));
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register(
"test_handler",
Box::new(DeterministicFailHandler::new(
"assertion failed in foo_test",
)),
);
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "e2e-circuit-breaker".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let result = engine.run(&graph, &config).await;
assert!(result.is_err(), "pipeline should abort, not loop forever");
let err = result.unwrap_err().to_string();
assert!(
err.contains("deterministic failure cycle detected"),
"error should mention cycle detection, got: {err}"
);
assert!(
err.contains("repeated 3 times"),
"error should mention the count, got: {err}"
);
assert!(
err.contains("work|deterministic|"),
"error should include the signature, got: {err}"
);
}
// --- E2E Test: custom signature limit (5) ---
#[tokio::test]
async fn e2e_circuit_breaker_custom_limit() {
let dir = tempfile::tempdir().unwrap();
let graph = circuit_breaker_self_loop_graph(Some(5));
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register(
"test_handler",
Box::new(DeterministicFailHandler::new("same error every time")),
);
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "e2e-custom-limit".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let result = engine.run(&graph, &config).await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("repeated 5 times"),
"should fire at limit=5, got: {err}"
);
}
// --- E2E Test: transient_infra failures do NOT trigger circuit breaker ---
#[tokio::test]
async fn e2e_circuit_breaker_ignores_transient_failures() {
let dir = tempfile::tempdir().unwrap();
let mut graph = circuit_breaker_self_loop_graph(Some(3));
// Lower visit limit so the test terminates quickly via visit limit
graph
.attrs
.insert("max_node_visits".to_string(), AttrValue::Integer(6));
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("test_handler", Box::new(TransientInfraFailHandler));
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "e2e-transient-no-breaker".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let result = engine.run(&graph, &config).await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
// Should hit visit limit, NOT circuit breaker
assert!(
err.contains("stuck in a cycle"),
"transient failures should not trigger circuit breaker, got: {err}"
);
}
// --- E2E Test: different failure reasons produce different signatures ---
#[tokio::test]
async fn e2e_circuit_breaker_different_reasons_separate_counters() {
let dir = tempfile::tempdir().unwrap();
let mut graph = circuit_breaker_self_loop_graph(Some(3));
// With 10 unique reasons and limit=3, we can do up to 30 iterations before
// any single reason hits 3. But max_node_visits=8 will fire first.
graph
.attrs
.insert("max_node_visits".to_string(), AttrValue::Integer(8));
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register(
"test_handler",
Box::new(VaryingReasonFailHandler {
counter: std::sync::atomic::AtomicU32::new(0),
}),
);
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "e2e-varying-reasons".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let result = engine.run(&graph, &config).await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
// Should hit visit limit because each failure has a unique signature
assert!(
err.contains("stuck in a cycle"),
"varying reasons should not trigger circuit breaker, got: {err}"
);
}
// --- E2E Test: loop_restart edge triggers circuit breaker ---
#[tokio::test]
async fn e2e_circuit_breaker_loop_restart() {
let dir = tempfile::tempdir().unwrap();
let graph = circuit_breaker_restart_graph(Some(3));
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register(
"test_handler",
Box::new(DeterministicFailHandler::new("verify step failed")),
);
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "e2e-restart-breaker".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let result = engine.run(&graph, &config).await;
assert!(
result.is_err(),
"pipeline should abort, not restart forever"
);
let err = result.unwrap_err().to_string();
// The loop_restart guard blocks non-transient_infra failures immediately
assert!(
err.contains("loop_restart blocked")
|| err.contains("failure cycle detected")
|| err.contains("circuit breaker"),
"expected loop_restart guard or circuit breaker error, got: {err}"
);
}
// --- E2E Test: failure_signature stored in context (checkpoint verification) ---
#[tokio::test]
async fn e2e_failure_signature_persisted_in_context() {
let dir = tempfile::tempdir().unwrap();
// Pipeline: start -> work (fails once) -> exit
// Work fails but the edge routes to exit unconditionally.
let mut graph = make_graph_with_start_exit("SignatureContextTest");
graph
.attrs
.insert("default_max_retry".to_string(), AttrValue::Integer(0));
let mut work = Node::new("work");
work.attrs.insert(
"type".to_string(),
AttrValue::String("test_handler".to_string()),
);
work.attrs
.insert("max_retries".to_string(), AttrValue::Integer(0));
graph.nodes.insert("work".to_string(), work);
graph.edges.push(Edge::new("start", "work"));
graph.edges.push(Edge::new("work", "exit"));
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register(
"test_handler",
Box::new(DeterministicFailHandler::new("test assertion failed")),
);
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "e2e-sig-context".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let outcome = engine.run(&graph, &config).await.unwrap();
// Pipeline reaches exit (terminal), last completed node is "work" (Fail).
// The engine doesn't execute exit handlers, just breaks on terminal nodes.
assert_eq!(outcome.status, StageStatus::Fail);
// Verify checkpoint has failure_signature in context
let cp = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
let sig_value = cp
.context_values
.get("failure_signature")
.expect("failure_signature should be in context");
let sig_str = sig_value.as_str().unwrap();
assert!(
sig_str.contains("work|deterministic|"),
"signature should contain node_id|class|, got: {sig_str}"
);
assert!(
sig_str.contains("test assertion failed"),
"signature should contain normalized reason, got: {sig_str}"
);
}
// --- E2E Test: failure_signature hint from handler overrides raw reason ---
#[tokio::test]
async fn e2e_failure_signature_hint_overrides_reason_in_context() {
let dir = tempfile::tempdir().unwrap();
let mut graph = make_graph_with_start_exit("SignatureHintTest");
graph
.attrs
.insert("default_max_retry".to_string(), AttrValue::Integer(0));
let mut work = Node::new("work");
work.attrs.insert(
"type".to_string(),
AttrValue::String("hint_handler".to_string()),
);
work.attrs
.insert("max_retries".to_string(), AttrValue::Integer(0));
graph.nodes.insert("work".to_string(), work);
graph.edges.push(Edge::new("start", "work"));
graph.edges.push(Edge::new("work", "exit"));
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("hint_handler", Box::new(SignatureHintHandler));
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "e2e-sig-hint".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let _outcome = engine.run(&graph, &config).await.unwrap();
let cp = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
let sig_str = cp
.context_values
.get("failure_signature")
.and_then(|v| v.as_str())
.expect("failure_signature should be set");
// The hint "custom-grouping-key" should be used, not the raw reason
assert!(
sig_str.contains("custom-grouping-key"),
"hint should override raw reason, got: {sig_str}"
);
// Raw reason contained line numbers and hex — verify they are NOT in the signature
assert!(
!sig_str.contains("42"),
"raw reason details should not leak through, got: {sig_str}"
);
}
// --- E2E Test: signature maps persisted in checkpoint and survive save/load ---
#[tokio::test]
async fn e2e_signature_maps_persist_in_checkpoint() {
let dir = tempfile::tempdir().unwrap();
// Pipeline where work fails twice then we check the checkpoint
let graph = circuit_breaker_self_loop_graph(Some(5));
// Use a handler that succeeds on the 3rd call (0-indexed), so we get
// exactly 3 failures at the work node before succeeding on the 4th visit.
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register(
"test_handler",
Box::new(SucceedOnNthHandler {
succeed_on: 3,
counter: std::sync::atomic::AtomicU32::new(0),
}),
);
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "e2e-sig-persist".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let outcome = engine.run(&graph, &config).await.unwrap();
assert_eq!(outcome.status, StageStatus::Success);
// Load checkpoint and verify signature maps
let cp = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
// The pipeline had 3 deterministic failures at "work" before succeeding.
// loop_failure_signatures should have recorded them.
assert!(
!cp.loop_failure_signatures.is_empty(),
"loop_failure_signatures should have entries after deterministic failures"
);
// Verify the signature key format
let (sig, count) = cp.loop_failure_signatures.iter().next().unwrap();
assert!(
sig.to_string().starts_with("work|deterministic|"),
"signature key should have correct format, got: {sig}"
);
assert_eq!(
*count, 3,
"should have recorded exactly 3 failures before success"
);
}
// --- E2E Test: checkpoint backward compat (old checkpoints without signature fields) ---
#[test]
fn e2e_checkpoint_backward_compat_no_signatures() {
// Simulate loading a checkpoint saved before signature fields existed
let json = serde_json::json!({
"timestamp": "2025-06-01T00:00:00Z",
"current_node": "work",
"completed_nodes": ["start", "work"],
"node_retries": {},
"context_values": {"goal": "test"},
"logs": ["some log entry"],
"node_outcomes": {}
});
let cp: Checkpoint = serde_json::from_value(json).expect("should deserialize old checkpoint");
assert!(cp.loop_failure_signatures.is_empty());
assert!(cp.restart_failure_signatures.is_empty());
assert_eq!(cp.current_node, "work");
}
// --- E2E Test: checkpoint with signatures round-trips through save/load ---
#[test]
fn e2e_checkpoint_signatures_roundtrip() {
use fabro_workflows::error::{FailureClass, FailureSignature};
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("cp.json");
let ctx = Context::new();
ctx.set("goal", serde_json::json!("test roundtrip"));
let mut loop_sigs = std::collections::HashMap::new();
let sig1 = FailureSignature::new(
"verify",
FailureClass::Deterministic,
None,
Some("assertion failed"),
);
loop_sigs.insert(sig1.clone(), 2usize);
let mut restart_sigs = std::collections::HashMap::new();
let sig2 = FailureSignature::new(
"build",
FailureClass::Structural,
None,
Some("scope violation"),
);
restart_sigs.insert(sig2.clone(), 1usize);
let cp = Checkpoint::from_context(
&ctx,
"verify",
vec!["start".to_string(), "verify".to_string()],
std::collections::HashMap::new(),
std::collections::HashMap::new(),
None,
loop_sigs,
restart_sigs,
std::collections::HashMap::new(),
);
cp.save(&path).unwrap();
let loaded = Checkpoint::load(&path).unwrap();
assert_eq!(loaded.loop_failure_signatures.len(), 1);
assert_eq!(loaded.restart_failure_signatures.len(), 1);
assert_eq!(loaded.loop_failure_signatures.get(&sig1), Some(&2));
assert_eq!(loaded.restart_failure_signatures.get(&sig2), Some(&1));
}
// --- E2E Test: pipeline events are emitted before circuit breaker aborts ---
#[tokio::test]
async fn e2e_circuit_breaker_emits_events_before_abort() {
let dir = tempfile::tempdir().unwrap();
let graph = circuit_breaker_self_loop_graph(Some(3));
let mut emitter = EventEmitter::new();
let events = collect_events(&mut emitter);
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register(
"test_handler",
Box::new(DeterministicFailHandler::new("assertion failed")),
);
let engine = WorkflowRunEngine::new(registry, Arc::new(emitter), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "e2e-events".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let result = engine.run(&graph, &config).await;
assert!(result.is_err());
let events = events.lock().unwrap();
// Should have at least WorkflowRunStarted and some StageFailed/StageCompleted events
let has_pipeline_started = events
.iter()
.any(|e| matches!(e, WorkflowRunEvent::WorkflowRunStarted { .. }));
assert!(
has_pipeline_started,
"WorkflowRunStarted event should be emitted"
);
// Verify we got stage events for the failing work node.
// The circuit breaker fires when count reaches the limit (3) *before*
// the stage event for that iteration is emitted, so we see limit-1 events.
let stage_failed_count = events
.iter()
.filter(|e| matches!(e, WorkflowRunEvent::StageFailed { name, .. } if name == "work"))
.count();
let stage_completed_count = events
.iter()
.filter(|e| matches!(e, WorkflowRunEvent::StageCompleted { name, .. } if name == "work"))
.count();
let total_work_events = stage_completed_count + stage_failed_count;
// With limit=3, the breaker fires on the 3rd failure before its event is emitted.
// So we get 2 events (for failures 1 and 2).
assert!(
total_work_events >= 2,
"should have at least 2 stage events before circuit breaker fires, got: {total_work_events}"
);
}
// --- E2E Test: success resets to success path, but signatures are preserved ---
#[tokio::test]
async fn e2e_circuit_breaker_does_not_fire_below_limit() {
let dir = tempfile::tempdir().unwrap();
let graph = circuit_breaker_self_loop_graph(Some(5));
// Handler that fails 4 times (below limit of 5) then succeeds
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register(
"test_handler",
Box::new(SucceedOnNthHandler {
succeed_on: 4,
counter: std::sync::atomic::AtomicU32::new(0),
}),
);
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "e2e-below-limit".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let outcome = engine.run(&graph, &config).await.unwrap();
assert_eq!(
outcome.status,
StageStatus::Success,
"pipeline should succeed when failures stay below limit"
);
// Verify signatures were tracked but didn't trigger abort
let cp = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
let total_failures: usize = cp.loop_failure_signatures.values().sum();
assert_eq!(
total_failures, 4,
"should have tracked 4 failures in signatures"
);
}
// --- E2E Test: multi-stage pipeline with impl/verify cycle detection ---
#[tokio::test]
async fn e2e_circuit_breaker_multi_stage_impl_verify_cycle() {
// Pipeline: start -> impl (succeeds) -> verify (fails) -> impl -> verify -> ...
// The verify node always fails with the same deterministic reason.
// Circuit breaker should detect the verify failure cycling.
let dir = tempfile::tempdir().unwrap();
let mut graph = make_graph_with_start_exit("ImplVerifyCycle");
graph
.attrs
.insert("default_max_retry".to_string(), AttrValue::Integer(0));
graph
.attrs
.insert("max_node_visits".to_string(), AttrValue::Integer(100));
graph.attrs.insert(
"loop_restart_signature_limit".to_string(),
AttrValue::Integer(3),
);
let mut impl_node = Node::new("impl");
impl_node.attrs.insert(
"type".to_string(),
AttrValue::String("success_handler".to_string()),
);
graph.nodes.insert("impl".to_string(), impl_node);
let mut verify_node = Node::new("verify");
verify_node.attrs.insert(
"type".to_string(),
AttrValue::String("fail_handler".to_string()),
);
verify_node
.attrs
.insert("max_retries".to_string(), AttrValue::Integer(0));
graph.nodes.insert("verify".to_string(), verify_node);
graph.edges.push(Edge::new("start", "impl"));
graph.edges.push(Edge::new("impl", "verify"));
// verify fail -> back to impl
let mut fail_edge = Edge::new("verify", "impl");
fail_edge.attrs.insert(
"condition".to_string(),
AttrValue::String("outcome=fail".to_string()),
);
graph.edges.push(fail_edge);
// verify success -> exit (never taken)
let mut ok_edge = Edge::new("verify", "exit");
ok_edge.attrs.insert(
"condition".to_string(),
AttrValue::String("outcome=success".to_string()),
);
graph.edges.push(ok_edge);
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("success_handler", Box::new(StartHandler)); // StartHandler returns success
registry.register(
"fail_handler",
Box::new(DeterministicFailHandler::new(
"test assertion: expected 42, got 0",
)),
);
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "e2e-impl-verify-cycle".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let result = engine.run(&graph, &config).await;
assert!(
result.is_err(),
"should detect impl/verify cycle, not loop forever"
);
let err = result.unwrap_err().to_string();
assert!(
err.contains("deterministic failure cycle detected"),
"should identify deterministic failure cycle, got: {err}"
);
assert!(
err.contains("verify|deterministic|"),
"signature should name the verify node, got: {err}"
);
}
// --- E2E Tests: loop_restart guard (only transient_infra may restart) ---
/// Handler that fails with an explicit failure_class hint and succeeds on the Nth call.
struct ClassifiedFailHandler {
failure_class: &'static str,
succeed_on: u32,
counter: std::sync::atomic::AtomicU32,
}
impl ClassifiedFailHandler {
fn always(failure_class: &'static str) -> Self {
Self {
failure_class,
succeed_on: u32::MAX,
counter: std::sync::atomic::AtomicU32::new(0),
}
}
fn succeed_on(failure_class: &'static str, n: u32) -> Self {
Self {
failure_class,
succeed_on: n,
counter: std::sync::atomic::AtomicU32::new(0),
}
}
}
#[async_trait::async_trait]
impl Handler for ClassifiedFailHandler {
async fn execute(
&self,
_node: &Node,
_context: &Context,
_graph: &Graph,
_run_dir: &Path,
_services: &fabro_workflows::handler::EngineServices,
) -> Result<Outcome, FabroError> {
let n = self
.counter
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
if n >= self.succeed_on {
return Ok(Outcome::success());
}
let failure_class: fabro_workflows::error::FailureClass =
self.failure_class.parse().unwrap();
let mut outcome = Outcome::fail_classify("classified failure");
if let Some(ref mut f) = outcome.failure {
f.failure_class = failure_class;
}
Ok(outcome)
}
}
#[tokio::test]
async fn e2e_loop_restart_blocked_for_deterministic_failure() {
let dir = tempfile::tempdir().unwrap();
let graph = circuit_breaker_restart_graph(Some(10));
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register(
"test_handler",
Box::new(ClassifiedFailHandler::always("deterministic")),
);
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "e2e-restart-blocked-det".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let result = engine.run(&graph, &config).await;
assert!(
result.is_err(),
"deterministic failure should not loop_restart"
);
let err = result.unwrap_err().to_string();
assert!(
err.contains("loop_restart blocked"),
"expected loop_restart blocked error, got: {err}"
);
}
#[tokio::test]
async fn e2e_loop_restart_blocked_for_structural_failure() {
let dir = tempfile::tempdir().unwrap();
let graph = circuit_breaker_restart_graph(Some(10));
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register(
"test_handler",
Box::new(ClassifiedFailHandler::always("structural")),
);
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "e2e-restart-blocked-struct".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let result = engine.run(&graph, &config).await;
assert!(
result.is_err(),
"structural failure should not loop_restart"
);
let err = result.unwrap_err().to_string();
assert!(
err.contains("loop_restart blocked"),
"expected loop_restart blocked error, got: {err}"
);
}
#[tokio::test]
async fn e2e_loop_restart_blocked_for_budget_exhausted_failure() {
let dir = tempfile::tempdir().unwrap();
let graph = circuit_breaker_restart_graph(Some(10));
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register(
"test_handler",
Box::new(ClassifiedFailHandler::always("budget_exhausted")),
);
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "e2e-restart-blocked-budget".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let result = engine.run(&graph, &config).await;
assert!(
result.is_err(),
"budget_exhausted failure should not loop_restart"
);
let err = result.unwrap_err().to_string();
assert!(
err.contains("loop_restart blocked"),
"expected loop_restart blocked error, got: {err}"
);
}
#[tokio::test]
async fn e2e_loop_restart_blocked_for_canceled_failure() {
let dir = tempfile::tempdir().unwrap();
let graph = circuit_breaker_restart_graph(Some(10));
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register(
"test_handler",
Box::new(ClassifiedFailHandler::always("canceled")),
);
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "e2e-restart-blocked-canceled".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let result = engine.run(&graph, &config).await;
assert!(result.is_err(), "canceled failure should not loop_restart");
let err = result.unwrap_err().to_string();
assert!(
err.contains("loop_restart blocked"),
"expected loop_restart blocked error, got: {err}"
);
}
#[tokio::test]
async fn e2e_loop_restart_blocked_for_compilation_loop_failure() {
let dir = tempfile::tempdir().unwrap();
let graph = circuit_breaker_restart_graph(Some(10));
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register(
"test_handler",
Box::new(ClassifiedFailHandler::always("compilation_loop")),
);
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "e2e-restart-blocked-comploop".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let result = engine.run(&graph, &config).await;
assert!(
result.is_err(),
"compilation_loop failure should not loop_restart"
);
let err = result.unwrap_err().to_string();
assert!(
err.contains("loop_restart blocked"),
"expected loop_restart blocked error, got: {err}"
);
}
#[tokio::test]
async fn e2e_loop_restart_allowed_for_transient_infra() {
let dir = tempfile::tempdir().unwrap();
let graph = circuit_breaker_restart_graph(Some(10));
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
// Fails with transient_infra on first call, succeeds on second
registry.register(
"test_handler",
Box::new(ClassifiedFailHandler::succeed_on("transient_infra", 1)),
);
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "e2e-restart-allowed-transient".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let result = engine.run(&graph, &config).await;
assert!(
result.is_ok(),
"transient_infra failure should be allowed to loop_restart, got: {:?}",
result.unwrap_err()
);
}
// ---------------------------------------------------------------------------
// Stall watchdog e2e tests
// ---------------------------------------------------------------------------
/// Handler that sleeps forever (for stall watchdog testing).
struct HangingHandler;
#[async_trait::async_trait]
impl Handler for HangingHandler {
async fn execute(
&self,
_node: &Node,
_context: &Context,
_graph: &Graph,
_run_dir: &Path,
_services: &fabro_workflows::handler::EngineServices,
) -> Result<Outcome, FabroError> {
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
Ok(Outcome::success())
}
}
/// Handler that emits keepalive events periodically, then succeeds.
struct KeepaliveHandler {
interval_ms: u64,
total_ms: u64,
}
#[async_trait::async_trait]
impl Handler for KeepaliveHandler {
async fn execute(
&self,
node: &Node,
_context: &Context,
_graph: &Graph,
_run_dir: &Path,
services: &fabro_workflows::handler::EngineServices,
) -> Result<Outcome, FabroError> {
let start = std::time::Instant::now();
while start.elapsed() < std::time::Duration::from_millis(self.total_ms) {
tokio::time::sleep(std::time::Duration::from_millis(self.interval_ms)).await;
services.emitter.emit(&WorkflowRunEvent::Prompt {
stage: node.id.clone(),
text: "keepalive".to_string(),
});
}
Ok(Outcome::success())
}
}
#[tokio::test]
async fn e2e_stall_watchdog_triggers_from_dot_parsed_pipeline() {
// Parse a DOT graph with stall_timeout set to 200ms
let dot = r#"digraph StallTest {
graph [goal="Test stall watchdog", stall_timeout="50ms", default_max_retry=0]
start [shape=Mdiamond]
work [type="hanging", label="Work"]
exit [shape=Msquare]
start -> work -> exit
}"#;
let graph = parse(dot).expect("parse should succeed");
// Verify the stall_timeout was parsed correctly
assert_eq!(
graph.stall_timeout(),
Some(std::time::Duration::from_millis(50)),
);
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("hanging", Box::new(HangingHandler));
let events = Arc::new(std::sync::Mutex::new(Vec::new()));
let events_clone = events.clone();
let mut emitter = EventEmitter::new();
emitter.on_event(move |event| {
events_clone.lock().unwrap().push(format!("{event:?}"));
});
let engine = WorkflowRunEngine::new(registry, Arc::new(emitter), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "stall-e2e".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let result = engine.run(&graph, &config).await;
assert!(result.is_err(), "expected stall watchdog error");
let err = result.unwrap_err().to_string();
assert!(
err.contains("stall watchdog"),
"expected error to contain 'stall watchdog', got: {err}"
);
// Verify StallWatchdogTimeout event was emitted
let collected = events.lock().unwrap();
assert!(
collected.iter().any(|e| e.contains("StallWatchdogTimeout")),
"expected StallWatchdogTimeout event in: {collected:?}"
);
}
#[tokio::test]
async fn e2e_stall_watchdog_kept_alive_by_handler_events() {
// Parse a DOT graph with stall_timeout 200ms, but the handler emits events
// every 100ms for 500ms total — the watchdog should NOT trigger.
let dot = r#"digraph StallAliveTest {
graph [goal="Test stall keepalive", stall_timeout="100ms", default_max_retry=0]
start [shape=Mdiamond]
work [type="keepalive", label="Work"]
exit [shape=Msquare]
start -> work -> exit
}"#;
let graph = parse(dot).expect("parse should succeed");
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register(
"keepalive",
Box::new(KeepaliveHandler {
interval_ms: 10,
total_ms: 50,
}),
);
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "stall-alive-e2e".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let outcome = engine
.run(&graph, &config)
.await
.expect("pipeline should succeed");
assert_eq!(outcome.status, StageStatus::Success);
}
#[tokio::test]
async fn e2e_stall_watchdog_disabled_with_zero_timeout() {
// Parse a DOT graph with stall_timeout="0s" — watchdog should be disabled,
// and a short sleep handler should complete successfully.
let dot = r#"digraph StallDisabledTest {
graph [goal="Test stall disabled", stall_timeout="0s", default_max_retry=0]
start [shape=Mdiamond]
work [type="slow", label="Work"]
exit [shape=Msquare]
start -> work -> exit
}"#;
let graph = parse(dot).expect("parse should succeed");
assert_eq!(
graph.stall_timeout(),
None,
"zero timeout should disable watchdog"
);
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("slow", Box::new(SlowTestHandler { sleep_ms: 50 }));
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "stall-disabled-e2e".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let outcome = engine
.run(&graph, &config)
.await
.expect("pipeline should succeed");
assert_eq!(outcome.status, StageStatus::Success);
}
/// Handler that sleeps for a configurable duration, then succeeds (for e2e tests).
struct SlowTestHandler {
sleep_ms: u64,
}
#[async_trait::async_trait]
impl Handler for SlowTestHandler {
async fn execute(
&self,
_node: &Node,
_context: &Context,
_graph: &Graph,
_run_dir: &Path,
_services: &fabro_workflows::handler::EngineServices,
) -> Result<Outcome, FabroError> {
tokio::time::sleep(std::time::Duration::from_millis(self.sleep_ms)).await;
Ok(Outcome::success())
}
}
#[tokio::test]
async fn e2e_stall_watchdog_with_explicit_timeout_override() {
// A short stall_timeout of 50ms should trigger faster than the default 1800s.
// This tests that the graph attribute is actually respected.
let dot = r#"digraph StallOverrideTest {
graph [goal="Test stall override", stall_timeout="50ms", default_max_retry=0]
start [shape=Mdiamond]
work [type="hanging", label="Work"]
exit [shape=Msquare]
start -> work -> exit
}"#;
let graph = parse(dot).expect("parse should succeed");
assert_eq!(
graph.stall_timeout(),
Some(std::time::Duration::from_millis(50)),
);
let dir = tempfile::tempdir().unwrap();
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("hanging", Box::new(HangingHandler));
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), local_env());
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "stall-override-e2e".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let start = std::time::Instant::now();
let result = engine.run(&graph, &config).await;
let elapsed = start.elapsed();
assert!(result.is_err(), "expected stall watchdog error");
let err = result.unwrap_err().to_string();
assert!(err.contains("stall watchdog"), "got: {err}");
// Should trigger well under 1 second (50ms timeout + check interval overhead)
assert!(
elapsed < std::time::Duration::from_secs(1),
"stall watchdog took too long: {elapsed:?}"
);
}
// Daytona parallel git branching test is in daytona_integration.rs
// ---------------------------------------------------------------------------
// Asset collection e2e tests
// ---------------------------------------------------------------------------
/// Handler that creates asset files in the sandbox working directory via exec_command.
struct AssetCreatorHandler {
should_fail: bool,
}
impl AssetCreatorHandler {
fn success() -> Self {
Self { should_fail: false }
}
fn failing() -> Self {
Self { should_fail: true }
}
}
#[async_trait::async_trait]
impl Handler for AssetCreatorHandler {
async fn execute(
&self,
_node: &Node,
_context: &Context,
_graph: &Graph,
_run_dir: &Path,
services: &fabro_workflows::handler::EngineServices,
) -> Result<Outcome, FabroError> {
// Create asset files via the sandbox's exec_command
let script = concat!(
"mkdir -p test-results && ",
"echo '<testsuites><testsuite name=\"example\"/></testsuites>' > test-results/report.xml && ",
"echo 'test output' > test-results/output.txt"
);
services
.sandbox
.exec_command(script, 30_000, None, None, None)
.await
.map_err(|e| FabroError::handler(format!("exec failed: {e}")))?;
if self.should_fail {
Ok(Outcome::fail_classify("intentional failure"))
} else {
Ok(Outcome::success())
}
}
}
/// Local sandbox: asset collection discovers and downloads files created by a handler.
#[tokio::test]
async fn asset_collection_local_sandbox_success() {
let work_dir = tempfile::tempdir().unwrap();
let run_dir = tempfile::tempdir().unwrap();
let sandbox: Arc<dyn fabro_agent::Sandbox> = Arc::new(fabro_agent::LocalSandbox::new(
work_dir.path().to_path_buf(),
));
sandbox.initialize().await.unwrap();
let mut registry = HandlerRegistry::new(Box::new(AssetCreatorHandler::success()));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
let mut emitter = EventEmitter::new();
let events = collect_events(&mut emitter);
let engine = WorkflowRunEngine::new(registry, Arc::new(emitter), sandbox.clone());
let mut graph = Graph::new("AssetCollectionTest");
graph.attrs.insert(
"goal".to_string(),
AttrValue::String("Test asset collection".to_string()),
);
let mut start = Node::new("start");
start.attrs.insert(
"shape".to_string(),
AttrValue::String("Mdiamond".to_string()),
);
graph.nodes.insert("start".to_string(), start);
let mut create_assets = Node::new("create_assets");
create_assets.attrs.insert(
"label".to_string(),
AttrValue::String("Create Assets".to_string()),
);
graph
.nodes
.insert("create_assets".to_string(), create_assets);
let mut exit = Node::new("exit");
exit.attrs.insert(
"shape".to_string(),
AttrValue::String("Msquare".to_string()),
);
graph.nodes.insert("exit".to_string(), exit);
graph.edges.push(Edge::new("start", "create_assets"));
graph.edges.push(Edge::new("create_assets", "exit"));
let config = RunConfig {
run_dir: run_dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "asset-test-local".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: vec!["test-results/**".to_string()],
workflow_slug: None,
};
let outcome = engine
.run(&graph, &config)
.await
.expect("run should succeed");
assert_eq!(outcome.status, StageStatus::Success);
// Check that asset files were collected into the stage directory
let assets_dir = run_dir
.path()
.join("artifacts")
.join("assets")
.join("create_assets")
.join("retry_1");
let report_path = assets_dir.join("test-results/report.xml");
assert!(
report_path.exists(),
"report.xml should be collected at {}",
report_path.display()
);
let report_content = std::fs::read_to_string(&report_path).unwrap();
assert!(report_content.contains("testsuites"));
// Check manifest.json was written
let manifest_path = assets_dir.join("manifest.json");
assert!(
manifest_path.exists(),
"manifest.json should exist at {}",
manifest_path.display()
);
let manifest: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&manifest_path).unwrap()).unwrap();
assert!(manifest["files_copied"].as_u64().unwrap() >= 1);
// Check that AssetsCaptured event was emitted
let captured_events = events.lock().unwrap();
let assets_events: Vec<&WorkflowRunEvent> = captured_events
.iter()
.filter(|e| matches!(e, WorkflowRunEvent::AssetsCaptured { .. }))
.collect();
assert!(
!assets_events.is_empty(),
"should emit at least one AssetsCaptured event"
);
}
/// Local sandbox: assets are still collected even when the handler fails.
#[tokio::test]
async fn asset_collection_local_sandbox_on_failure() {
let work_dir = tempfile::tempdir().unwrap();
let run_dir = tempfile::tempdir().unwrap();
let sandbox: Arc<dyn fabro_agent::Sandbox> = Arc::new(fabro_agent::LocalSandbox::new(
work_dir.path().to_path_buf(),
));
sandbox.initialize().await.unwrap();
let mut registry = HandlerRegistry::new(Box::new(AssetCreatorHandler::failing()));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), sandbox.clone());
let mut graph = Graph::new("AssetCollectionFailTest");
graph.attrs.insert(
"goal".to_string(),
AttrValue::String("Test asset collection on failure".to_string()),
);
let mut start = Node::new("start");
start.attrs.insert(
"shape".to_string(),
AttrValue::String("Mdiamond".to_string()),
);
graph.nodes.insert("start".to_string(), start);
let mut create_assets = Node::new("create_assets");
create_assets.attrs.insert(
"label".to_string(),
AttrValue::String("Create Assets".to_string()),
);
graph
.nodes
.insert("create_assets".to_string(), create_assets);
let mut exit = Node::new("exit");
exit.attrs.insert(
"shape".to_string(),
AttrValue::String("Msquare".to_string()),
);
graph.nodes.insert("exit".to_string(), exit);
graph.edges.push(Edge::new("start", "create_assets"));
graph.edges.push(Edge::new("create_assets", "exit"));
let config = RunConfig {
run_dir: run_dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "asset-test-fail".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: vec!["test-results/**".to_string()],
workflow_slug: None,
};
let outcome = engine
.run(&graph, &config)
.await
.expect("run should succeed");
// The pipeline completes (handler returned Fail, not an error), but assets should still be collected
assert_eq!(outcome.status, StageStatus::Fail);
let assets_dir = run_dir
.path()
.join("artifacts")
.join("assets")
.join("create_assets")
.join("retry_1");
let report_path = assets_dir.join("test-results/report.xml");
assert!(
report_path.exists(),
"report.xml should still be collected after handler failure, at {}",
report_path.display()
);
}
/// Docker sandbox: asset collection works across the bind-mount boundary.
/// Requires Docker with `fabro-agent:latest` image available locally.
#[tokio::test]
#[ignore]
async fn asset_collection_docker_sandbox() {
let host_dir = tempfile::tempdir().unwrap();
let run_dir = tempfile::tempdir().unwrap();
let config = fabro_agent::DockerSandboxConfig {
host_working_directory: host_dir.path().to_str().unwrap().to_string(),
auto_pull: false,
..Default::default()
};
let sandbox: Arc<dyn fabro_agent::Sandbox> =
Arc::new(fabro_agent::DockerSandbox::new(config).expect("Docker not available"));
sandbox.initialize().await.expect("Docker init failed");
let mut registry = HandlerRegistry::new(Box::new(AssetCreatorHandler::success()));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), sandbox.clone());
let mut graph = Graph::new("DockerAssetTest");
graph.attrs.insert(
"goal".to_string(),
AttrValue::String("Test asset collection in Docker".to_string()),
);
let mut start = Node::new("start");
start.attrs.insert(
"shape".to_string(),
AttrValue::String("Mdiamond".to_string()),
);
graph.nodes.insert("start".to_string(), start);
let mut create_assets = Node::new("create_assets");
create_assets.attrs.insert(
"label".to_string(),
AttrValue::String("Create Assets".to_string()),
);
graph
.nodes
.insert("create_assets".to_string(), create_assets);
let mut exit = Node::new("exit");
exit.attrs.insert(
"shape".to_string(),
AttrValue::String("Msquare".to_string()),
);
graph.nodes.insert("exit".to_string(), exit);
graph.edges.push(Edge::new("start", "create_assets"));
graph.edges.push(Edge::new("create_assets", "exit"));
let run_config = RunConfig {
run_dir: run_dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "asset-test-docker".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let outcome = engine
.run(&graph, &run_config)
.await
.expect("pipeline should succeed");
assert_eq!(outcome.status, StageStatus::Success);
let assets_dir = run_dir
.path()
.join("artifacts")
.join("assets")
.join("create_assets")
.join("retry_1");
let report_path = assets_dir.join("test-results/report.xml");
assert!(
report_path.exists(),
"report.xml should be collected from Docker container at {}",
report_path.display()
);
let content = std::fs::read_to_string(&report_path).unwrap();
assert!(content.contains("testsuites"));
let manifest_path = assets_dir.join("manifest.json");
assert!(manifest_path.exists(), "manifest.json should exist");
sandbox.cleanup().await.unwrap();
}
#[tokio::test]
async fn wait_timer_e2e() {
let mut graph = make_graph_with_start_exit("WaitTimerTest");
let mut wait_node = Node::new("wait60");
wait_node.attrs.insert(
"shape".to_string(),
AttrValue::String("insulator".to_string()),
);
wait_node.attrs.insert(
"label".to_string(),
AttrValue::String("Wait 1ms".to_string()),
);
wait_node.attrs.insert(
"duration".to_string(),
AttrValue::Duration(std::time::Duration::from_millis(1)),
);
graph.nodes.insert("wait60".to_string(), wait_node);
graph.edges.push(Edge::new("start", "wait60"));
graph.edges.push(Edge::new("wait60", "exit"));
let dir = tempfile::tempdir().unwrap();
let interviewer = Arc::new(AutoApproveInterviewer);
let engine = WorkflowRunEngine::new(
make_full_registry(interviewer),
Arc::new(EventEmitter::new()),
local_env(),
);
let config = RunConfig {
run_dir: dir.path().to_path_buf(),
cancel_token: None,
dry_run: false,
run_id: "test-run".into(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: fabro_workflows::git::GitAuthor::default(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: false,
pull_request_auto_merge: false,
pull_request_merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
asset_globs: Vec::new(),
workflow_slug: None,
};
let outcome = engine.run(&graph, &config).await.expect("run");
assert_eq!(outcome.status, StageStatus::Success);
}