fabro/lib/crates/fabro-cli/tests/it/cmd/run.rs
Bryan Helmkamp 0eca7d94d6 fix(run): make attach stream terminal-authoritative
Replay persisted run events for attach requests, keep the SSE stream live
only while the run is active, and close on terminal run events instead of
returning 410 for completed runs.

The CLI now treats premature attach EOF as an error, and the affected
integration tests were stabilized around store-backed event ordering and
recovered rewind timelines.
2026-04-07 14:32:23 -04:00

1417 lines
42 KiB
Rust

use fabro_test::{fabro_snapshot, test_context};
use fabro_types::StatusReason;
use httpmock::MockServer;
use serde_json::Value;
use super::support::{
output_stderr, resolve_run, run_state, wait_for_event_names, wait_for_no_process_match,
wait_for_status, write_gated_workflow,
};
use crate::support::{example_fixture, fabro_json_snapshot, run_output_filters, unique_run_id};
fn run_status_response(run_id: &str, status: &str) -> serde_json::Value {
serde_json::json!({
"id": run_id,
"status": status,
"created_at": "2026-04-05T12:00:00Z"
})
}
fn preflight_response() -> serde_json::Value {
serde_json::json!({
"ok": true,
"workflow": {
"name": "Simple",
"graph_path": null,
"nodes": 4,
"edges": 3,
"goal": "Run tests and report results",
"diagnostics": []
},
"checks": {
"title": "Preflight",
"sections": []
}
})
}
fn remote_run_state_response() -> serde_json::Value {
serde_json::json!({
"run": null,
"graph_source": null,
"start": null,
"status": null,
"checkpoint": {
"timestamp": "2026-04-05T12:00:01Z",
"current_node": "exit",
"completed_nodes": ["report"],
"node_retries": {},
"context_values": {
"response.report": "Remote output"
},
"node_outcomes": {},
"next_node_id": null,
"git_commit_sha": null,
"loop_failure_signatures": {},
"restart_failure_signatures": {},
"node_visits": {}
},
"checkpoints": [],
"conclusion": {
"timestamp": "2026-04-05T12:00:01Z",
"status": "success",
"duration_ms": 12,
"stages": [],
"total_cost": null,
"total_retries": 0,
"total_input_tokens": 0,
"total_output_tokens": 0,
"total_cache_read_tokens": 0,
"total_cache_write_tokens": 0,
"total_reasoning_tokens": 0,
"has_pricing": false
},
"retro": null,
"retro_prompt": null,
"retro_response": null,
"sandbox": null,
"final_patch": null,
"pull_request": null,
"nodes": {}
})
}
fn run_completed_event(run_id: &str) -> serde_json::Value {
serde_json::json!({
"seq": 1,
"payload": {
"event": "run.completed",
"id": "evt-run-completed",
"run_id": run_id,
"ts": "2026-04-05T12:00:01Z",
"properties": {
"duration_ms": 12,
"artifact_count": 0,
"status": "success"
}
}
})
}
#[test]
fn help() {
let context = test_context!();
let mut cmd = context.run_cmd();
cmd.arg("--help");
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
Launch a workflow run
Usage: fabro run [OPTIONS] <WORKFLOW>
Arguments:
<WORKFLOW> Path to a .fabro workflow file or .toml task config
Options:
--json Output as JSON [env: FABRO_JSON=]
--server <SERVER> Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=]
--debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
--dry-run Execute with simulated LLM backend
--auto-approve Auto-approve all human gates
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
--goal <GOAL> Override the workflow goal (exposed as $goal in prompts)
--quiet Suppress non-essential output [env: FABRO_QUIET=]
--goal-file <GOAL_FILE> Read the workflow goal from a file
--model <MODEL> Override default LLM model
--provider <PROVIDER> Override default LLM provider
-v, --verbose Enable verbose output
--sandbox <SANDBOX> Sandbox for agent tools [possible values: local, docker, daytona]
--label <KEY=VALUE> Attach a label to this run (repeatable, format: KEY=VALUE)
--no-retro Skip retro generation after the run
--preserve-sandbox Keep the sandbox alive after the run finishes (for debugging)
-d, --detach Run the workflow in the background and print the run ID
-h, --help Print help
----- stderr -----
");
}
#[test]
fn detach_uses_explicit_server_target_and_prints_remote_run_id() {
let context = test_context!();
let server = MockServer::start();
let run_id = unique_run_id();
let create_mock = server.mock(|when, then| {
when.method("POST").path("/api/v1/runs");
then.status(201)
.header("Content-Type", "application/json")
.body(run_status_response(run_id.as_str(), "submitted").to_string());
});
let start_mock = server.mock(|when, then| {
when.method("POST")
.path(format!("/api/v1/runs/{run_id}/start"));
then.status(200)
.header("Content-Type", "application/json")
.body(run_status_response(run_id.as_str(), "queued").to_string());
});
let output = context
.run_cmd()
.args([
"--server",
&format!("{}/api/v1", server.base_url()),
"--detach",
"--dry-run",
"--auto-approve",
example_fixture("simple.fabro").to_str().unwrap(),
])
.output()
.expect("command should execute");
assert!(
output.status.success(),
"command failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
create_mock.assert();
start_mock.assert();
assert_eq!(output_stderr(&output), "");
assert_eq!(
String::from_utf8_lossy(&output.stdout).trim(),
run_id.as_str()
);
}
#[test]
fn detach_uses_configured_server_target_without_server_flag() {
let context = test_context!();
let server = MockServer::start();
let run_id = unique_run_id();
let create_mock = server.mock(|when, then| {
when.method("POST").path("/api/v1/runs");
then.status(201)
.header("Content-Type", "application/json")
.body(run_status_response(run_id.as_str(), "submitted").to_string());
});
let start_mock = server.mock(|when, then| {
when.method("POST")
.path(format!("/api/v1/runs/{run_id}/start"));
then.status(200)
.header("Content-Type", "application/json")
.body(run_status_response(run_id.as_str(), "queued").to_string());
});
context.write_home(
".fabro/settings.toml",
format!("[server]\ntarget = \"{}/api/v1\"\n", server.base_url()),
);
let output = context
.run_cmd()
.args([
"--detach",
"--dry-run",
"--auto-approve",
example_fixture("simple.fabro").to_str().unwrap(),
])
.output()
.expect("command should execute");
assert!(
output.status.success(),
"command failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
create_mock.assert();
start_mock.assert();
assert_eq!(
String::from_utf8_lossy(&output.stdout).trim(),
run_id.as_str()
);
}
#[test]
fn detach_rejects_storage_dir_flag() {
let context = test_context!();
let output = context
.run_cmd()
.args([
"--storage-dir",
"/tmp/fabro-run",
"--detach",
"--dry-run",
"--auto-approve",
"--no-retro",
example_fixture("simple.fabro").to_str().unwrap(),
])
.output()
.expect("command should execute");
assert!(
!output.status.success(),
"command should reject --storage-dir"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("unexpected argument '--storage-dir'"));
}
#[test]
fn detach_cli_server_target_overrides_configured_server_target() {
let context = test_context!();
let config_server = MockServer::start();
let config_create = config_server.mock(|when, then| {
when.method("POST").path("/api/v1/runs");
then.status(500)
.body("configured-server-should-not-be-used");
});
let config_start = config_server.mock(|when, then| {
when.method("POST").path_includes("/api/v1/runs/");
then.status(500)
.body("configured-server-should-not-be-used");
});
let cli_server = MockServer::start();
let run_id = unique_run_id();
let cli_create = cli_server.mock(|when, then| {
when.method("POST").path("/api/v1/runs");
then.status(201)
.header("Content-Type", "application/json")
.body(run_status_response(run_id.as_str(), "submitted").to_string());
});
let cli_start = cli_server.mock(|when, then| {
when.method("POST")
.path(format!("/api/v1/runs/{run_id}/start"));
then.status(200)
.header("Content-Type", "application/json")
.body(run_status_response(run_id.as_str(), "queued").to_string());
});
context.write_home(
".fabro/settings.toml",
format!(
"[server]\ntarget = \"{}/api/v1\"\n",
config_server.base_url()
),
);
let output = context
.run_cmd()
.args([
"--server",
&format!("{}/api/v1", cli_server.base_url()),
"--detach",
"--dry-run",
"--auto-approve",
example_fixture("simple.fabro").to_str().unwrap(),
])
.output()
.expect("command should execute");
assert!(
output.status.success(),
"command failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
cli_create.assert();
cli_start.assert();
config_create.assert_calls(0);
config_start.assert_calls(0);
assert_eq!(
String::from_utf8_lossy(&output.stdout).trim(),
run_id.as_str()
);
}
#[test]
fn remote_foreground_run_prints_server_backed_summary_without_local_run_dir() {
let context = test_context!();
let server = MockServer::start();
let run_id = unique_run_id();
server.mock(|when, then| {
when.method("POST").path("/api/v1/preflight");
then.status(200)
.header("Content-Type", "application/json")
.body(preflight_response().to_string());
});
server.mock(|when, then| {
when.method("POST").path("/api/v1/runs");
then.status(201)
.header("Content-Type", "application/json")
.body(run_status_response(run_id.as_str(), "submitted").to_string());
});
server.mock(|when, then| {
when.method("POST")
.path(format!("/api/v1/runs/{run_id}/start"));
then.status(200)
.header("Content-Type", "application/json")
.body(run_status_response(run_id.as_str(), "queued").to_string());
});
server.mock(|when, then| {
when.method("GET")
.path(format!("/api/v1/runs/{run_id}/events"))
.query_param_missing("since_seq");
then.status(200)
.header("Content-Type", "application/json")
.body(
serde_json::json!({
"data": [run_completed_event(run_id.as_str())],
"meta": { "has_more": false }
})
.to_string(),
);
});
server.mock(|when, then| {
when.method("GET")
.path(format!("/api/v1/runs/{run_id}/events"))
.query_param("since_seq", "2");
then.status(200)
.header("Content-Type", "application/json")
.body(
serde_json::json!({
"data": [],
"meta": { "has_more": false }
})
.to_string(),
);
});
server.mock(|when, then| {
when.method("GET")
.path(format!("/api/v1/runs/{run_id}/questions"))
.query_param("page[limit]", "100")
.query_param("page[offset]", "0");
then.status(200)
.header("Content-Type", "application/json")
.body(
serde_json::json!({
"data": [],
"meta": { "has_more": false }
})
.to_string(),
);
});
server.mock(|when, then| {
when.method("GET")
.path(format!("/api/v1/runs/{run_id}/state"));
then.status(200)
.header("Content-Type", "application/json")
.body(remote_run_state_response().to_string());
});
let output = context
.run_cmd()
.args([
"--server",
&format!("{}/api/v1", server.base_url()),
"--dry-run",
"--auto-approve",
example_fixture("simple.fabro").to_str().unwrap(),
])
.output()
.expect("command should execute");
assert!(
output.status.success(),
"command failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stderr = output_stderr(&output);
assert!(stderr.contains("=== Run Result ==="), "{stderr}");
assert!(stderr.contains("Remote output"), "{stderr}");
assert_eq!(
stderr
.lines()
.filter(|line| line.trim_start().starts_with("Run:"))
.count(),
1,
"{stderr}"
);
assert!(!stderr.contains("=== Artifacts ==="), "{stderr}");
}
#[test]
fn dry_run_simple() {
let context = test_context!();
let mut cmd = context.run_cmd();
cmd.args(["--dry-run", "--auto-approve"]);
cmd.arg(example_fixture("simple.fabro"));
fabro_snapshot!(run_output_filters(&context), cmd, @"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Workflow: Simple (4 nodes, 3 edges)
Graph: [FIXTURES]/simple.fabro
Goal: Run tests and report results
Sandbox: local (ready in [TIME])
✓ Start [TIME]
✓ Run Tests [TIME]
✓ Report [TIME]
✓ Exit [TIME]
=== Run Result ===
Run: [ULID]
Status: SUCCESS
Duration: [DURATION]
Run: [RUN_DIR]
=== Output ===
[Simulated] Response for stage: report
");
}
#[test]
fn dry_run_persists_event_history_in_store() {
let context = test_context!();
let run_id = unique_run_id();
context
.command()
.args([
"run",
"--dry-run",
"--auto-approve",
"--sandbox",
"local",
"--run-id",
run_id.as_str(),
example_fixture("simple.fabro").to_str().unwrap(),
])
.assert()
.success();
let run_dir = context.find_run_dir(&run_id);
wait_for_event_names(&run_dir, &["run.completed", "sandbox.cleanup.completed"]);
let output = context
.command()
.args(["logs", &run_id])
.output()
.expect("logs command should execute");
assert!(
output.status.success(),
"logs failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let progress: Vec<Value> = String::from_utf8(output.stdout)
.expect("stdout should be UTF-8")
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| serde_json::from_str(line).expect("logs output should be JSONL"))
.collect();
assert!(
!progress.is_empty(),
"store-backed event history should have at least one line"
);
assert_eq!(
progress.first().and_then(|event| event["event"].as_str()),
Some("run.created")
);
assert_eq!(
progress
.first()
.and_then(|event| event.pointer("/properties/settings/auto_approve"))
.and_then(Value::as_bool),
Some(true)
);
assert!(
progress
.iter()
.any(|event| event["event"].as_str() == Some("run.completed")),
"store-backed event history should include run.completed"
);
assert_eq!(
progress.last().and_then(|event| event["event"].as_str()),
Some("sandbox.cleanup.completed")
);
let tail_output = context
.command()
.args(["logs", "--tail", "1", &run_id])
.output()
.expect("tail logs command should execute");
assert!(
tail_output.status.success(),
"tail logs failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&tail_output.stdout),
String::from_utf8_lossy(&tail_output.stderr)
);
let live_content: Value = String::from_utf8(tail_output.stdout)
.expect("stdout should be UTF-8")
.lines()
.find(|line| !line.trim().is_empty())
.map(|line| serde_json::from_str(line).expect("tail logs output should be JSON"))
.expect("tail logs should include the latest event");
fabro_json_snapshot!(context, &live_content, @r#"
{
"event": "sandbox.cleanup.completed",
"id": "[EVENT_ID]",
"properties": {
"duration_ms": "[DURATION_MS]",
"provider": "local"
},
"run_id": "[ULID]",
"ts": "[TIMESTAMP]"
}
"#);
assert_eq!(live_content, *progress.last().unwrap());
}
#[test]
fn run_id_passthrough_uses_provided_ulid() {
let context = test_context!();
let run_id = unique_run_id();
context
.command()
.args([
"run",
"--dry-run",
"--auto-approve",
"--run-id",
run_id.as_str(),
example_fixture("simple.fabro").to_str().unwrap(),
])
.assert()
.success();
context.find_run_dir(&run_id);
}
#[test]
fn json_run_implies_auto_approve_for_human_gates() {
let context = test_context!();
let workflow = context.temp_dir.join("human-gate.fabro");
context.write_temp(
"human-gate.fabro",
r#"digraph HumanGate {
graph [goal="Route through the default approval path"]
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
approve [shape=hexagon, label="Approve?"]
ship [shape=parallelogram, script="echo shipped"]
revise [shape=parallelogram, script="echo revised"]
start -> approve
approve -> ship [label="[A] Approve"]
approve -> revise [label="[R] Revise"]
ship -> exit
revise -> exit
}
"#,
);
let output = context
.command()
.args([
"--json",
"run",
"--sandbox",
"local",
"--no-retro",
workflow.to_str().unwrap(),
])
.output()
.expect("command should execute");
assert!(
output.status.success(),
"command failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let mut progress: Vec<Value> = String::from_utf8(output.stdout)
.expect("stdout should be UTF-8")
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| serde_json::from_str(line).expect("run JSON output should be JSONL"))
.collect();
for event in &mut progress {
let Some(llm) = event.pointer_mut("/properties/settings/llm") else {
continue;
};
let Some(llm) = llm.as_object_mut() else {
continue;
};
llm.insert(
"model".to_string(),
Value::String("[LLM_MODEL]".to_string()),
);
llm.insert(
"provider".to_string(),
Value::String("[LLM_PROVIDER]".to_string()),
);
}
fabro_json_snapshot!(context, &progress, @r#"
[
{
"event": "run.created",
"id": "[EVENT_ID]",
"properties": {
"graph": {
"attrs": {
"goal": {
"String": "Route through the default approval path"
}
},
"edges": [
{
"attrs": {},
"from": "start",
"to": "approve"
},
{
"attrs": {
"label": {
"String": "[A] Approve"
}
},
"from": "approve",
"to": "ship"
},
{
"attrs": {
"label": {
"String": "[R] Revise"
}
},
"from": "approve",
"to": "revise"
},
{
"attrs": {},
"from": "ship",
"to": "exit"
},
{
"attrs": {},
"from": "revise",
"to": "exit"
}
],
"name": "HumanGate",
"nodes": {
"approve": {
"attrs": {
"label": {
"String": "Approve?"
},
"shape": {
"String": "hexagon"
}
},
"id": "approve"
},
"exit": {
"attrs": {
"label": {
"String": "Exit"
},
"shape": {
"String": "Msquare"
}
},
"id": "exit"
},
"revise": {
"attrs": {
"script": {
"String": "echo revised"
},
"shape": {
"String": "parallelogram"
}
},
"id": "revise"
},
"ship": {
"attrs": {
"script": {
"String": "echo shipped"
},
"shape": {
"String": "parallelogram"
}
},
"id": "ship"
},
"start": {
"attrs": {
"label": {
"String": "Start"
},
"shape": {
"String": "Mdiamond"
}
},
"id": "start"
}
}
},
"host_repo_path": "[TEMP_DIR]",
"provenance": {
"client": {
"name": "fabro-cli",
"user_agent": "fabro-cli/0.176.2",
"version": "0.176.2"
},
"server": {
"version": "0.176.2"
},
"subject": {
"auth_method": "disabled"
}
},
"run_dir": "[RUN_DIR]",
"settings": {
"auto_approve": true,
"goal": "Route through the default approval path",
"llm": {
"fallbacks": null,
"model": "[LLM_MODEL]",
"provider": "[LLM_PROVIDER]"
},
"no_retro": true,
"sandbox": {
"daytona": null,
"devcontainer": null,
"env": null,
"local": null,
"preserve": null,
"provider": "local"
},
"storage_dir": "[STORAGE_DIR]"
},
"workflow_slug": "human-gate",
"workflow_source": "digraph HumanGate {/n graph [goal=\"Route through the default approval path\"]/n start [shape=Mdiamond, label=\"Start\"]/n exit [shape=Msquare, label=\"Exit\"]/n approve [shape=hexagon, label=\"Approve?\"]/n ship [shape=parallelogram, script=\"echo shipped\"]/n revise [shape=parallelogram, script=\"echo revised\"]/n start -> approve/n approve -> ship [label=\"[A] Approve\"]/n approve -> revise [label=\"[R] Revise\"]/n ship -> exit/n revise -> exit/n}/n",
"working_directory": "[TEMP_DIR]"
},
"run_id": "[ULID]",
"ts": "[TIMESTAMP]"
},
{
"event": "run.submitted",
"id": "[EVENT_ID]",
"properties": {},
"run_id": "[ULID]",
"ts": "[TIMESTAMP]"
},
{
"event": "run.starting",
"id": "[EVENT_ID]",
"properties": {
"reason": "sandbox_initializing"
},
"run_id": "[ULID]",
"ts": "[TIMESTAMP]"
},
{
"event": "sandbox.initializing",
"id": "[EVENT_ID]",
"properties": {
"provider": "local"
},
"run_id": "[ULID]",
"ts": "[TIMESTAMP]"
},
{
"event": "sandbox.ready",
"id": "[EVENT_ID]",
"properties": {
"duration_ms": "[DURATION_MS]",
"provider": "local"
},
"run_id": "[ULID]",
"ts": "[TIMESTAMP]"
},
{
"event": "sandbox.initialized",
"id": "[EVENT_ID]",
"properties": {
"provider": "local",
"working_directory": "[TEMP_DIR]"
},
"run_id": "[ULID]",
"ts": "[TIMESTAMP]"
},
{
"event": "run.started",
"id": "[EVENT_ID]",
"properties": {
"goal": "Route through the default approval path",
"name": "HumanGate"
},
"run_id": "[ULID]",
"ts": "[TIMESTAMP]"
},
{
"event": "run.running",
"id": "[EVENT_ID]",
"properties": {},
"run_id": "[ULID]",
"ts": "[TIMESTAMP]"
},
{
"event": "stage.started",
"id": "[EVENT_ID]",
"node_id": "start",
"node_label": "Start",
"properties": {
"attempt": 1,
"handler_type": "start",
"index": 0,
"max_attempts": 1
},
"run_id": "[ULID]",
"ts": "[TIMESTAMP]"
},
{
"event": "stage.completed",
"id": "[EVENT_ID]",
"node_id": "start",
"node_label": "Start",
"properties": {
"attempt": 1,
"context_values": {
"current.preamble": "Goal: Route through the default approval path/n",
"current_node": "start",
"graph.goal": "Route through the default approval path",
"internal.fidelity": "compact",
"internal.node_visit_count": 1,
"internal.run_id": "[ULID]",
"internal.thread_id": null
},
"duration_ms": "[DURATION_MS]",
"index": 0,
"max_attempts": 1,
"node_visits": {
"start": 1
},
"status": "success"
},
"run_id": "[ULID]",
"ts": "[TIMESTAMP]"
},
{
"event": "edge.selected",
"id": "[EVENT_ID]",
"properties": {
"from_node": "start",
"is_jump": false,
"reason": "unconditional",
"stage_status": "success",
"to_node": "approve"
},
"run_id": "[ULID]",
"ts": "[TIMESTAMP]"
},
{
"event": "checkpoint.completed",
"id": "[EVENT_ID]",
"node_id": "start",
"node_label": "start",
"properties": {
"completed_nodes": [
"start"
],
"context_values": {
"current.preamble": "Goal: Route through the default approval path/n",
"current_node": "start",
"failure_class": "",
"failure_signature": "",
"graph.goal": "Route through the default approval path",
"internal.fidelity": "compact",
"internal.node_visit_count": 1,
"internal.retry_count.start": 0,
"internal.run_id": "[ULID]",
"internal.thread_id": null,
"outcome": "success"
},
"current_node": "start",
"next_node_id": "approve",
"node_outcomes": {
"start": {
"status": "success",
"usage": null
}
},
"node_visits": {
"start": 1
},
"status": "success"
},
"run_id": "[ULID]",
"ts": "[TIMESTAMP]"
},
{
"event": "stage.started",
"id": "[EVENT_ID]",
"node_id": "approve",
"node_label": "Approve?",
"properties": {
"attempt": 1,
"handler_type": "human",
"index": 1,
"max_attempts": 1
},
"run_id": "[ULID]",
"ts": "[TIMESTAMP]"
},
{
"event": "stage.completed",
"id": "[EVENT_ID]",
"node_id": "approve",
"node_label": "Approve?",
"properties": {
"attempt": 1,
"context_updates": {
"human.gate.label": "[A] Approve",
"human.gate.selected": "A"
},
"context_values": {
"current.preamble": "Goal: Route through the default approval path/n",
"current_node": "approve",
"failure_class": "",
"failure_signature": "",
"graph.goal": "Route through the default approval path",
"internal.fidelity": "compact",
"internal.node_visit_count": 1,
"internal.retry_count.start": 0,
"internal.run_id": "[ULID]",
"internal.thread_id": "start",
"outcome": "success",
"thread.start.current_node": "approve"
},
"duration_ms": "[DURATION_MS]",
"index": 1,
"max_attempts": 1,
"node_visits": {
"approve": 1,
"start": 1
},
"preferred_label": "[A] Approve",
"status": "success",
"suggested_next_ids": [
"ship"
]
},
"run_id": "[ULID]",
"ts": "[TIMESTAMP]"
},
{
"event": "edge.selected",
"id": "[EVENT_ID]",
"properties": {
"from_node": "approve",
"is_jump": false,
"label": "[A] Approve",
"preferred_label": "[A] Approve",
"reason": "preferred_label",
"stage_status": "success",
"suggested_next_ids": [
"ship"
],
"to_node": "ship"
},
"run_id": "[ULID]",
"ts": "[TIMESTAMP]"
},
{
"event": "checkpoint.completed",
"id": "[EVENT_ID]",
"node_id": "approve",
"node_label": "approve",
"properties": {
"completed_nodes": [
"start",
"approve"
],
"context_values": {
"current.preamble": "Goal: Route through the default approval path/n",
"current_node": "approve",
"failure_class": "",
"failure_signature": "",
"graph.goal": "Route through the default approval path",
"human.gate.label": "[A] Approve",
"human.gate.selected": "A",
"internal.fidelity": "compact",
"internal.node_visit_count": 1,
"internal.retry_count.approve": 0,
"internal.retry_count.start": 0,
"internal.run_id": "[ULID]",
"internal.thread_id": "start",
"outcome": "success",
"preferred_label": "[A] Approve",
"thread.start.current_node": "approve"
},
"current_node": "approve",
"next_node_id": "ship",
"node_outcomes": {
"approve": {
"context_updates": {
"human.gate.label": "[A] Approve",
"human.gate.selected": "A"
},
"preferred_label": "[A] Approve",
"status": "success",
"suggested_next_ids": [
"ship"
],
"usage": null
},
"start": {
"status": "success",
"usage": null
}
},
"node_visits": {
"approve": 1,
"start": 1
},
"status": "success"
},
"run_id": "[ULID]",
"ts": "[TIMESTAMP]"
},
{
"event": "stage.started",
"id": "[EVENT_ID]",
"node_id": "ship",
"node_label": "ship",
"properties": {
"attempt": 1,
"handler_type": "command",
"index": 2,
"max_attempts": 1
},
"run_id": "[ULID]",
"ts": "[TIMESTAMP]"
},
{
"event": "command.started",
"id": "[EVENT_ID]",
"node_id": "ship",
"node_label": "ship",
"properties": {
"command": "echo shipped",
"language": "shell",
"script": "echo shipped"
},
"run_id": "[ULID]",
"ts": "[TIMESTAMP]"
},
{
"event": "command.completed",
"id": "[EVENT_ID]",
"node_id": "ship",
"node_label": "ship",
"properties": {
"duration_ms": "[DURATION_MS]",
"exit_code": 0,
"stderr": "",
"stdout": "shipped/n",
"timed_out": false
},
"run_id": "[ULID]",
"ts": "[TIMESTAMP]"
},
{
"event": "stage.completed",
"id": "[EVENT_ID]",
"node_id": "ship",
"node_label": "ship",
"properties": {
"attempt": 1,
"context_updates": {
"command.output": "shipped/n",
"command.stderr": ""
},
"context_values": {
"current.preamble": "Goal: Route through the default approval path/n/n## Completed stages/n- **approve**: success/n/n## Context/n- human.gate.label: [A] Approve/n- human.gate.selected: A/n",
"current_node": "ship",
"failure_class": "",
"failure_signature": "",
"graph.goal": "Route through the default approval path",
"human.gate.label": "[A] Approve",
"human.gate.selected": "A",
"internal.fidelity": "compact",
"internal.node_visit_count": 1,
"internal.retry_count.approve": 0,
"internal.retry_count.start": 0,
"internal.run_id": "[ULID]",
"internal.thread_id": "approve",
"outcome": "success",
"preferred_label": "[A] Approve",
"thread.approve.current_node": "ship",
"thread.start.current_node": "approve"
},
"duration_ms": "[DURATION_MS]",
"index": 2,
"max_attempts": 1,
"node_visits": {
"approve": 1,
"ship": 1,
"start": 1
},
"notes": "Script completed: echo shipped",
"status": "success"
},
"run_id": "[ULID]",
"ts": "[TIMESTAMP]"
},
{
"event": "edge.selected",
"id": "[EVENT_ID]",
"properties": {
"from_node": "ship",
"is_jump": false,
"reason": "unconditional",
"stage_status": "success",
"to_node": "exit"
},
"run_id": "[ULID]",
"ts": "[TIMESTAMP]"
},
{
"event": "checkpoint.completed",
"id": "[EVENT_ID]",
"node_id": "ship",
"node_label": "ship",
"properties": {
"completed_nodes": [
"start",
"approve",
"ship"
],
"context_values": {
"command.output": "shipped/n",
"command.stderr": "",
"current.preamble": "Goal: Route through the default approval path/n/n## Completed stages/n- **approve**: success/n/n## Context/n- human.gate.label: [A] Approve/n- human.gate.selected: A/n",
"current_node": "ship",
"failure_class": "",
"failure_signature": "",
"graph.goal": "Route through the default approval path",
"human.gate.label": "[A] Approve",
"human.gate.selected": "A",
"internal.fidelity": "compact",
"internal.node_visit_count": 1,
"internal.retry_count.approve": 0,
"internal.retry_count.ship": 0,
"internal.retry_count.start": 0,
"internal.run_id": "[ULID]",
"internal.thread_id": "approve",
"outcome": "success",
"preferred_label": "[A] Approve",
"thread.approve.current_node": "ship",
"thread.start.current_node": "approve"
},
"current_node": "ship",
"next_node_id": "exit",
"node_outcomes": {
"approve": {
"context_updates": {
"human.gate.label": "[A] Approve",
"human.gate.selected": "A"
},
"preferred_label": "[A] Approve",
"status": "success",
"suggested_next_ids": [
"ship"
],
"usage": null
},
"ship": {
"context_updates": {
"command.output": "shipped/n",
"command.stderr": ""
},
"notes": "Script completed: echo shipped",
"status": "success",
"usage": null
},
"start": {
"status": "success",
"usage": null
}
},
"node_visits": {
"approve": 1,
"ship": 1,
"start": 1
},
"status": "success"
},
"run_id": "[ULID]",
"ts": "[TIMESTAMP]"
},
{
"event": "stage.started",
"id": "[EVENT_ID]",
"node_id": "exit",
"node_label": "Exit",
"properties": {
"attempt": 1,
"handler_type": "exit",
"index": 3,
"max_attempts": 1
},
"run_id": "[ULID]",
"ts": "[TIMESTAMP]"
},
{
"event": "stage.completed",
"id": "[EVENT_ID]",
"node_id": "exit",
"node_label": "Exit",
"properties": {
"attempt": 1,
"duration_ms": "[DURATION_MS]",
"index": 3,
"max_attempts": 1,
"status": "success"
},
"run_id": "[ULID]",
"ts": "[TIMESTAMP]"
},
{
"event": "run.completed",
"id": "[EVENT_ID]",
"properties": {
"artifact_count": 0,
"duration_ms": "[DURATION_MS]",
"reason": "completed",
"status": "success"
},
"run_id": "[ULID]",
"ts": "[TIMESTAMP]"
}
]
"#);
assert_eq!(
progress[0].pointer("/properties/settings/auto_approve"),
Some(&serde_json::json!(true))
);
}
#[test]
fn detach_prints_ulid_and_exits() {
let context = test_context!();
let mut cmd = context.run_cmd();
cmd.args([
"--detach",
"--dry-run",
"--auto-approve",
example_fixture("simple.fabro").to_str().unwrap(),
]);
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
[ULID]
----- stderr -----
");
}
#[test]
fn detach_creates_run_dir_with_detach_log() {
let context = test_context!();
let run_id = unique_run_id();
context
.run_cmd()
.args([
"--detach",
"--dry-run",
"--auto-approve",
"--run-id",
run_id.as_str(),
example_fixture("simple.fabro").to_str().unwrap(),
])
.assert()
.success();
let run_dir = context.find_run_dir(&run_id);
fabro_json_snapshot!(
context,
serde_json::json!({
"run_dir": run_dir,
"launcher_log_exists": context.storage_dir.join("launchers").join(format!("{run_id}.log")).exists(),
"detach_log_exists": run_dir.join("detach.log").exists(),
}),
@r#"
{
"run_dir": "[RUN_DIR]",
"launcher_log_exists": false,
"detach_log_exists": false
}
"#
);
}
#[test]
fn ctrl_c_cancels_active_run_via_server() {
let context = test_context!();
let gate = write_gated_workflow(&context.temp_dir.join("slow.fabro"), "slow", "Run slowly");
let run_id = unique_run_id();
let mut run_cmd = std::process::Command::new(env!("CARGO_BIN_EXE_fabro"));
run_cmd.current_dir(&context.temp_dir);
run_cmd.env("NO_COLOR", "1");
run_cmd.env("HOME", &context.home_dir);
run_cmd.env("FABRO_NO_UPGRADE_CHECK", "true");
run_cmd.env("FABRO_TEST_IN_MEMORY_STORE", "1");
run_cmd.env("FABRO_STORAGE_DIR", &context.storage_dir);
run_cmd.env("FABRO_SERVER_MAX_CONCURRENT_RUNS", "64");
run_cmd.env("OPENAI_API_KEY", "test");
run_cmd.args([
"run",
"--run-id",
run_id.as_str(),
"--label",
&context.test_run_label(),
"--label",
&context.test_case_label(),
"--provider",
"openai",
"--sandbox",
"local",
"--no-retro",
"slow.fabro",
]);
let child = run_cmd.spawn().expect("run should spawn");
let run = resolve_run(&context, &run_id);
wait_for_status(&run.run_dir, &["running"]);
let kill_status = std::process::Command::new("kill")
.args(["-INT", &child.id().to_string()])
.status()
.expect("kill should execute");
assert!(kill_status.success(), "kill -INT should succeed");
let output = child
.wait_with_output()
.expect("run should exit after SIGINT");
assert!(
!output.status.success(),
"run should exit non-zero after cancellation\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
output_stderr(&output)
);
let final_status = wait_for_status(&run.run_dir, &["failed"]);
assert_eq!(final_status, "failed");
assert_eq!(
run_state(&run.run_dir)
.status
.and_then(|record| record.reason),
Some(StatusReason::Cancelled)
);
let gate_pattern = gate.gate_path().to_string_lossy().into_owned();
wait_for_no_process_match(&gate_pattern);
}