fabro(01KY7WQ92JWT90307EBQY6P2HV): implement (succeeded)

Fabro-Run: 01KY7WQ92JWT90307EBQY6P2HV
Fabro-Completed: 5

⚒️ Generated with [Fabro](https://fabro.sh)
This commit is contained in:
Fabro 2026-07-23 17:16:41 +00:00
parent 79979c1733
commit f68187bfc6
9 changed files with 594 additions and 19 deletions

View file

@ -66,7 +66,7 @@ JSON objects without recognized fields are ignored.
### Validated routing output
Set `output_schema="routing"` on an agent or prompt node to require Fabro's built-in routing directive schema:
Set `output_schema="routing"` on an agent, prompt, or command node to require Fabro's built-in routing directive schema:
```dot
review [
@ -81,7 +81,9 @@ With `output_schema="routing"`, the routing JSON must be an object with at least
Validated routing uses the same reverse scan as normal routing extraction: Fabro validates the last parsable JSON object that contains a recognized routing field. If a routing object is present but malformed or has invalid field types, Fabro repairs that response instead of falling through to a file fallback.
Fabro repairs invalid structured output inside the same LLM context before failing the node. For prompt nodes, Fabro appends the invalid assistant response and a corrective user message to the same message list. For agent nodes using the API backend, Fabro sends the corrective message to the same live agent session. `output_retries` controls these repair turns and defaults to `2`; `output_retries=0` validates once and fails without a repair turn. Negative values are treated as `0`. These repair turns are separate from workflow `max_retries` and do not consume node retry attempts.
Fabro repairs invalid structured output inside the same LLM context before failing an agent or prompt node. For prompt nodes, Fabro appends the invalid assistant response and a corrective user message to the same message list. For agent nodes using the API backend, Fabro sends the corrective message to the same live agent session. `output_retries` controls these repair turns and defaults to `2`; `output_retries=0` validates once and fails without a repair turn. Negative values are treated as `0`. These repair turns are separate from workflow `max_retries` and do not consume node retry attempts.
Command nodes instead validate only after an exit-code-`0` script, using the last JSON object in merged stdout and stderr. Print the intended JSON object last. Invalid output fails deterministically without a repair turn, command retry, or `status.json` fallback; `output_retries`, `retry_policy`, and `max_retries` do not retry the validation failure. Nonzero exits keep their normal command failure behavior without schema validation.
### Routing fallback sources
@ -95,7 +97,7 @@ Agent nodes can provide routing directives through fallback files. Fabro checks
This fallback chain applies to normal routing extraction and to `output_schema="routing"`. For validated routing, Fabro only advances to the next source when the current source has no JSON object or no object with recognized routing fields. If the current source contains malformed routing JSON or valid JSON with wrong routing field types, validation fails and Fabro starts the repair loop instead.
Prompt nodes do not use file fallbacks; they validate or extract routing directives from the response text only.
Prompt nodes do not use file fallbacks; they validate or extract routing directives from the response text only. Command nodes likewise have no file fallback and validate only their merged stdout and stderr.
If no source provides routing directives, the transition falls through to condition matching, unconditional edges, or weight-based tiebreaking as described in [Transitions](/workflows/transitions).
@ -119,7 +121,7 @@ review -> approve [label="Approve"]
## Custom structured outputs
Agent and prompt nodes can also validate their final JSON object against a JSON Schema file:
Agent, prompt, and command nodes can also validate their final JSON object against a JSON Schema file:
```dot
audit [
@ -130,9 +132,9 @@ audit [
]
```
`output_schema="@path/to/schema.json"` uses the same workflow file-reference rules as prompt files: the schema is loaded relative to the workflow file and inlined before execution. The final JSON object in the LLM response is validated with `jsonschema`.
`output_schema="@path/to/schema.json"` uses the same workflow file-reference rules as prompt files: the schema is loaded relative to the workflow file and inlined before execution. The final JSON object in the LLM response, or in a successful command's merged stdout and stderr, is validated with `jsonschema`.
Custom schema validation only reads the response text. It does not fall back to `status.json` or the last file touched by the agent.
Custom schema validation only reads response or command output text. It does not fall back to `status.json` or the last file touched by the agent.
When custom schema validation succeeds, Fabro stores the parsed JSON value in context at:
@ -140,12 +142,12 @@ When custom schema validation succeeds, Fabro stores the parsed JSON value in co
|---|---|
| `output.{node_id}` | The parsed JSON object that matched the custom schema |
For example, node `audit` writes its parsed custom output to `output.audit`. Fabro still stores the raw response text at `response.audit`.
For example, node `audit` writes its parsed custom output to `output.audit`. Fabro still stores raw LLM response text at `response.audit`; command output remains available through `command.output`.
If custom schema validation fails, Fabro sends concise validation feedback to the same prompt conversation or agent session and asks for corrected JSON. After `output_retries` repair turns are exhausted, the node fails terminally with `output schema validation failed after N repair attempt(s)`.
If custom schema validation fails for an agent or prompt node, Fabro sends concise validation feedback to the same prompt conversation or agent session and asks for corrected JSON. After `output_retries` repair turns are exhausted, the node fails terminally with `output schema validation failed after N repair attempt(s)`. A command node instead fails deterministically after its first validation, without a repair turn or command retry.
<Note>
Structured output validation currently applies to agent and prompt nodes. `backend="acp"` does not support `output_schema` in this release. Custom schemas update `output.{node_id}`; routing schemas update routing fields and `context_updates` instead.
Structured output validation applies to agent, prompt, and command nodes. `backend="acp"` does not support `output_schema` in this release. Custom schemas update `output.{node_id}`; routing schemas update routing fields and merge `context_updates` as flat context keys that edge conditions can read. Edge conditions cannot traverse the custom `output.{node_id}` object.
</Note>
## Output logging

View file

@ -206,7 +206,7 @@ Start nodes can also be identified by ID (`start` or `Start`). Exit nodes can be
| `model` | String | Explicit model ID (overrides stylesheet) |
| `provider` | String | Explicit provider name (overrides stylesheet). Auto-inferred from the model catalog when omitted. |
| `project_memory` | Boolean | When `true` (default), prompt nodes discover and include project docs (`AGENTS.md`, `CLAUDE.md`, etc.) as a system prompt. Set to `false` to disable. |
| `output_schema` | String | Optional structured output validation. Use `routing` for Fabro's built-in routing directive schema, `@path/to/schema.json` for a JSON Schema file, or an inline JSON Schema object string. Supported on agent and prompt nodes. |
| `output_schema` | String | Optional structured output validation. Use `routing` for Fabro's built-in routing directive schema, `@path/to/schema.json` for a JSON Schema file, or an inline JSON Schema object string. Supported on agent, prompt, and command nodes. |
| `output_retries` | Integer | Corrective structured-output turns inside the same prompt conversation or agent session. Default `2`; `0` validates once and fails without repair; negative values are treated as `0`. Separate from `max_retries`. |
| `backend` | String | Agent execution backend: `api` (default) or `acp`. `api` runs Fabro's tool loop through provider APIs; `acp` runs an Agent Client Protocol stdio agent inside the active sandbox. Prompt nodes are API-only. See [Agents — Backends](/core-concepts/agents#backends). |
| `acp.command` | String | Shell command for nodes with `backend="acp"`. Mutually exclusive with `acp.config`. The value is always parsed as a command string, not JSON. |
@ -214,7 +214,7 @@ Start nodes can also be identified by ID (`start` or `Start`). Exit nodes can be
#### Structured output validation
`output_schema` opts an agent or prompt node into strict JSON validation:
`output_schema` opts an agent, prompt, or command node into strict JSON validation:
```dot
review [
@ -236,6 +236,8 @@ audit [
- `output_retries` defaults to `2` and controls only these corrective structured-output turns. Negative values are treated as `0`. It is not the same as `max_retries` and does not consume workflow retry attempts.
- Custom schema output is stored in context at `output.{node_id}`. Routing schema output updates routing fields and any `context_updates`.
- Agent routing fallbacks still apply to `output_schema="routing"`: response text first, then `status.json`, then the last file touched by the agent. Custom schemas and prompt nodes validate response text only.
- Command nodes validate the last JSON object in merged stdout and stderr only after the script exits with code `0`; print the intended JSON object last. A validation error is a deterministic, non-retryable failure with no repair turn or `status.json` fallback. `output_retries`, `retry_policy`, and `max_retries` do not retry it. Nonzero exits retain normal command failure behavior without schema validation.
- For commands, custom schema output is stored at `output.{node_id}` and is not directly addressable by edge conditions. The `routing` schema applies routing fields and merges `context_updates` into flat context keys, which conditions can read (for example, `context.kept_count`).
- `backend="acp"` with `output_schema` is unsupported in this release.
### Command nodes
@ -244,6 +246,7 @@ audit [
|---|---|---|
| `script` | String | Shell command to execute |
| `language` | String | `"shell"` (default) or `"python"` |
| `output_schema` | String | Optional structured output validation. Accepts `routing`, `@path/to/schema.json`, or an inline JSON Schema object string. See [Structured output validation](#structured-output-validation). |
### Parallel (fan-out) nodes

View file

@ -0,0 +1,34 @@
use fabro_test::test_context;
use super::{completed_nodes, find_run_dir, fixture, read_conclusion, sandbox_tests, timeout_for};
sandbox_tests!(command_routing);
fn scenario_command_routing(sandbox: &str) {
let context = test_context!();
let workflow = fixture("command_routing.fabro");
context.validate().arg(&workflow).assert().success();
context
.run_cmd()
.args(["--auto-approve", "--environment", sandbox])
.arg(workflow)
.timeout(timeout_for(sandbox))
.assert()
.success();
let run_dir = find_run_dir(&context);
let conclusion = read_conclusion(&run_dir);
assert_eq!(conclusion["status"].as_str(), Some("succeeded"));
let nodes = completed_nodes(&run_dir);
assert!(
nodes.contains(&"kept".to_string()),
"kept node should be in completed_nodes: {nodes:?}"
);
assert!(
!nodes.contains(&"none".to_string()),
"none node should NOT be in completed_nodes: {nodes:?}"
);
}

View file

@ -0,0 +1,23 @@
digraph CommandRouting {
graph [goal="Route on structured output from a command"]
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
classify [
label="Classify",
shape=parallelogram,
script="echo '{\"context_updates\":{\"kept_count\":2}}'",
output_schema="routing"
]
gate [label="Check Count", shape=diamond]
kept [label="Kept", shape=parallelogram, script="echo kept"]
none [label="None", shape=parallelogram, script="echo none"]
start -> classify -> gate
gate -> kept [label="Kept", condition="context.kept_count > 0"]
gate -> none [label="None"]
kept -> exit
none -> exit
}

View file

@ -8,6 +8,7 @@ mod agent_linear;
mod artifacts;
mod command_agent_mixed;
mod command_pipeline;
mod command_routing;
mod conditional_branching;
mod dry_run_examples;
mod full_stack;

View file

@ -20,7 +20,7 @@ const HANDLER_SPECIFIC_ATTRS: &[(&str, &[&str])] = &[
("duration", &["wait"]),
("join_policy", &["parallel"]),
("max_parallel", &["parallel"]),
("output_schema", &["agent", "prompt"]),
("output_schema", &["agent", "prompt", "command"]),
("prompt", &["agent", "prompt", "parallel.fan_in"]),
];
@ -152,15 +152,13 @@ mod tests {
}
#[test]
fn warns_on_output_schema_on_command_node() {
fn accepts_output_schema_on_command_node() {
let mut g = minimal_graph();
g.nodes.insert(
"run".to_string(),
node_with_attr("run", "parallelogram", "output_schema", "routing"),
);
let d = Rule.apply(&g);
assert_eq!(d.len(), 1);
assert!(d[0].message.contains("'output_schema'"));
assert!(Rule.apply(&g).is_empty());
}
#[test]

View file

@ -6,6 +6,7 @@ use fabro_graphviz::graph::{Graph, Node};
use fabro_types::{CommandTermination, StageTiming};
use fabro_util::shell::shell_quote;
use super::structured_output::{self, StructuredOutputError};
use super::{EngineServices, Handler, NodeTimeoutPolicy};
use crate::command_log::CommandLogRecorder;
use crate::context::{Context, keys};
@ -76,6 +77,8 @@ impl Handler for CommandHandler {
)));
}
let output_schema = structured_output::parse_node_output_schema(node)?;
let command = if language == "python" {
format!("python3 -c {}", shell_quote(script))
} else {
@ -172,6 +175,34 @@ impl Handler for CommandHandler {
);
outcome.notes = Some(format!("Script completed: {script}"));
outcome.timing = Some(StageTiming::active_only(0, result.duration_ms));
if let Some(schema) = output_schema.as_ref() {
match structured_output::validate_response_text(schema, &finalized.output_text) {
Ok(validated) => {
structured_output::apply_validated_output(
node,
schema,
&validated,
&mut outcome,
);
}
Err(error) => {
let reason = schema_validation_failure_reason(
script,
&error,
&finalized.output_text,
);
let mut failed = Outcome::fail_deterministic(reason);
failed.context_updates.insert(
keys::COMMAND_OUTPUT.to_string(),
serde_json::json!(finalized.output_ref),
);
failed.timing = Some(StageTiming::active_only(0, result.duration_ms));
return Ok(failed);
}
}
}
Ok(outcome)
} else {
let mut reason = format!(
@ -194,6 +225,20 @@ impl Handler for CommandHandler {
}
}
fn schema_validation_failure_reason(
script: &str,
error: &StructuredOutputError,
output_text: &str,
) -> String {
let mut reason = format!("Script output failed output_schema validation: {script}");
for message in error.messages() {
reason.push_str("\n- ");
reason.push_str(message);
}
append_output_tail(&mut reason, output_text);
reason
}
fn append_output_tail(reason: &mut String, output: &str) {
let output_tail = tail_bytes(output, 4096);
if !output_tail.trim().is_empty() {
@ -227,9 +272,12 @@ mod tests {
use super::*;
use crate::command_log::command_log_path;
use crate::outcome::StageOutcome;
use crate::outcome::{FailureCategory, StageOutcome};
use crate::runtime_store::{RunStoreBackend, RunStoreHandle};
const PASSED_OUTPUT_SCHEMA: &str =
r#"{"type":"object","required":["passed"],"properties":{"passed":{"type":"boolean"}}}"#;
#[derive(Default)]
struct MemoryRunStoreBackend {
blobs: Mutex<std::collections::HashMap<fabro_types::RunBlobId, Bytes>>,
@ -465,6 +513,385 @@ mod tests {
assert!(!outcome.context_updates.contains_key("command.stderr"));
}
#[tokio::test]
async fn command_custom_output_schema_stores_output_context_key() {
let handler = CommandHandler;
let mut node = Node::new("audit");
node.attrs.insert(
"script".to_string(),
AttrValue::String(r#"echo '{"passed": true}'"#.to_string()),
);
node.attrs.insert(
"output_schema".to_string(),
AttrValue::String(PASSED_OUTPUT_SCHEMA.to_string()),
);
let context = Context::new();
let graph = Graph::new("test");
let run_dir = tempfile::tempdir().unwrap();
let services = make_services();
let outcome = handler
.execute(&node, &context, &graph, run_dir.path(), &services)
.await
.unwrap();
assert_eq!(outcome.status, StageOutcome::Succeeded);
assert_eq!(
outcome.context_updates.get("output.audit"),
Some(&serde_json::json!({"passed": true})),
);
let command_output = outcome
.context_updates
.get(keys::COMMAND_OUTPUT)
.expect("command.output should still be set");
assert!(
command_text(&services, command_output)
.await
.contains(r#"{"passed": true}"#)
);
}
#[tokio::test]
async fn command_custom_output_schema_validates_last_json_object() {
let handler = CommandHandler;
let mut node = Node::new("audit");
node.attrs.insert(
"script".to_string(),
AttrValue::String(
r#"printf '%s\n' 'starting audit' '{"passed": false}' 'final result:' '{"passed": true}'"#
.to_string(),
),
);
node.attrs.insert(
"output_schema".to_string(),
AttrValue::String(PASSED_OUTPUT_SCHEMA.to_string()),
);
let context = Context::new();
let graph = Graph::new("test");
let run_dir = tempfile::tempdir().unwrap();
let outcome = handler
.execute(&node, &context, &graph, run_dir.path(), &make_services())
.await
.unwrap();
assert_eq!(outcome.status, StageOutcome::Succeeded);
assert_eq!(
outcome.context_updates.get("output.audit"),
Some(&serde_json::json!({"passed": true})),
);
}
#[tokio::test]
async fn command_custom_output_schema_failure_is_deterministic() {
let handler = CommandHandler;
let mut node = Node::new("audit");
node.attrs.insert(
"script".to_string(),
AttrValue::String(r#"echo '{"passed":"yes"}'"#.to_string()),
);
node.attrs.insert(
"output_schema".to_string(),
AttrValue::String(PASSED_OUTPUT_SCHEMA.to_string()),
);
node.attrs
.insert("output_retries".to_string(), AttrValue::Integer(7));
let context = Context::new();
let graph = Graph::new("test");
let run_dir = tempfile::tempdir().unwrap();
let services = make_services();
let outcome = handler
.execute(&node, &context, &graph, run_dir.path(), &services)
.await
.unwrap();
assert_eq!(outcome.status, StageOutcome::Failed {
retry_requested: false,
});
assert_eq!(
outcome.failure_category(),
Some(FailureCategory::Deterministic)
);
let reason = outcome
.failure_reason()
.expect("schema validation failure should have a reason");
assert!(
reason.contains("Script output failed output_schema validation: echo"),
"unexpected failure reason: {reason}"
);
assert!(
reason.contains("boolean"),
"validator message should be included: {reason}"
);
assert!(
reason.contains("## output"),
"output heading missing: {reason}"
);
assert!(
reason.contains(r#"{"passed":"yes"}"#),
"output tail missing: {reason}"
);
assert!(
!reason.contains("repair attempt"),
"commands must not claim repair attempts: {reason}"
);
assert!(
outcome.context_updates.contains_key(keys::COMMAND_OUTPUT),
"command.output should be set on validation failure"
);
assert!(outcome.timing.is_some());
assert_eq!(outcome.notes, None);
}
#[tokio::test]
async fn command_routing_output_schema_no_json_object_fails() {
let handler = CommandHandler;
let mut node = Node::new("route");
node.attrs.insert(
"script".to_string(),
AttrValue::String("echo not-json".to_string()),
);
node.attrs.insert(
"output_schema".to_string(),
AttrValue::String("routing".to_string()),
);
let context = Context::new();
let graph = Graph::new("test");
let run_dir = tempfile::tempdir().unwrap();
let outcome = handler
.execute(&node, &context, &graph, run_dir.path(), &make_services())
.await
.unwrap();
assert_eq!(outcome.status, StageOutcome::Failed {
retry_requested: false,
});
assert_eq!(
outcome.failure_category(),
Some(FailureCategory::Deterministic)
);
let reason = outcome.failure_reason().unwrap();
assert!(reason.contains("no JSON object found"), "got: {reason}");
assert!(reason.contains("## output\nnot-json"), "got: {reason}");
}
#[tokio::test]
async fn command_routing_output_schema_applies_routing_fields() {
let handler = CommandHandler;
let mut node = Node::new("route");
node.attrs.insert(
"script".to_string(),
AttrValue::String(
r#"echo '{"preferred_next_label":"fix","context_updates":{"kept_count":2}}'"#
.to_string(),
),
);
node.attrs.insert(
"output_schema".to_string(),
AttrValue::String("routing".to_string()),
);
let context = Context::new();
let graph = Graph::new("test");
let run_dir = tempfile::tempdir().unwrap();
let outcome = handler
.execute(&node, &context, &graph, run_dir.path(), &make_services())
.await
.unwrap();
assert_eq!(outcome.status, StageOutcome::Succeeded);
assert_eq!(outcome.preferred_label.as_deref(), Some("fix"));
assert_eq!(
outcome.context_updates.get("kept_count"),
Some(&serde_json::json!(2))
);
assert!(outcome.context_updates.contains_key(keys::COMMAND_OUTPUT));
}
#[tokio::test]
async fn command_routing_output_schema_outcome_failed_override() {
let handler = CommandHandler;
let mut node = Node::new("route");
node.attrs.insert(
"script".to_string(),
AttrValue::String(
r#"echo '{"outcome":"failed","failure_reason":"tests failed"}'"#.to_string(),
),
);
node.attrs.insert(
"output_schema".to_string(),
AttrValue::String("routing".to_string()),
);
let context = Context::new();
let graph = Graph::new("test");
let run_dir = tempfile::tempdir().unwrap();
let outcome = handler
.execute(&node, &context, &graph, run_dir.path(), &make_services())
.await
.unwrap();
assert_eq!(outcome.status, StageOutcome::Failed {
retry_requested: false,
});
assert_eq!(outcome.failure_reason(), Some("tests failed"));
assert_eq!(
outcome.failure_category(),
Some(FailureCategory::Deterministic)
);
}
#[tokio::test]
async fn command_invalid_output_schema_fails_before_execution() {
let spy = std::sync::Arc::new(SpySandbox::new(fabro_agent::sandbox::ExecResult {
stdout: String::new(),
stderr: String::new(),
exit_code: Some(0),
termination: CommandTermination::Exited,
duration_ms: 1,
}));
let handler = CommandHandler;
let mut node = Node::new("audit");
node.attrs.insert(
"script".to_string(),
AttrValue::String("echo should-not-run".to_string()),
);
node.attrs.insert(
"output_schema".to_string(),
AttrValue::String("{".to_string()),
);
let context = Context::new();
let graph = Graph::new("test");
let run_dir = tempfile::tempdir().unwrap();
let mut services = make_spy_services(spy.clone());
let event_names = Arc::new(std::sync::Mutex::new(Vec::new()));
let captured_event_names = Arc::clone(&event_names);
let emitter = Arc::new(crate::event::Emitter::new(fixtures::RUN_1));
emitter.on_event(move |event| {
captured_event_names
.lock()
.unwrap()
.push(event.event_name().to_string());
});
services.run = services.run.with_emitter(emitter);
let error = handler
.execute(&node, &context, &graph, run_dir.path(), &services)
.await
.unwrap_err();
assert!(
error.to_string().contains("Invalid output_schema"),
"unexpected error: {error}"
);
assert_eq!(
spy.captured_command(),
None,
"invalid schema must fail before sandbox execution"
);
assert!(
event_names.lock().unwrap().is_empty(),
"invalid schema must fail before event emission"
);
}
#[tokio::test]
async fn command_nonzero_exit_skips_schema_validation() {
let handler = CommandHandler;
let mut node = Node::new("audit");
node.attrs.insert(
"script".to_string(),
AttrValue::String(r#"echo '{"passed":"bad"}'; exit 1"#.to_string()),
);
node.attrs.insert(
"output_schema".to_string(),
AttrValue::String(PASSED_OUTPUT_SCHEMA.to_string()),
);
let context = Context::new();
let graph = Graph::new("test");
let run_dir = tempfile::tempdir().unwrap();
let outcome = handler
.execute(&node, &context, &graph, run_dir.path(), &make_services())
.await
.unwrap();
assert_eq!(outcome.status, StageOutcome::Failed {
retry_requested: false,
});
let reason = outcome.failure_reason().unwrap();
assert!(reason.contains("exit code: 1"), "got: {reason}");
assert!(
!reason.contains("output_schema validation"),
"nonzero exits must skip schema validation: {reason}"
);
}
#[tokio::test]
async fn command_simulate_ignores_output_schema() {
let handler = CommandHandler;
let mut node = Node::new("audit");
node.attrs.insert(
"script".to_string(),
AttrValue::String("echo should-not-run".to_string()),
);
node.attrs.insert(
"output_schema".to_string(),
AttrValue::String("{not a valid schema".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, StageOutcome::Succeeded);
assert!(outcome.notes.as_deref().unwrap().contains("[Simulated]"));
assert_eq!(
outcome.context_updates.get(keys::COMMAND_OUTPUT),
Some(&serde_json::json!(""))
);
assert!(!outcome.context_updates.contains_key("output.audit"));
}
#[tokio::test]
async fn command_python_custom_output_schema() {
let handler = CommandHandler;
let mut node = Node::new("audit");
node.attrs.insert(
"script".to_string(),
AttrValue::String(r#"import json; print(json.dumps({"passed": True}))"#.to_string()),
);
node.attrs.insert(
"language".to_string(),
AttrValue::String("python".to_string()),
);
node.attrs.insert(
"output_schema".to_string(),
AttrValue::String(PASSED_OUTPUT_SCHEMA.to_string()),
);
let context = Context::new();
let graph = Graph::new("test");
let run_dir = tempfile::tempdir().unwrap();
let outcome = handler
.execute(&node, &context, &graph, run_dir.path(), &make_services())
.await
.unwrap();
assert_eq!(outcome.status, StageOutcome::Succeeded);
assert_eq!(
outcome.context_updates.get("output.audit"),
Some(&serde_json::json!({"passed": true})),
);
assert!(outcome.context_updates.contains_key(keys::COMMAND_OUTPUT));
}
#[tokio::test]
async fn script_handler_reports_command_duration_as_tool_timing() {
let handler = CommandHandler;

View file

@ -72,7 +72,6 @@ impl StructuredOutputError {
self.kind
}
#[cfg(test)]
#[must_use]
pub(crate) fn messages(&self) -> &[String] {
&self.messages

View file

@ -33,7 +33,7 @@ use fabro_interview::{
use fabro_model::catalog::{LlmCatalogSettings, ProviderCatalogSettings};
use fabro_model::{Catalog, ProviderId};
use fabro_store::{ArtifactKey, ArtifactStore, Database};
use fabro_types::{RunEvent, RunId, StageId, WorkflowSettings, parse_blob_ref};
use fabro_types::{EventBody, RunEvent, RunId, StageId, WorkflowSettings, parse_blob_ref};
use fabro_validate::{Severity, validate, validate_or_raise};
use fabro_workflow::context::Context;
use fabro_workflow::error::{Error, FailureSignatureExt};
@ -1931,6 +1931,94 @@ fn make_graph_with_start_exit(name: &str) -> Graph {
graph
}
#[tokio::test]
async fn command_schema_validation_failure_does_not_consume_retries() {
let mut graph = make_graph_with_start_exit("CommandSchemaNoRetry");
let mut audit = Node::new("audit");
audit.attrs.insert(
"shape".to_string(),
AttrValue::String("parallelogram".to_string()),
);
audit.attrs.insert(
"script".to_string(),
AttrValue::String(r#"echo '{"passed":"yes"}'"#.to_string()),
);
audit.attrs.insert(
"output_schema".to_string(),
AttrValue::String(
r#"{"type":"object","required":["passed"],"properties":{"passed":{"type":"boolean"}}}"#
.to_string(),
),
);
audit
.attrs
.insert("max_retries".to_string(), AttrValue::Integer(2));
graph.nodes.insert("audit".to_string(), audit);
graph.edges.push(Edge::new("start", "audit"));
let emitter = Emitter::default();
let events = collect_events(&emitter);
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));
let engine = WorkflowRunner::new(registry, Arc::new(emitter), local_env());
let run_options = RunOptions {
settings: WorkflowSettings::default(),
run_dir: dir.path().to_path_buf(),
cancel_token: CancellationToken::new(),
run_id: test_run_id("command-schema-no-retry"),
labels: std::collections::HashMap::new(),
workflow_slug: None,
github_app: None,
base_branch: None,
display_base_sha: None,
pre_run_git: None,
fork_source_ref: None,
git: None,
};
let (outcome, state) = engine
.run_with_state(&graph, &run_options)
.await
.expect("deterministic command failure should remain a workflow outcome");
assert_eq!(outcome.status, StageOutcome::Failed {
retry_requested: false,
});
assert_eq!(
outcome.failure_category(),
Some(fabro_workflow::outcome::FailureCategory::Deterministic)
);
let checkpoint = state
.current_checkpoint()
.expect("checkpoint should be captured");
let audit_outcome = checkpoint
.node_outcomes
.get("audit")
.expect("audit outcome should be checkpointed");
assert_eq!(audit_outcome.status, StageOutcome::Failed {
retry_requested: false,
});
assert_eq!(
audit_outcome.failure_category(),
Some(fabro_workflow::outcome::FailureCategory::Deterministic)
);
assert_eq!(
checkpoint.node_retries.get("audit").copied().unwrap_or(0),
0,
"schema validation should not consume node retries"
);
let command_starts = events
.lock()
.unwrap()
.iter()
.filter(|event| matches!(event.body, EventBody::CommandStarted(_)))
.count();
assert_eq!(command_starts, 1, "command should execute exactly once");
}
#[tokio::test]
async fn smoke_test_with_mock_codergen_backend() {
// Pipeline: