Unified dry-run via Handler::simulate() (#14)

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

<details>
<summary>Ran 7 stages in 24m 57s for $4.72</summary>

| 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** |

</details>

<details>
<summary>Ran <code>ImplementAndSimplify.fabro</code> (10 nodes and 13
edges)</summary>

```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
}

```

</details>

⚒️ Generated with [Fabro](https://fabro.sh)

---------

Co-authored-by: Fabro <noreply@fabro.sh>
Co-authored-by: Fabro Assistant <assistant@fabro.dev>
Co-authored-by: Bryan Helmkamp <bryan@brynary.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
brynary-fabro[bot] 2026-03-15 19:55:03 -04:00 committed by GitHub
parent 7ce7a842cc
commit da3610af59
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 478 additions and 69 deletions

View file

@ -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 {

View file

@ -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<Outcome, FabroError> {
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]

View file

@ -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<Outcome, FabroError> {
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]

View file

@ -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<Outcome, FabroError> {
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"))
);
}
}

View file

@ -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<Outcome, FabroError> {
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"]);
}
}

View file

@ -82,6 +82,19 @@ pub trait Handler: Send + Sync {
services: &EngineServices,
) -> Result<Outcome, FabroError>;
/// 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<Outcome, FabroError> {
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<Outcome, FabroError> {
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<String, Box<dyn Handler>>,
@ -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());
}
}

View file

@ -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<Outcome, FabroError> {
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<BranchResult> = 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<serde_json::Value> = 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)));
}
}

View file

@ -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<Outcome, FabroError> {
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]

View file

@ -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());
}
}