Merge pull request #656 from fabro-sh/feat/infer-command-node-from-script

feat(workflow): infer command nodes from the script attribute
This commit is contained in:
Bryan Helmkamp 2026-07-29 11:06:39 -04:00 committed by GitHub
commit 8a985c2d54
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 523 additions and 138 deletions

View file

@ -174,6 +174,36 @@ describe("parseFabro", () => {
expect(draft.nodes.find((n) => n.id === "plain")?.shape).toBe("box");
});
test("shapeless script node keeps command behavior after round trip", () => {
const draft = expectOk(
parseFabro(`digraph G {
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
build [script="cargo build"]
start -> build -> exit
}`),
);
expect(draft.nodes.find((n) => n.id === "build")?.shape).toBe(
"parallelogram",
);
const reparsed = expectOk(parseFabro(renderFabro(draft)));
expect(reparsed.nodes.find((n) => n.id === "build")?.shape).toBe(
"parallelogram",
);
});
test("explicit type disables command shape inference", () => {
const draft = expectOk(
parseFabro(`digraph G {
work [type=agent, script="cargo build"]
}`),
);
expect(draft.nodes.find((n) => n.id === "work")?.shape).toBe("box");
});
test("unknown shape falls back to box", () => {
const draft = expectOk(
parseFabro(`digraph G {

View file

@ -147,7 +147,7 @@ export function parseFabro(src: string): ParseResult {
}
function buildNode(id: string, attrs: Record<string, AttrValue>): Node {
const shape = coerceShape(attrs.shape, id);
const shape = coerceShape(attrs, id);
const label = typeof attrs.label === "string" ? attrs.label : id;
const node: Node = { id, label, shape };
if (typeof attrs.prompt === "string") node.prompt = attrs.prompt;
@ -159,10 +159,18 @@ function buildNode(id: string, attrs: Record<string, AttrValue>): Node {
return node;
}
function coerceShape(raw: AttrValue | undefined, nodeId: string): Shape {
function coerceShape(
attrs: Record<string, AttrValue>,
nodeId: string,
): Shape {
const raw = attrs.shape;
if (typeof raw !== "string") {
// Shape omitted: default to start/exit terminals if id matches,
// otherwise `box` (Fabro's agent default).
// Without an explicit shape or type, `script` selects the command
// handler. Keep the inferred shape when the draft is rendered again.
if (typeof attrs.type !== "string" && attrs.script !== undefined) {
return "parallelogram";
}
// The playground also infers terminal shapes from reserved ids.
if (nodeId === "start") return "mdiamond";
if (nodeId === "exit") return "msquare";
return "box";

View file

@ -153,7 +153,7 @@ Node and edge defaults declared inside a subgraph are scoped — they don't leak
## Node types
Each node's `shape` attribute determines its execution behavior. See [Nodes & Stages](/workflows/stages-and-nodes) for detailed documentation of each type.
Fabro uses an explicit `type` attribute to select a node's handler. Without `type`, the `shape` attribute selects the handler. See [Nodes & Stages](/workflows/stages-and-nodes) for detailed documentation of each type.
| Shape | Handler | Purpose |
|---|---|---|
@ -161,7 +161,7 @@ Each node's `shape` attribute determines its execution behavior. See [Nodes & St
| `Msquare` | exit | Workflow terminal (exactly one required) |
| `box` (default) | agent | Multi-turn LLM with tool access |
| `tab` | prompt | Single LLM call, no tools |
| `parallelogram` | command | Execute a shell script |
| `parallelogram` (inferred from `script` when `shape` and `type` are omitted) | command | Execute a shell script |
| `hexagon` | human | Human-in-the-loop decision gate |
| `diamond` | conditional | Route based on conditions |
| `component` | parallel | Fan-out to concurrent branches |
@ -169,10 +169,23 @@ Each node's `shape` attribute determines its execution behavior. See [Nodes & St
| `insulator` | wait | Pause for a duration |
| `house` | stack.manager_loop | Sub-workflow orchestration |
The `type` attribute can also be set explicitly to override the shape-based mapping.
The `type` attribute can be set explicitly to override the shape-based mapping and attribute inference.
Start nodes can also be identified by ID (`start` or `Start`). Exit nodes can be identified by ID (`exit`, `Exit`, `end`, or `End`).
### Omitting the shape
The two most common node types don't need a `shape` or `type`. When both are omitted, a node that sets `script` is a command node. Every other node defaults to an agent node:
```dot
plan [prompt="Plan the work"] // agent
build [script="cargo build"] // command
```
An explicit `shape` or `type` always wins. For example, `shape=box` with a `script` is still an agent node, and the `script` has no effect. Setting both `script` and `prompt` on one node is an error because command handlers consume `script`, while LLM handlers consume `prompt`.
Other node types still need their shape, because their attributes don't identify them uniquely. In particular, agent and prompt nodes take the same attributes, so a prompt node needs `shape=tab`.
## Node attributes
### All nodes
@ -244,7 +257,7 @@ audit [
| Attribute | Type | Description |
|---|---|---|
| `script` | String | Shell command to execute |
| `script` | String | Shell command to execute (required). Its presence infers a command node when `shape` and `type` are omitted. |
| `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). |

View file

@ -11,7 +11,7 @@ This distinction matters for observability and debugging: the workflow graph sho
## Node types
Every node's Graphviz `shape` attribute determines its execution behavior. If no shape is specified, the node defaults to an agent.
An explicit `type` attribute selects a node's execution behavior. Otherwise, its Graphviz `shape` selects the behavior. When both are omitted, a node with `script` is a command node and every other node defaults to an agent.
### Start
@ -35,7 +35,7 @@ exit [shape=Msquare, label="Exit"]
### Agent
**Shape:** `box` (default)
**Shape:** `box` (default when `shape`, `type`, and `script` are omitted)
Runs an LLM with access to tools — bash, file editing, sub-agents — in an agentic loop. The agent works autonomously, calling tools as needed, until it decides the task is complete.
@ -94,17 +94,19 @@ Prompt nodes accept the same attributes as agent nodes (`prompt`, `reasoning_eff
### Command
**Shape:** `parallelogram`
**Shape:** `parallelogram` (optional when `shape` and `type` are omitted — inferred from `script`)
Runs a shell script inside the configured sandbox and captures its output. The output is available to downstream nodes as context. This ensures command nodes execute in the same environment as agent nodes.
```dot
test [label="Run Tests", shape=parallelogram, script="cargo test 2>&1 || true"]
test [label="Run Tests", script="cargo test 2>&1 || true"]
```
When a node has no explicit `shape` or `type`, the presence of `script` makes it a command node. Writing `shape=parallelogram` explicitly is still valid and does the same thing.
| Attribute | Description |
|---|---|
| `script` | The shell command to execute. Substitutes `{{ goal }}`, `{{ inputs.NAME }}`, and `{{ vars.NAME }}` — see [command node scripts](/workflows/variables#command-node-scripts) |
| `script` | The shell command to execute (required). Substitutes `{{ goal }}`, `{{ inputs.NAME }}`, and `{{ vars.NAME }}` — see [command node scripts](/workflows/variables#command-node-scripts) |
| `language` | `"shell"` (default) or `"python"` |
### Human

View file

@ -154,17 +154,17 @@ fn styled() {
}
#[test]
fn legacy_tool() {
fn inferred_command() {
let context = test_context!();
let mut cmd = context.validate();
cmd.arg(fixture("legacy_tool.fabro"));
cmd.arg(fixture("inferred_command.fabro"));
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Workflow: LegacyTool (3 nodes, 2 edges)
Graph: [FIXTURES]/legacy_tool.fabro
Workflow: InferredCommand (3 nodes, 2 edges)
Graph: [FIXTURES]/inferred_command.fabro
Validation: OK
");
}

View file

@ -149,9 +149,9 @@ fn dry_run_styled() {
}
#[test]
fn dry_run_legacy_tool() {
fn dry_run_inferred_command() {
let context = test_context!();
let workflow = context.install_fixture("legacy_tool.fabro");
let workflow = context.install_fixture("inferred_command.fabro");
let mut cmd = context.run_cmd();
cmd.args(["--dry-run", "--auto-approve"]);
cmd.arg(&workflow);
@ -160,9 +160,9 @@ fn dry_run_legacy_tool() {
exit_code: 0
----- stdout -----
----- stderr -----
Workflow: LegacyTool (3 nodes, 2 edges)
Workflow: InferredCommand (3 nodes, 2 edges)
Graph: [GRAPH_PATH]
Goal: Verify backwards compatibility with old tool naming
Goal: Verify a shapeless script node runs as a command
Run: [ULID]
Web UI: http://localhost:3000/runs/[ULID]

View file

@ -0,0 +1,146 @@
use fabro_graphviz::graph::Graph;
use crate::{Diagnostic, LintRule, Severity};
pub(super) fn rule() -> Box<dyn LintRule> {
Box::new(Rule)
}
struct Rule;
impl LintRule for Rule {
fn name(&self) -> &'static str {
"command_requires_script"
}
fn apply(&self, graph: &Graph) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
for node in graph.nodes.values() {
if node.handler_type() != Some("command") {
continue;
}
if node.script().is_some_and(|s| !s.trim().is_empty()) {
continue;
}
diagnostics.push(Diagnostic {
rule: self.name().to_string(),
severity: Severity::Error,
message: format!("Command node '{}' has no 'script' to run", node.id),
node_id: Some(node.id.clone()),
edge: None,
fix: Some(
"Add a 'script' attribute, or remove the command shape or type if this was \
meant to be an agent node"
.to_string(),
),
..Diagnostic::default()
});
}
diagnostics
}
}
#[cfg(test)]
mod tests {
use fabro_graphviz::graph::{AttrValue, Node};
use super::Rule;
use crate::rules::test_support;
use crate::{LintRule, Severity};
#[test]
fn errors_on_command_shape_without_script() {
let mut g = test_support::minimal_graph();
g.nodes.insert(
"run".to_string(),
test_support::node_with_attrs("run", &[("shape", "parallelogram"), ("label", "Build")]),
);
let d = Rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Error);
assert!(d[0].message.contains("no 'script'"));
assert_eq!(d[0].node_id.as_deref(), Some("run"));
}
#[test]
fn errors_on_explicit_command_type_without_script() {
let mut g = test_support::minimal_graph();
g.nodes.insert(
"run".to_string(),
test_support::node_with_attrs("run", &[("type", "command")]),
);
let d = Rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Error);
}
#[test]
fn errors_on_legacy_tool_type_without_script() {
let mut g = test_support::minimal_graph();
g.nodes.insert(
"run".to_string(),
test_support::node_with_attrs("run", &[("type", "tool")]),
);
assert_eq!(Rule.apply(&g).len(), 1);
}
#[test]
fn errors_on_blank_script() {
let mut g = test_support::minimal_graph();
g.nodes.insert(
"run".to_string(),
test_support::node_with_attrs("run", &[("shape", "parallelogram"), ("script", " ")]),
);
assert_eq!(Rule.apply(&g).len(), 1);
}
#[test]
fn errors_on_non_string_script() {
let mut g = test_support::minimal_graph();
let mut node = Node::new("run");
node.attrs
.insert("script".to_string(), AttrValue::Integer(123));
g.nodes.insert("run".to_string(), node);
assert_eq!(Rule.apply(&g).len(), 1);
}
#[test]
fn accepts_command_node_with_script() {
let mut g = test_support::minimal_graph();
g.nodes.insert(
"run".to_string(),
test_support::node_with_attrs("run", &[
("shape", "parallelogram"),
("script", "cargo build"),
]),
);
g.nodes.insert(
"build".to_string(),
test_support::node_with_attrs("build", &[("script", "cargo build")]),
);
assert!(Rule.apply(&g).is_empty());
}
#[test]
fn ignores_non_command_nodes() {
let mut g = test_support::minimal_graph();
g.nodes.insert(
"plan".to_string(),
test_support::node_with_attrs("plan", &[("prompt", "do it")]),
);
g.nodes.insert(
"gate".to_string(),
test_support::node_with_attrs("gate", &[("shape", "hexagon")]),
);
assert!(Rule.apply(&g).is_empty());
}
}

View file

@ -1,4 +1,4 @@
use fabro_graphviz::graph::{self, Graph};
use fabro_graphviz::graph::{self, Graph, Node};
use crate::{Diagnostic, LintRule, Severity};
@ -25,8 +25,30 @@ const HANDLER_SPECIFIC_ATTRS: &[(&str, &[&str])] = &[
("review_target", &["human"]),
];
const SCRIPT_PROMPT_CONFLICT_RULE: &str = "script_prompt_conflict";
struct Rule;
fn script_prompt_conflict(node: &Node) -> Diagnostic {
Diagnostic {
rule: SCRIPT_PROMPT_CONFLICT_RULE.to_string(),
severity: Severity::Error,
message: format!(
"Node '{}' sets both 'script' and 'prompt'. No built-in handler reads both: command \
handlers consume 'script', while LLM handlers consume 'prompt'",
node.id
),
node_id: Some(node.id.clone()),
edge: None,
fix: Some(
"Remove whichever attribute is wrong, or split the node into a command node and an \
agent node"
.to_string(),
),
..Diagnostic::default()
}
}
impl LintRule for Rule {
fn name(&self) -> &'static str {
"inert_attribute"
@ -35,6 +57,12 @@ impl LintRule for Rule {
fn apply(&self, graph: &Graph) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
for node in graph.nodes.values() {
let has_script_prompt_conflict =
node.attrs.contains_key("script") && node.attrs.contains_key("prompt");
if has_script_prompt_conflict {
diagnostics.push(script_prompt_conflict(node));
}
// An unknown shape or type is covered by the type_known rule; a
// node this rule cannot classify is skipped rather than guessed at.
let Some(handler) = node.handler_type() else {
@ -47,6 +75,9 @@ impl LintRule for Rule {
if !node.attrs.contains_key(*attr) {
continue;
}
if has_script_prompt_conflict && matches!(*attr, "script" | "prompt") {
continue;
}
if consumers.contains(&handler) {
continue;
}
@ -74,24 +105,19 @@ impl LintRule for Rule {
#[cfg(test)]
mod tests {
use fabro_graphviz::graph::{AttrValue, Node};
use fabro_graphviz::graph::{AttrValue, Edge, Node};
use super::Rule;
use crate::rules::test_support::minimal_graph;
use crate::rules::test_support;
use crate::{LintRule, Severity};
fn node_with_attr(id: &str, shape: &str, attr: &str, value: &str) -> Node {
let mut node = Node::new(id);
node.attrs
.insert("shape".to_string(), AttrValue::String(shape.to_string()));
node.attrs
.insert(attr.to_string(), AttrValue::String(value.to_string()));
node
test_support::node_with_attrs(id, &[("shape", shape), (attr, value)])
}
#[test]
fn warns_on_script_on_agent_node() {
let mut g = minimal_graph();
let mut g = test_support::minimal_graph();
g.nodes.insert(
"work".to_string(),
node_with_attr("work", "box", "script", "echo hi"),
@ -104,9 +130,77 @@ mod tests {
assert_eq!(d[0].node_id.as_deref(), Some("work"));
}
#[test]
fn built_in_rules_report_script_prompt_conflict_once() {
let mut g = test_support::minimal_graph();
g.nodes.insert(
"work".to_string(),
test_support::node_with_attrs("work", &[
("script", "cargo build"),
("prompt", "do it"),
]),
);
g.edges = vec![Edge::new("start", "work"), Edge::new("work", "exit")];
let diagnostics = crate::validate(&g, &[]);
let work_diagnostics = diagnostics
.iter()
.filter(|diagnostic| diagnostic.node_id.as_deref() == Some("work"))
.collect::<Vec<_>>();
assert_eq!(work_diagnostics.len(), 1, "diagnostics: {diagnostics:?}");
assert_eq!(work_diagnostics[0].rule, "script_prompt_conflict");
assert_eq!(work_diagnostics[0].severity, Severity::Error);
}
#[test]
fn conflict_error_replaces_inert_warning_for_explicit_shapes() {
let mut g = test_support::minimal_graph();
g.nodes.insert(
"run".to_string(),
test_support::node_with_attrs("run", &[
("shape", "parallelogram"),
("script", "cargo build"),
("prompt", "do it"),
]),
);
g.nodes.insert(
"plan".to_string(),
test_support::node_with_attrs("plan", &[
("shape", "box"),
("script", "cargo build"),
("prompt", "do it"),
]),
);
let diagnostics = Rule.apply(&g);
assert_eq!(diagnostics.len(), 2);
assert!(
diagnostics
.iter()
.all(|diagnostic| diagnostic.rule == "script_prompt_conflict"
&& diagnostic.severity == Severity::Error)
);
}
#[test]
fn conflict_uses_attribute_presence() {
let mut g = test_support::minimal_graph();
let mut node = test_support::node_with_attrs("work", &[("prompt", "do it")]);
node.attrs
.insert("script".to_string(), AttrValue::Integer(123));
g.nodes.insert("work".to_string(), node);
let diagnostics = Rule.apply(&g);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].rule, "script_prompt_conflict");
}
#[test]
fn warns_on_prompt_on_start_and_command_nodes() {
let mut g = minimal_graph();
let mut g = test_support::minimal_graph();
g.nodes
.get_mut("start")
.expect("minimal graph has start")
@ -126,7 +220,7 @@ mod tests {
#[test]
fn warns_on_duration_on_command_node() {
let mut g = minimal_graph();
let mut g = test_support::minimal_graph();
g.nodes.insert(
"run".to_string(),
node_with_attr("run", "parallelogram", "duration", "30s"),
@ -139,7 +233,7 @@ mod tests {
#[test]
fn warns_on_parallel_attrs_on_agent_node() {
let mut g = minimal_graph();
let mut g = test_support::minimal_graph();
let mut node = Node::new("work");
node.attrs
.insert("max_parallel".to_string(), AttrValue::Integer(4));
@ -150,7 +244,7 @@ mod tests {
#[test]
fn warns_on_output_retries_on_command_node() {
let mut g = minimal_graph();
let mut g = test_support::minimal_graph();
g.nodes.insert(
"run".to_string(),
node_with_attr("run", "parallelogram", "output_retries", "2"),
@ -165,7 +259,7 @@ mod tests {
#[test]
fn accepts_attrs_on_their_own_handler_types() {
let mut g = minimal_graph();
let mut g = test_support::minimal_graph();
g.nodes.insert(
"run".to_string(),
node_with_attr("run", "parallelogram", "script", "echo hi"),
@ -202,12 +296,19 @@ mod tests {
"human".to_string(),
node_with_attr("human", "hexagon", "review_target", "true"),
);
g.nodes.insert(
"legacy_command".to_string(),
test_support::node_with_attrs("legacy_command", &[
("type", "tool"),
("script", "echo legacy"),
]),
);
assert!(Rule.apply(&g).is_empty());
}
#[test]
fn warns_on_review_target_on_non_human_node() {
let mut g = minimal_graph();
let mut g = test_support::minimal_graph();
g.nodes.insert(
"work".to_string(),
node_with_attr("work", "box", "review_target", "true"),
@ -222,7 +323,7 @@ mod tests {
#[test]
fn accepts_prompt_on_shapeless_node_defaulting_to_agent() {
let mut g = minimal_graph();
let mut g = test_support::minimal_graph();
let mut node = Node::new("work");
node.attrs.insert(
"prompt".to_string(),
@ -234,7 +335,7 @@ mod tests {
#[test]
fn accepts_prompt_on_fan_in_judge() {
let mut g = minimal_graph();
let mut g = test_support::minimal_graph();
g.nodes.insert(
"merge".to_string(),
node_with_attr("merge", "tripleoctagon", "prompt", "pick the best"),
@ -244,7 +345,7 @@ mod tests {
#[test]
fn ignores_unclassifiable_node_shapes() {
let mut g = minimal_graph();
let mut g = test_support::minimal_graph();
g.nodes.insert(
"odd".to_string(),
node_with_attr("odd", "doubleoctagon", "script", "echo hi"),
@ -254,7 +355,7 @@ mod tests {
#[test]
fn ignores_handler_specific_attrs_on_unrecognized_explicit_types() {
let mut g = minimal_graph();
let mut g = test_support::minimal_graph();
let mut node = Node::new("custom");
node.attrs.insert(
"type".to_string(),

View file

@ -1,5 +1,6 @@
mod all_conditional_edges;
mod backend_valid;
mod command_requires_script;
mod condition_syntax;
mod direction_valid;
mod edge_target_exists;
@ -61,6 +62,7 @@ pub fn built_in_rules() -> Vec<Box<dyn LintRule>> {
all_conditional_edges::rule(),
orphan_custom_outcome::rule(),
script_absolute_cd::rule(),
command_requires_script::rule(),
import_error::rule(),
join_policy_removed::rule(),
unresolved_file_ref::rule(),

View file

@ -40,12 +40,7 @@ impl LintRule for Rule {
if node.handler_type() != Some("command") {
continue;
}
let script = node
.attrs
.get("script")
.or_else(|| node.attrs.get("tool_command"))
.and_then(|v| v.as_str())
.unwrap_or("");
let script = node.script().unwrap_or("");
if contains_cd_absolute(script) {
diagnostics.push(Diagnostic {
rule: self.name().to_string(),
@ -114,7 +109,22 @@ mod tests {
}
#[test]
fn script_absolute_cd_warns_on_legacy_tool_command() {
fn script_absolute_cd_warns_on_shapeless_script_node() {
let mut g = minimal_graph();
let mut node = Node::new("run");
node.attrs.insert(
"script".to_string(),
AttrValue::String("cd /home/user && make".to_string()),
);
g.nodes.insert("run".to_string(), node);
let rule = Rule;
let d = rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Warning);
}
#[test]
fn script_absolute_cd_ignores_legacy_tool_command() {
let mut g = minimal_graph();
let mut node = Node::new("run");
node.attrs.insert(
@ -127,9 +137,7 @@ mod tests {
);
g.nodes.insert("run".to_string(), node);
let rule = Rule;
let d = rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Warning);
assert!(rule.apply(&g).is_empty());
}
#[test]

View file

@ -1,5 +1,14 @@
use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node};
pub(crate) fn node_with_attrs(id: &str, attrs: &[(&str, &str)]) -> Node {
let mut node = Node::new(id);
for (key, value) in attrs {
node.attrs
.insert((*key).to_string(), AttrValue::String((*value).to_string()));
}
node
}
pub(crate) fn minimal_graph() -> Graph {
let mut g = Graph::new("test");
let mut start = Node::new("start");

View file

@ -18,6 +18,10 @@ fn timeout_ms(node: &Node) -> Option<u64> {
node.timeout().map(crate::millis_u64)
}
fn non_blank_script(node: &Node) -> Option<&str> {
node.script().filter(|script| !script.trim().is_empty())
}
/// Executes an external script configured via node attributes.
pub struct CommandHandler;
@ -31,12 +35,9 @@ impl Handler for CommandHandler {
_run_dir: &Path,
_services: &EngineServices,
) -> Result<Outcome, Error> {
let script = node
.attrs
.get("script")
.or_else(|| node.attrs.get("tool_command"))
.and_then(|v| v.as_str())
.unwrap_or("");
let Some(script) = non_blank_script(node) else {
return Ok(Outcome::fail_classify("No script specified"));
};
let mut outcome = Outcome::simulated(&node.id);
outcome.notes = Some(format!("[Simulated] Command skipped: {script}"));
@ -54,16 +55,9 @@ impl Handler for CommandHandler {
run_dir: &Path,
services: &EngineServices,
) -> Result<Outcome, Error> {
let script = node
.attrs
.get("script")
.or_else(|| node.attrs.get("tool_command"))
.and_then(|v| v.as_str())
.unwrap_or("");
if script.is_empty() {
let Some(script) = non_blank_script(node) else {
return Ok(Outcome::fail_classify("No script specified"));
}
};
let language = node
.attrs
@ -402,22 +396,38 @@ mod tests {
}
#[tokio::test]
async fn script_handler_no_script() {
async fn missing_and_blank_scripts_fail_execution_and_simulation() {
let handler = CommandHandler;
let node = Node::new("script_node");
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_reason(), Some("No script specified"));
for script in [None, Some(" \t\n")] {
let mut node = Node::new("script_node");
if let Some(script) = script {
node.attrs
.insert("script".to_string(), AttrValue::String(script.to_string()));
}
let outcomes = [
handler
.execute(&node, &context, &graph, run_dir.path(), &services)
.await
.unwrap(),
handler
.simulate(&node, &context, &graph, run_dir.path(), &services)
.await
.unwrap(),
];
for outcome in outcomes {
assert_eq!(outcome.status, StageOutcome::Failed {
retry_requested: false,
});
assert_eq!(outcome.failure_reason(), Some("No script specified"));
}
}
}
#[tokio::test]
@ -1264,7 +1274,7 @@ mod tests {
}
#[tokio::test]
async fn tool_command_attribute_fallback() {
async fn tool_command_attribute_is_not_read() {
let handler = CommandHandler;
let mut node = Node::new("script_node");
node.attrs.insert(
@ -1280,13 +1290,10 @@ mod tests {
.execute(&node, &context, &graph, run_dir.path(), &services)
.await
.unwrap();
assert_eq!(outcome.status, StageOutcome::Succeeded);
let command_output = outcome.context_updates.get(keys::COMMAND_OUTPUT).unwrap();
assert!(
command_text(&services, command_output)
.await
.contains("legacy")
);
assert_eq!(outcome.status, StageOutcome::Failed {
retry_requested: false,
});
assert!(outcome.failure_reason().unwrap().contains("No script"));
}
#[tokio::test]

View file

@ -161,15 +161,8 @@ fn render_compact_stage_details(
match handler {
Some("command") => {
let mut lines = Vec::new();
if let Some(n) = node {
if let Some(cmd) = n
.attrs
.get("script")
.or_else(|| n.attrs.get("tool_command"))
.and_then(|v| v.as_str())
{
lines.push(format!(" - Script: `{cmd}`"));
}
if let Some(cmd) = node.and_then(Node::script) {
lines.push(format!(" - Script: `{cmd}`"));
}
if let Some(output_val) = outcome.context_updates.get(keys::COMMAND_OUTPUT) {
let output = format_value(output_val);
@ -215,15 +208,8 @@ fn render_summary_high_stage_section(
match handler {
Some("command") => {
if let Some(n) = node {
if let Some(cmd) = n
.attrs
.get("script")
.or_else(|| n.attrs.get("tool_command"))
.and_then(|v| v.as_str())
{
lines.push(format!("- Script: `{cmd}`"));
}
if let Some(cmd) = node.and_then(Node::script) {
lines.push(format!("- Script: `{cmd}`"));
}
if let Some(output_val) = outcome.context_updates.get(keys::COMMAND_OUTPUT) {
if let Some(path) = artifact_path(output_val) {
@ -531,15 +517,8 @@ fn build_summary_preamble(
}
match handler {
Some("command") => {
if let Some(n) = node {
if let Some(cmd) = n
.attrs
.get("script")
.or_else(|| n.attrs.get("tool_command"))
.and_then(|v| v.as_str())
{
parts.push(format!(" - Script: `{cmd}`"));
}
if let Some(cmd) = node.and_then(Node::script) {
parts.push(format!(" - Script: `{cmd}`"));
}
}
h if is_llm_handler_type(h) => {

View file

@ -153,9 +153,20 @@ impl Node {
self.str_attr("label").unwrap_or(&self.id)
}
/// The node's Graphviz shape, which contributes to handler selection.
///
/// An explicit `shape` or `type` attribute disables inference. Otherwise,
/// the presence of `script` infers `parallelogram`. Everything else falls
/// back to `box`.
#[must_use]
pub fn shape(&self) -> &str {
self.str_attr("shape").unwrap_or("box")
if let Some(shape) = self.str_attr("shape") {
return shape;
}
if self.node_type().is_none() && self.attrs.contains_key("script") {
return "parallelogram";
}
"box"
}
#[must_use]
@ -168,6 +179,12 @@ impl Node {
self.str_attr("prompt")
}
/// The shell or Python source a command node runs.
#[must_use]
pub fn script(&self) -> Option<&str> {
self.str_attr("script")
}
/// The prompt a handler should send, falling back to the node label when
/// `prompt` is absent or empty.
#[must_use]
@ -315,8 +332,10 @@ impl Node {
/// mapping.
#[must_use]
pub fn handler_type(&self) -> Option<&str> {
if let Some(t) = self.node_type() {
return Some(t);
match self.node_type() {
Some("tool") => return Some("command"),
Some(node_type) => return Some(node_type),
None => {}
}
shape_to_handler_type(self.shape())
}
@ -605,6 +624,7 @@ mod tests {
assert_eq!(node.shape(), "box");
assert_eq!(node.node_type(), None);
assert_eq!(node.prompt(), None);
assert_eq!(node.script(), None);
assert_eq!(node.for_each(), None);
assert_eq!(node.output_schema(), None);
assert_eq!(node.output_retries(), 2);
@ -627,6 +647,66 @@ mod tests {
assert!(node.project_memory());
}
fn node_with(id: &str, attrs: &[(&str, &str)]) -> Node {
let mut node = Node::new(id);
for (key, value) in attrs {
node.attrs
.insert((*key).to_string(), AttrValue::String((*value).to_string()));
}
node
}
#[test]
fn shapeless_script_node_infers_command() {
let node = node_with("build", &[("script", "cargo build")]);
assert_eq!(node.shape(), "parallelogram");
assert_eq!(node.handler_type(), Some("command"));
}
#[test]
fn shapeless_node_without_script_stays_agent() {
let node = node_with("plan", &[("prompt", "Plan the work")]);
assert_eq!(node.shape(), "box");
assert_eq!(node.handler_type(), Some("agent"));
}
#[test]
fn explicit_shape_wins_over_script_inference() {
let node = node_with("odd", &[("shape", "box"), ("script", "cargo build")]);
assert_eq!(node.shape(), "box");
assert_eq!(node.handler_type(), Some("agent"));
}
#[test]
fn explicit_type_wins_over_script_inference() {
let node = node_with("odd", &[("type", "agent"), ("script", "cargo build")]);
assert_eq!(node.shape(), "box");
assert_eq!(node.handler_type(), Some("agent"));
}
#[test]
fn any_script_attribute_value_infers_command() {
// The command-requires-script lint reports this; inference only asks
// whether the attribute is present so the diagnostic lands on a
// command node rather than a silently-agent one.
let empty = node_with("empty", &[("script", "")]);
assert_eq!(empty.shape(), "parallelogram");
assert_eq!(empty.handler_type(), Some("command"));
let mut non_string = Node::new("non_string");
non_string
.attrs
.insert("script".to_string(), AttrValue::Integer(123));
assert_eq!(non_string.shape(), "parallelogram");
assert_eq!(non_string.handler_type(), Some("command"));
}
#[test]
fn legacy_tool_type_resolves_to_command() {
let node = node_with("build", &[("type", "tool")]);
assert_eq!(node.handler_type(), Some("command"));
}
#[test]
fn node_project_memory_false_overrides_default() {
let mut node = Node::new("x");

View file

@ -43,11 +43,11 @@ digraph reference_template {
start [shape=Mdiamond, label="Start"]
// Toolchain gate — fail fast before LLM stages.
// Replace tool_command with project-specific checks.
// Replace script with project-specific checks.
check_toolchain [
shape=parallelogram,
max_retries=0,
tool_command="echo 'Replace with project-specific toolchain checks'; exit 0"
script="echo 'Replace with project-specific toolchain checks'; exit 0"
]
// PROMPT: expand_spec
@ -187,42 +187,42 @@ digraph reference_template {
check_implement [shape=diamond, label="Implement OK?"]
// Auto-fix formatting before verify gate.
// Replace tool_command with project-specific auto-formatter.
// Replace script with project-specific auto-formatter.
fix_fmt [
shape=parallelogram,
max_retries=0,
tool_command="echo 'Replace with project-specific auto-formatter'; exit 0"
script="echo 'Replace with project-specific auto-formatter'; exit 0"
]
// Replace tool_command with project-specific formatter check.
// Replace script with project-specific formatter check.
verify_fmt [
shape=parallelogram,
max_retries=0,
tool_command="echo 'Replace with project-specific formatter check'; exit 0"
script="echo 'Replace with project-specific formatter check'; exit 0"
]
check_fmt [shape=diamond, label="Fmt OK?"]
// Replace tool_command with project-specific build command.
// Replace script with project-specific build command.
verify_build [
shape=parallelogram,
tool_command="echo 'Replace with project-specific build check'; exit 0"
script="echo 'Replace with project-specific build check'; exit 0"
]
check_build [shape=diamond, label="Build OK?"]
// Replace tool_command with project-specific test command.
// Replace script with project-specific test command.
verify_test [
shape=parallelogram,
tool_command="echo 'Replace with project-specific test check'; exit 0"
script="echo 'Replace with project-specific test check'; exit 0"
]
check_test [shape=diamond, label="Tests OK?"]
// Replace tool_command with artifact hygiene check.
// Replace script with artifact hygiene check.
// Confirm deliverables meet their interface contract (exports, endpoints,
// CLI behavior, observable outputs); file existence alone is insufficient.
verify_artifacts [
shape=parallelogram,
max_retries=0,
tool_command="echo 'Replace with artifact hygiene check'; exit 0"
script="echo 'Replace with artifact hygiene check'; exit 0"
]
check_artifacts [shape=diamond, label="Artifacts OK?"]

View file

@ -0,0 +1,11 @@
digraph InferredCommand {
graph [goal="Verify a shapeless script node runs as a command"]
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
echo_task [label="Echo", script="echo hello"]
start -> echo_task -> exit
}

View file

@ -1,11 +0,0 @@
digraph LegacyTool {
graph [goal="Verify backwards compatibility with old tool naming"]
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
echo_task [label="Echo", shape=parallelogram, tool_command="echo hello"]
start -> echo_task -> exit
}