From da3610af59efbb99b3d12cea2fb723aa9df99b31 Mon Sep 17 00:00:00 2001
From: "brynary-fabro[bot]"
<265161896+brynary-fabro[bot]@users.noreply.github.com>
Date: Sun, 15 Mar 2026 19:55:03 -0400
Subject: [PATCH] Unified dry-run via Handler::simulate() (#14)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This PR introduces a unified dry-run mechanism by adding a
`Handler::simulate()` trait method and a `dispatch_handler()` routing
function that selects between `simulate()` and `execute()` based on
`services.dry_run`. Previously, dry-run behavior was scattered
inconsistently across handlers—`CommandHandler` checked
`services.dry_run` inline, `AgentHandler`/`PromptHandler`/`FanInHandler`
relied on the backend being `None`, and `WaitHandler`/`HumanHandler` had
no dry-run support at all (sleeping or blocking on input for real). This
made dry-run behavior fragile and difficult to extend to new handlers.
The new design adds an `Outcome::simulated(node_id)` factory for
standardized dry-run results, a default `simulate()` implementation on
the `Handler` trait that returns a generic simulated success, and
per-handler overrides where custom context updates are needed.
`CommandHandler` populates empty output/stderr, `AgentHandler` and
`PromptHandler` set simulated
`last_stage`/`last_response`/`response.{id}` context keys,
`FanInHandler` calls `heuristic_select()` without an LLM, `HumanHandler`
auto-selects the first choice, and `ParallelHandler` dispatches child
branches through `dispatch_handler()` while skipping git worktree
operations. The inline `dry_run` check in `CommandHandler::execute()` is
removed, and both call sites in the engine (`execute_with_retry` and
parallel branch dispatch) now route through `dispatch_handler()`.
All existing dry-run tests are updated to test `simulate()` directly,
and new tests verify that `dispatch_handler()` correctly routes based on
the `dry_run` flag and that each handler's `simulate()` produces the
expected context updates and outcome structure.
### Fabro Details
Ran 7 stages in 24m 57s for $4.72
| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 0s | – | 0 |
| preflight_compile | 0s | – | 0 |
| preflight_lint | 0s | – | 0 |
| implement | 0s | $3.29 | 0 |
| simplify | 0s | $1.42 | 0 |
| verify | 0s | – | 0 |
| **Total** | **24m 57s** | **$4.72** | **0** |
Ran ImplementAndSimplify.fabro (10 nodes and 13
edges)
```dot
digraph ImplementAndSimplify {
graph [
goal="Implement and simplify",
model_stylesheet="
* { backend: api; model: claude-opus-4-6;}
"
]
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
toolchain [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0]
preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check 2>&1", max_retries=0]
preflight_lint [label="Preflight Lint", shape=parallelogram, script="cargo clippy -- -D warnings 2>&1", max_retries=0]
fix_lints [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3]
implement [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan."]
simplify [label="Simplify", prompt="@prompts/simplify.md"]
verify [label="Verify", shape=parallelogram, script="cargo clippy -- -D warnings 2>&1 && cargo test 2>&1", goal_gate=true, retry_target="fixup"]
fixup [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all clippy lint warnings and test failures.", max_visits=3]
start -> toolchain
toolchain -> preflight_compile [condition="outcome=success"]
toolchain -> exit
preflight_compile -> preflight_lint [condition="outcome=success"]
preflight_compile -> exit
preflight_lint -> implement [condition="outcome=success"]
preflight_lint -> fix_lints
fix_lints -> preflight_lint
implement -> simplify -> verify
verify -> exit [condition="outcome=success"]
verify -> fixup
fixup -> verify
}
```
⚒️ Generated with [Fabro](https://fabro.sh)
---------
Co-authored-by: Fabro
Co-authored-by: Fabro Assistant
Co-authored-by: Bryan Helmkamp
Co-authored-by: Claude Opus 4.6 (1M context)
---
lib/crates/fabro-workflows/src/engine.rs | 9 +-
.../fabro-workflows/src/handler/agent.rs | 65 +++++----
.../fabro-workflows/src/handler/command.rs | 87 +++++++++---
.../fabro-workflows/src/handler/fan_in.rs | 58 ++++++++
.../fabro-workflows/src/handler/human.rs | 65 +++++++++
lib/crates/fabro-workflows/src/handler/mod.rs | 73 +++++++++++
.../fabro-workflows/src/handler/parallel.rs | 124 ++++++++++++++++--
.../fabro-workflows/src/handler/prompt.rs | 48 ++++---
lib/crates/fabro-workflows/src/outcome.rs | 18 +++
9 files changed, 478 insertions(+), 69 deletions(-)
diff --git a/lib/crates/fabro-workflows/src/engine.rs b/lib/crates/fabro-workflows/src/engine.rs
index ec1eff779..ca689425b 100644
--- a/lib/crates/fabro-workflows/src/engine.rs
+++ b/lib/crates/fabro-workflows/src/engine.rs
@@ -1017,7 +1017,14 @@ impl WorkflowRunEngine {
// Gap #11: Panic safety -- catch panics from handler execution
let result = {
- let future = handler.execute(node, context, graph, run_dir, &self.services);
+ let future = crate::handler::dispatch_handler(
+ handler,
+ node,
+ context,
+ graph,
+ run_dir,
+ &self.services,
+ );
let panic_safe = AssertUnwindSafe(future).catch_unwind();
// Gap #2: Timeout enforcement -- wrap with tokio::time::timeout
let timed_result = if let Some(duration) = node_timeout {
diff --git a/lib/crates/fabro-workflows/src/handler/agent.rs b/lib/crates/fabro-workflows/src/handler/agent.rs
index cd49ced97..342b15ec5 100644
--- a/lib/crates/fabro-workflows/src/handler/agent.rs
+++ b/lib/crates/fabro-workflows/src/handler/agent.rs
@@ -196,8 +196,38 @@ pub(crate) fn truncate(s: &str, max_chars: usize) -> &str {
}
}
+/// Shared simulate implementation for LLM-backed handlers (agent & prompt).
+/// Produces a simulated outcome with standard context updates.
+pub(crate) fn simulate_llm_handler(node: &Node) -> Outcome {
+ let simulated_text = format!("[Simulated] Response for stage: {}", node.id);
+ let mut outcome = Outcome::simulated(&node.id);
+ outcome
+ .context_updates
+ .insert(keys::LAST_STAGE.to_string(), serde_json::json!(node.id));
+ outcome.context_updates.insert(
+ keys::LAST_RESPONSE.to_string(),
+ serde_json::json!(truncate(&simulated_text, 200)),
+ );
+ outcome.context_updates.insert(
+ keys::response_key(&node.id),
+ serde_json::json!(&simulated_text),
+ );
+ outcome
+}
+
#[async_trait]
impl Handler for AgentHandler {
+ async fn simulate(
+ &self,
+ node: &Node,
+ _context: &Context,
+ _graph: &Graph,
+ _run_dir: &Path,
+ _services: &EngineServices,
+ ) -> Result {
+ Ok(simulate_llm_handler(node))
+ }
+
async fn execute(
&self,
node: &Node,
@@ -353,37 +383,28 @@ mod tests {
}
#[tokio::test]
- async fn codergen_handler_simulation_mode() {
+ async fn codergen_handler_simulate() {
let handler = AgentHandler::new(None);
- let mut node = Node::new("plan");
- node.attrs.insert(
- "prompt".to_string(),
- AttrValue::String("Plan the implementation".to_string()),
- );
+ let node = Node::new("plan");
let context = Context::new();
let graph = Graph::new("test");
let tmp = TempDir::new().unwrap();
let outcome = handler
- .execute(&node, &context, &graph, tmp.path(), &make_services())
+ .simulate(&node, &context, &graph, tmp.path(), &make_services())
.await
.unwrap();
assert_eq!(outcome.status, crate::outcome::StageStatus::Success);
- assert_eq!(outcome.notes.as_deref(), Some("Stage completed: plan"));
-
- // Check files were written
- let prompt_path = tmp.path().join("nodes").join("plan").join("prompt.md");
- assert!(prompt_path.exists());
- let prompt_content = std::fs::read_to_string(&prompt_path).unwrap();
- assert_eq!(prompt_content, "Plan the implementation");
-
- let response_path = tmp.path().join("nodes").join("plan").join("response.md");
- assert!(response_path.exists());
- let response_content = std::fs::read_to_string(&response_path).unwrap();
- assert!(response_content.contains("[Simulated]"));
-
- let status_path = tmp.path().join("nodes").join("plan").join("status.json");
- assert!(status_path.exists());
+ assert_eq!(outcome.notes.as_deref(), Some("[Simulated] plan"));
+ assert_eq!(
+ outcome.context_updates.get(keys::LAST_STAGE),
+ Some(&serde_json::json!("plan"))
+ );
+ assert!(outcome.context_updates.contains_key(keys::LAST_RESPONSE));
+ assert_eq!(
+ outcome.context_updates.get(&keys::response_key("plan")),
+ Some(&serde_json::json!("[Simulated] Response for stage: plan"))
+ );
}
#[tokio::test]
diff --git a/lib/crates/fabro-workflows/src/handler/command.rs b/lib/crates/fabro-workflows/src/handler/command.rs
index fc524ee3d..79bd8e626 100644
--- a/lib/crates/fabro-workflows/src/handler/command.rs
+++ b/lib/crates/fabro-workflows/src/handler/command.rs
@@ -27,6 +27,32 @@ pub struct CommandHandler;
#[async_trait]
impl Handler for CommandHandler {
+ async fn simulate(
+ &self,
+ node: &Node,
+ _context: &Context,
+ _graph: &Graph,
+ _run_dir: &Path,
+ _services: &EngineServices,
+ ) -> Result {
+ let script = node
+ .attrs
+ .get("script")
+ .or_else(|| node.attrs.get("tool_command"))
+ .and_then(|v| v.as_str())
+ .unwrap_or("");
+
+ let mut outcome = Outcome::simulated(&node.id);
+ outcome.notes = Some(format!("[Simulated] Command skipped: {script}"));
+ outcome
+ .context_updates
+ .insert(keys::COMMAND_OUTPUT.to_string(), serde_json::json!(""));
+ outcome
+ .context_updates
+ .insert(keys::COMMAND_STDERR.to_string(), serde_json::json!(""));
+ Ok(outcome)
+ }
+
async fn execute(
&self,
node: &Node,
@@ -46,18 +72,6 @@ impl Handler for CommandHandler {
return Ok(Outcome::fail_classify("No script specified"));
}
- if services.dry_run {
- let mut outcome = Outcome::success();
- outcome.notes = Some(format!("[Simulated] Command skipped: {script}"));
- outcome
- .context_updates
- .insert(keys::COMMAND_OUTPUT.to_string(), serde_json::json!(""));
- outcome
- .context_updates
- .insert(keys::COMMAND_STDERR.to_string(), serde_json::json!(""));
- return Ok(outcome);
- }
-
let language = node
.attrs
.get("language")
@@ -188,7 +202,36 @@ mod tests {
}
#[tokio::test]
- async fn dry_run_skips_execution() {
+ async fn simulate_skips_execution() {
+ let handler = CommandHandler;
+ let mut node = Node::new("script_node");
+ node.attrs.insert(
+ "script".to_string(),
+ AttrValue::String("echo hello".to_string()),
+ );
+ let context = Context::new();
+ let graph = Graph::new("test");
+ let run_dir = tempfile::tempdir().unwrap();
+
+ let outcome = handler
+ .simulate(&node, &context, &graph, run_dir.path(), &make_services())
+ .await
+ .unwrap();
+ assert_eq!(outcome.status, StageStatus::Success);
+ assert!(outcome.notes.as_deref().unwrap().contains("[Simulated]"));
+ assert!(outcome.notes.as_deref().unwrap().contains("echo hello"));
+ assert_eq!(
+ outcome.context_updates.get(keys::COMMAND_OUTPUT),
+ Some(&serde_json::json!(""))
+ );
+ assert_eq!(
+ outcome.context_updates.get(keys::COMMAND_STDERR),
+ Some(&serde_json::json!(""))
+ );
+ }
+
+ #[tokio::test]
+ async fn dispatch_routes_to_simulate_in_dry_run() {
let handler = CommandHandler;
let mut node = Node::new("script_node");
node.attrs.insert(
@@ -202,16 +245,18 @@ mod tests {
let mut services = make_services();
services.dry_run = true;
- let outcome = handler
- .execute(&node, &context, &graph, run_dir.path(), &services)
- .await
- .unwrap();
+ let outcome = crate::handler::dispatch_handler(
+ &handler,
+ &node,
+ &context,
+ &graph,
+ run_dir.path(),
+ &services,
+ )
+ .await
+ .unwrap();
assert_eq!(outcome.status, StageStatus::Success);
assert!(outcome.notes.as_deref().unwrap().contains("[Simulated]"));
- assert!(outcome.notes.as_deref().unwrap().contains("echo hello"));
- // No stdout/stderr logs should be written
- let stage_dir = run_dir.path().join("nodes").join("script_node");
- assert!(!stage_dir.join("stdout.log").exists());
}
#[tokio::test]
diff --git a/lib/crates/fabro-workflows/src/handler/fan_in.rs b/lib/crates/fabro-workflows/src/handler/fan_in.rs
index 6c8d96532..eb93d6e5b 100644
--- a/lib/crates/fabro-workflows/src/handler/fan_in.rs
+++ b/lib/crates/fabro-workflows/src/handler/fan_in.rs
@@ -28,6 +28,37 @@ impl FanInHandler {
#[async_trait]
impl Handler for FanInHandler {
+ async fn simulate(
+ &self,
+ node: &Node,
+ context: &Context,
+ _graph: &Graph,
+ _run_dir: &Path,
+ _services: &EngineServices,
+ ) -> Result {
+ let results = context.get(keys::PARALLEL_RESULTS);
+ let Some(results) = results else {
+ return Ok(Outcome::fail_deterministic(
+ "No parallel results to evaluate",
+ ));
+ };
+
+ let best = heuristic_select(&results);
+
+ let mut outcome = Outcome::simulated(&node.id);
+ outcome.context_updates.insert(
+ keys::PARALLEL_FAN_IN_BEST_ID.to_string(),
+ serde_json::json!(best.id),
+ );
+ outcome.context_updates.insert(
+ keys::PARALLEL_FAN_IN_BEST_OUTCOME.to_string(),
+ serde_json::json!(best.status),
+ );
+ // Override the generic simulated notes with handler-specific detail.
+ outcome.notes = Some(format!("[Simulated] Selected best candidate: {}", best.id));
+ Ok(outcome)
+ }
+
async fn execute(
&self,
node: &Node,
@@ -520,4 +551,31 @@ mod tests {
Some(&serde_json::json!("branch_b"))
);
}
+
+ #[tokio::test]
+ async fn fan_in_simulate_uses_heuristic() {
+ let handler = FanInHandler::new(None);
+ let node = Node::new("fan_in");
+ let context = Context::new();
+ context.set(
+ keys::PARALLEL_RESULTS,
+ serde_json::json!([
+ {"id": "branch_a", "status": "fail"},
+ {"id": "branch_b", "status": "success"},
+ ]),
+ );
+ let graph = Graph::new("test");
+ let run_dir = Path::new("/tmp/test");
+
+ let outcome = handler
+ .simulate(&node, &context, &graph, run_dir, &make_services())
+ .await
+ .unwrap();
+ assert_eq!(outcome.status, StageStatus::Success);
+ assert!(outcome.notes.as_deref().unwrap().contains("[Simulated]"));
+ assert_eq!(
+ outcome.context_updates.get(keys::PARALLEL_FAN_IN_BEST_ID),
+ Some(&serde_json::json!("branch_b"))
+ );
+ }
}
diff --git a/lib/crates/fabro-workflows/src/handler/human.rs b/lib/crates/fabro-workflows/src/handler/human.rs
index 5ed79b720..791287f88 100644
--- a/lib/crates/fabro-workflows/src/handler/human.rs
+++ b/lib/crates/fabro-workflows/src/handler/human.rs
@@ -96,6 +96,49 @@ impl HumanHandler {
#[async_trait]
impl Handler for HumanHandler {
+ async fn simulate(
+ &self,
+ node: &Node,
+ _context: &Context,
+ graph: &Graph,
+ _run_dir: &Path,
+ _services: &EngineServices,
+ ) -> Result {
+ let edges = graph.outgoing_edges(&node.id);
+ let first_choice = edges.iter().find(|e| !e.freeform());
+
+ if let Some(edge) = first_choice {
+ let label = edge.label().filter(|l| !l.is_empty()).unwrap_or(&edge.to);
+ let key = parse_accelerator_key(label);
+ let mut outcome = Outcome::simulated(&node.id);
+ outcome.preferred_label = Some(label.to_string());
+ outcome.suggested_next_ids = vec![edge.to.clone()];
+ outcome.context_updates.insert(
+ keys::HUMAN_GATE_SELECTED.to_string(),
+ serde_json::json!(key),
+ );
+ outcome
+ .context_updates
+ .insert(keys::HUMAN_GATE_LABEL.to_string(), serde_json::json!(label));
+ Ok(outcome)
+ } else if let Some(edge) = edges.first() {
+ // Only freeform edges — pick the first one
+ let mut outcome = Outcome::simulated(&node.id);
+ outcome.suggested_next_ids = vec![edge.to.clone()];
+ outcome.context_updates.insert(
+ keys::HUMAN_GATE_SELECTED.to_string(),
+ serde_json::json!("freeform"),
+ );
+ outcome.context_updates.insert(
+ keys::HUMAN_GATE_LABEL.to_string(),
+ serde_json::json!("[Simulated] auto-selected"),
+ );
+ Ok(outcome)
+ } else {
+ Ok(Outcome::simulated(&node.id))
+ }
+ }
+
async fn execute(
&self,
node: &Node,
@@ -466,4 +509,26 @@ mod tests {
assert_eq!(recordings.len(), 1);
assert_eq!(recordings[0].0.question_type, QuestionType::Freeform);
}
+
+ #[tokio::test]
+ async fn simulate_selects_first_choice() {
+ let interviewer = Arc::new(AutoApproveInterviewer);
+ let handler = HumanHandler::new(interviewer);
+ let graph = build_graph_with_human_gate();
+ let node = graph.nodes.get("gate").unwrap();
+ let context = Context::new();
+ let run_dir = Path::new("/tmp/test");
+
+ let outcome = handler
+ .simulate(node, &context, &graph, run_dir, &make_services())
+ .await
+ .unwrap();
+ assert_eq!(outcome.status, crate::outcome::StageStatus::Success);
+ assert!(outcome.notes.as_deref().unwrap().contains("[Simulated]"));
+ assert_eq!(
+ outcome.context_updates.get(keys::HUMAN_GATE_SELECTED),
+ Some(&serde_json::json!("A"))
+ );
+ assert_eq!(outcome.suggested_next_ids, vec!["approve"]);
+ }
}
diff --git a/lib/crates/fabro-workflows/src/handler/mod.rs b/lib/crates/fabro-workflows/src/handler/mod.rs
index a7115066f..29f0b30ac 100644
--- a/lib/crates/fabro-workflows/src/handler/mod.rs
+++ b/lib/crates/fabro-workflows/src/handler/mod.rs
@@ -82,6 +82,19 @@ pub trait Handler: Send + Sync {
services: &EngineServices,
) -> Result;
+ /// Produce a simulated result for dry-run mode.
+ /// Override for handlers that need custom context updates.
+ async fn simulate(
+ &self,
+ node: &Node,
+ _context: &Context,
+ _graph: &Graph,
+ _run_dir: &Path,
+ _services: &EngineServices,
+ ) -> Result {
+ Ok(Outcome::simulated(&node.id))
+ }
+
/// Determines whether an error should be retried.
/// Default implementation retries transient errors only.
fn should_retry(&self, err: &FabroError) -> bool {
@@ -89,6 +102,27 @@ pub trait Handler: Send + Sync {
}
}
+/// Route to [`Handler::simulate`] when `services.dry_run` is true, otherwise
+/// [`Handler::execute`].
+pub async fn dispatch_handler(
+ handler: &dyn Handler,
+ node: &Node,
+ context: &Context,
+ graph: &Graph,
+ run_dir: &Path,
+ services: &EngineServices,
+) -> Result {
+ if services.dry_run {
+ handler
+ .simulate(node, context, graph, run_dir, services)
+ .await
+ } else {
+ handler
+ .execute(node, context, graph, run_dir, services)
+ .await
+ }
+}
+
/// Maps handler type strings to handler implementations.
pub struct HandlerRegistry {
handlers: HashMap>,
@@ -313,4 +347,43 @@ mod tests {
let handler = registry.resolve(&node);
let _ = handler;
}
+
+ #[tokio::test]
+ async fn dispatch_handler_routes_to_simulate_when_dry_run() {
+ let handler = TestHandler {
+ _name: "test".to_string(),
+ };
+ let node = Node::new("my_node");
+ let context = Context::new();
+ let graph = Graph::new("test");
+ let run_dir = std::path::Path::new("/tmp/test");
+ let mut services = EngineServices::test_default();
+ services.dry_run = true;
+
+ let outcome = dispatch_handler(&handler, &node, &context, &graph, run_dir, &services)
+ .await
+ .unwrap();
+ assert_eq!(outcome.status, crate::outcome::StageStatus::Success);
+ assert_eq!(outcome.notes.as_deref(), Some("[Simulated] my_node"));
+ }
+
+ #[tokio::test]
+ async fn dispatch_handler_routes_to_execute_when_not_dry_run() {
+ let handler = TestHandler {
+ _name: "test".to_string(),
+ };
+ let node = Node::new("my_node");
+ let context = Context::new();
+ let graph = Graph::new("test");
+ let run_dir = std::path::Path::new("/tmp/test");
+ let mut services = EngineServices::test_default();
+ services.dry_run = false;
+
+ let outcome = dispatch_handler(&handler, &node, &context, &graph, run_dir, &services)
+ .await
+ .unwrap();
+ assert_eq!(outcome.status, crate::outcome::StageStatus::Success);
+ // execute() returns success with no notes
+ assert!(outcome.notes.is_none());
+ }
}
diff --git a/lib/crates/fabro-workflows/src/handler/parallel.rs b/lib/crates/fabro-workflows/src/handler/parallel.rs
index 486978bcb..41b83f9bb 100644
--- a/lib/crates/fabro-workflows/src/handler/parallel.rs
+++ b/lib/crates/fabro-workflows/src/handler/parallel.rs
@@ -205,6 +205,68 @@ struct BranchResult {
#[async_trait]
impl Handler for ParallelHandler {
+ async fn simulate(
+ &self,
+ node: &Node,
+ context: &Context,
+ graph: &Graph,
+ run_dir: &Path,
+ services: &EngineServices,
+ ) -> Result {
+ let branches = graph.outgoing_edges(&node.id);
+ if branches.is_empty() {
+ return Ok(Outcome::fail_classify("No branches for parallel node"));
+ }
+
+ // Dispatch each branch child via dispatch_handler (which will call simulate)
+ let mut branch_results: Vec = Vec::new();
+ for edge in &branches {
+ let target_id = &edge.to;
+ if let Some(target_node) = graph.nodes.get(target_id) {
+ let handler = services.registry.resolve(target_node);
+ let branch_context = context.clone_context();
+ let outcome = super::dispatch_handler(
+ handler,
+ target_node,
+ &branch_context,
+ graph,
+ run_dir,
+ services,
+ )
+ .await?;
+ branch_results.push(BranchResult {
+ id: target_id.clone(),
+ outcome,
+ head_sha: None,
+ worktree_path: None,
+ });
+ }
+ }
+
+ let total = branch_results.len();
+ context.set(keys::PARALLEL_BRANCH_COUNT, serde_json::json!(total));
+
+ let results_json: Vec = branch_results
+ .iter()
+ .map(|r| {
+ serde_json::json!({
+ "id": r.id,
+ "status": r.outcome.status.to_string(),
+ })
+ })
+ .collect();
+ context.set(keys::PARALLEL_RESULTS, serde_json::json!(results_json));
+
+ let join_node = find_join_node(&branch_results, graph);
+
+ let mut outcome = Outcome::simulated(&node.id);
+ outcome.notes = Some(format!(
+ "[Simulated] Parallel node dispatched {total} branches"
+ ));
+ outcome.jump_to_node = join_node;
+ Ok(outcome)
+ }
+
async fn execute(
&self,
node: &Node,
@@ -447,15 +509,15 @@ impl Handler for ParallelHandler {
dry_run,
};
let handler = registry.resolve(target_node);
- let outcome = handler
- .execute(
- target_node,
- &setup.branch_context,
- &graph,
- &run_dir,
- &branch_services,
- )
- .await?;
+ let outcome = super::dispatch_handler(
+ handler,
+ target_node,
+ &setup.branch_context,
+ &graph,
+ &run_dir,
+ &branch_services,
+ )
+ .await?;
// Checkpoint commit after branch execution (capture head_sha)
let head_sha = if has_git {
@@ -922,4 +984,48 @@ mod tests {
assert_eq!(parse_error_policy("ignore"), ErrorPolicy::Ignore);
assert_eq!(parse_error_policy("unknown"), ErrorPolicy::Continue);
}
+
+ #[tokio::test]
+ async fn parallel_handler_simulate() {
+ let services = make_services();
+ let mut node = Node::new("par");
+ node.attrs.insert(
+ "shape".to_string(),
+ AttrValue::String("component".to_string()),
+ );
+ let context = Context::new();
+ let mut graph = Graph::new("test");
+ graph.nodes.insert("par".to_string(), node.clone());
+ graph
+ .nodes
+ .insert("branch_a".to_string(), Node::new("branch_a"));
+ graph
+ .nodes
+ .insert("branch_b".to_string(), Node::new("branch_b"));
+ // Add a fan_in node reachable from both branches
+ graph
+ .nodes
+ .insert("fan_in".to_string(), Node::new("fan_in"));
+ graph.edges.push(Edge::new("par", "branch_a"));
+ graph.edges.push(Edge::new("par", "branch_b"));
+ graph.edges.push(Edge::new("branch_a", "fan_in"));
+ graph.edges.push(Edge::new("branch_b", "fan_in"));
+
+ let run_dir = Path::new("/tmp/test");
+ let mut dry_services = services;
+ dry_services.dry_run = true;
+
+ let outcome = ParallelHandler
+ .simulate(&node, &context, &graph, run_dir, &dry_services)
+ .await
+ .unwrap();
+
+ assert_eq!(outcome.status, StageStatus::Success);
+ assert!(outcome.notes.as_deref().unwrap().contains("[Simulated]"));
+ assert!(outcome.notes.as_deref().unwrap().contains("2 branches"));
+ assert_eq!(outcome.jump_to_node, Some("fan_in".to_string()));
+
+ let branch_count = context.get(keys::PARALLEL_BRANCH_COUNT);
+ assert_eq!(branch_count, Some(serde_json::json!(2)));
+ }
}
diff --git a/lib/crates/fabro-workflows/src/handler/prompt.rs b/lib/crates/fabro-workflows/src/handler/prompt.rs
index a0e5b7e95..1bfb27fe7 100644
--- a/lib/crates/fabro-workflows/src/handler/prompt.rs
+++ b/lib/crates/fabro-workflows/src/handler/prompt.rs
@@ -29,6 +29,17 @@ impl PromptHandler {
#[async_trait]
impl Handler for PromptHandler {
+ async fn simulate(
+ &self,
+ node: &Node,
+ _context: &Context,
+ _graph: &Graph,
+ _run_dir: &Path,
+ _services: &EngineServices,
+ ) -> Result {
+ Ok(super::agent::simulate_llm_handler(node))
+ }
+
async fn execute(
&self,
node: &Node,
@@ -156,31 +167,36 @@ mod tests {
}
#[tokio::test]
- async fn prompt_handler_simulation_mode() {
+ async fn prompt_handler_simulate() {
let handler = PromptHandler::new(None);
- let mut node = Node::new("classify");
- node.attrs.insert(
- "prompt".to_string(),
- AttrValue::String("Classify this".to_string()),
- );
+ let node = Node::new("classify");
let context = Context::new();
let graph = Graph::new("test");
let tmp = TempDir::new().unwrap();
let outcome = handler
- .execute(&node, &context, &graph, tmp.path(), &make_services())
+ .simulate(&node, &context, &graph, tmp.path(), &make_services())
.await
.unwrap();
assert_eq!(outcome.status, crate::outcome::StageStatus::Success);
-
- let response_content = std::fs::read_to_string(
- tmp.path()
- .join("nodes")
- .join("classify")
- .join("response.md"),
- )
- .unwrap();
- assert!(response_content.contains("[Simulated]"));
+ assert_eq!(outcome.notes.as_deref(), Some("[Simulated] classify"));
+ assert_eq!(
+ outcome
+ .context_updates
+ .get(crate::context::keys::LAST_STAGE),
+ Some(&serde_json::json!("classify"))
+ );
+ assert!(outcome
+ .context_updates
+ .contains_key(crate::context::keys::LAST_RESPONSE));
+ assert_eq!(
+ outcome
+ .context_updates
+ .get(&crate::context::keys::response_key("classify")),
+ Some(&serde_json::json!(
+ "[Simulated] Response for stage: classify"
+ ))
+ );
}
#[tokio::test]
diff --git a/lib/crates/fabro-workflows/src/outcome.rs b/lib/crates/fabro-workflows/src/outcome.rs
index 7677fe888..25804112c 100644
--- a/lib/crates/fabro-workflows/src/outcome.rs
+++ b/lib/crates/fabro-workflows/src/outcome.rs
@@ -215,6 +215,15 @@ impl Outcome {
}
}
+ /// Create a simulated success outcome for dry-run mode.
+ #[must_use]
+ pub fn simulated(node_id: &str) -> Self {
+ Self {
+ notes: Some(format!("[Simulated] {node_id}")),
+ ..Self::success()
+ }
+ }
+
/// Get the failure reason message, if any.
pub fn failure_reason(&self) -> Option<&str> {
self.failure.as_ref().map(|f| f.message.as_str())
@@ -440,4 +449,13 @@ mod tests {
let parsed: StageStatus = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, StageStatus::PartialSuccess);
}
+
+ #[test]
+ fn outcome_simulated_factory() {
+ let o = Outcome::simulated("my_node");
+ assert_eq!(o.status, StageStatus::Success);
+ assert_eq!(o.notes.as_deref(), Some("[Simulated] my_node"));
+ assert!(o.failure.is_none());
+ assert!(o.context_updates.is_empty());
+ }
}