fabro/lib/crates/fabro-cli/tests/it/cmd/support.rs
Bryan Helmkamp bc7a926e26
feat(artifacts): remove scratch artifact cache staging
Stage captured artifacts in per-attempt tempdirs and persist them through an
explicit artifact sink instead of writing into run scratch cache.

Server-managed and test-owned runs now write directly to ArtifactStore, while
CLI worker runs keep the staged upload path. The local run summary now prints
durable artifact identifiers and copy hints rather than scratch-cache paths,
and the run-directory docs and integration coverage were updated to match.
2026-04-08 17:42:55 -04:00

1063 lines
35 KiB
Rust

#![allow(
clippy::absolute_paths,
clippy::manual_assert,
clippy::redundant_closure_for_method_calls
)]
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::process::Output;
use std::time::{Duration, Instant};
use crate::support::unique_run_id;
use fabro_config::Storage;
use fabro_server::bind::Bind;
use fabro_store::EventEnvelope;
use fabro_test::TestContext;
use fabro_types::RunId;
use serde_json::Value;
use shlex::try_quote;
const COMMAND_TIMEOUT: Duration = Duration::from_secs(30);
pub(crate) use fabro_store::RunProjection;
#[derive(Debug, Clone, Default, serde::Deserialize)]
struct RunSummaryRecord {
run_id: String,
#[serde(default)]
labels: std::collections::HashMap<String, String>,
}
pub(crate) struct RunSetup {
pub(crate) run_id: String,
pub(crate) run_dir: PathBuf,
}
pub(crate) struct GitRunSetup {
pub(crate) run: RunSetup,
pub(crate) repo_dir: PathBuf,
pub(crate) base_sha: String,
}
pub(crate) struct ProjectFixture {
pub(crate) project_dir: PathBuf,
pub(crate) fabro_root: PathBuf,
}
pub(crate) struct WorkspaceRunSetup {
pub(crate) run: RunSetup,
pub(crate) workspace_dir: PathBuf,
}
pub(crate) struct WorkflowGate {
gate_path: PathBuf,
}
#[derive(Clone, Copy)]
enum GitWorkflowKind {
Changed,
Noop,
}
pub(crate) fn fixture(name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join(format!("../../../test/{name}"))
.canonicalize()
.expect("fixture path should exist")
}
pub(crate) fn output_stderr(output: &Output) -> String {
stderr(output)
}
pub(crate) fn output_stdout(output: &Output) -> String {
stdout(output)
}
pub(crate) fn read_text(path: &Path) -> String {
std::fs::read_to_string(path)
.unwrap_or_else(|err| panic!("failed to read {}: {err}", path.display()))
}
fn stdout(output: &Output) -> String {
String::from_utf8(output.stdout.clone()).expect("stdout should be valid UTF-8")
}
fn stderr(output: &Output) -> String {
String::from_utf8(output.stderr.clone()).expect("stderr should be valid UTF-8")
}
pub(crate) fn run_success(context: &TestContext, args: &[&str]) -> Output {
run_success_in(context, args, &context.temp_dir)
}
fn run_success_in(context: &TestContext, args: &[&str], cwd: &Path) -> Output {
let mut cmd = context.command();
cmd.current_dir(cwd);
cmd.timeout(COMMAND_TIMEOUT);
cmd.args(args);
let output = cmd.output().expect("command should execute");
if !output.status.success() {
panic!(
"command failed: fabro {}\nstdout:\n{}\nstderr:\n{}",
args.join(" "),
stdout(&output),
stderr(&output)
);
}
output
}
pub(crate) fn setup_completed_dry_run(context: &TestContext) -> RunSetup {
let workflow = fixture("simple.fabro");
run_completed_dry_run(context, &workflow)
}
pub(crate) fn setup_completed_fast_dry_run(context: &TestContext) -> RunSetup {
let workflow = fast_simple_workflow(context);
run_completed_dry_run(context, &workflow)
}
fn run_completed_dry_run(context: &TestContext, workflow: &Path) -> RunSetup {
let run_id = unique_run_id();
let mut cmd = context.run_cmd();
cmd.current_dir(&context.temp_dir);
cmd.timeout(COMMAND_TIMEOUT);
cmd.args([
"--run-id",
run_id.as_str(),
"--dry-run",
"--auto-approve",
"--no-retro",
"--sandbox",
"local",
]);
cmd.arg(workflow);
let output = cmd.output().expect("command should execute");
if !output.status.success() {
panic!(
"command failed: fabro run --dry-run --auto-approve --no-retro --sandbox local {}\nstdout:\n{}\nstderr:\n{}",
workflow.display(),
stdout(&output),
stderr(&output)
);
}
let run_setup = RunSetup {
run_dir: context.find_run_dir(&run_id),
run_id,
};
wait_for_event_names(
&run_setup.run_dir,
&["run.completed", "sandbox.cleanup.completed"],
);
run_setup
}
pub(crate) fn setup_created_dry_run(context: &TestContext) -> RunSetup {
let workflow = fixture("simple.fabro");
run_created_dry_run(context, &workflow)
}
pub(crate) fn setup_created_fast_dry_run(context: &TestContext) -> RunSetup {
let workflow = fast_simple_workflow(context);
run_created_dry_run(context, &workflow)
}
fn run_created_dry_run(context: &TestContext, workflow: &Path) -> RunSetup {
let run_id = unique_run_id();
let mut cmd = context.create_cmd();
cmd.current_dir(&context.temp_dir);
cmd.timeout(COMMAND_TIMEOUT);
cmd.args([
"--run-id",
run_id.as_str(),
"--dry-run",
"--auto-approve",
"--no-retro",
"--sandbox",
"local",
]);
cmd.arg(workflow);
let output = cmd.output().expect("command should execute");
if !output.status.success() {
panic!(
"command failed: fabro create --dry-run --auto-approve --no-retro --sandbox local {}\nstdout:\n{}\nstderr:\n{}",
workflow.display(),
stdout(&output),
stderr(&output)
);
}
assert_eq!(stdout(&output).trim(), run_id);
RunSetup {
run_dir: context.find_run_dir(&run_id),
run_id,
}
}
fn fast_simple_workflow(context: &TestContext) -> PathBuf {
let workflow = context.temp_dir.join("simple.fabro");
if !workflow.exists() {
write_text_file(
&workflow,
r#"digraph Simple {
graph [goal="Run tests and report results"]
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
run_tests [shape=parallelogram, label="Run Tests", script="true"]
report [shape=parallelogram, label="Report", script="true"]
start -> run_tests -> report -> exit
}
"#,
);
}
workflow
}
pub(crate) fn setup_detached_dry_run(context: &TestContext) -> RunSetup {
let workflow = fixture("simple.fabro");
let run_id = unique_run_id();
let mut cmd = context.run_cmd();
cmd.current_dir(&context.temp_dir);
cmd.timeout(COMMAND_TIMEOUT);
cmd.args([
"--run-id",
run_id.as_str(),
"--detach",
"--dry-run",
"--auto-approve",
"--no-retro",
"--sandbox",
"local",
]);
cmd.arg(workflow);
let output = cmd.output().expect("command should execute");
if !output.status.success() {
panic!(
"command failed: fabro run --detach --dry-run --auto-approve --no-retro --sandbox local {}\nstdout:\n{}\nstderr:\n{}",
fixture("simple.fabro").display(),
stdout(&output),
stderr(&output)
);
}
assert_eq!(stdout(&output).trim(), run_id);
let run = resolve_run(context, &run_id);
let deadline = Instant::now() + COMMAND_TIMEOUT;
while run_events(&run.run_dir).is_empty() {
assert!(
Instant::now() < deadline,
"timed out waiting for store events for {run_id}"
);
std::thread::sleep(Duration::from_millis(50));
}
run
}
pub(crate) fn setup_git_backed_changed_run(context: &TestContext) -> GitRunSetup {
setup_git_backed_run(context, GitWorkflowKind::Changed)
}
pub(crate) fn setup_git_backed_noop_run(context: &TestContext) -> GitRunSetup {
setup_git_backed_run(context, GitWorkflowKind::Noop)
}
pub(crate) fn setup_project_fixture(context: &TestContext) -> ProjectFixture {
let project_dir = context.temp_dir.join("project");
let fabro_root = project_dir.join("fabro");
write_text_file(
&project_dir.join("fabro.toml"),
"version = 1\n[fabro]\nroot = \"fabro/\"\n",
);
std::fs::create_dir_all(fabro_root.join("workflows"))
.unwrap_or_else(|err| panic!("failed to create {}: {err}", fabro_root.display()));
ProjectFixture {
project_dir,
fabro_root,
}
}
impl WorkflowGate {
pub(crate) fn release(&self) {
write_text_file(&self.gate_path, "open\n");
}
}
pub(crate) fn setup_artifact_run(context: &TestContext) -> WorkspaceRunSetup {
let workspace_dir = context.temp_dir.join("artifact-run");
std::fs::create_dir_all(&workspace_dir)
.unwrap_or_else(|err| panic!("failed to create {}: {err}", workspace_dir.display()));
write_text_file(
&workspace_dir.join("artifact_run.fabro"),
r#"digraph ArtifactRun {
graph [goal="Exercise artifact commands", default_max_retries=0]
start [shape=Mdiamond]
exit [shape=Msquare]
create_assets [shape=parallelogram, script="mkdir -p assets/shared assets/node_a && printf one > assets/shared/report.txt && printf alpha > assets/node_a/summary.txt", max_retries=0]
retry_assets [shape=parallelogram, script="mkdir -p assets/retry && touch -c -t 200001010000 assets/shared/report.txt assets/node_a/summary.txt && if [ ! -f .retry-sentinel ]; then printf first > assets/retry/report.txt && touch .retry-sentinel && sleep 1; else printf second > assets/retry/report.txt; fi", retry_policy="linear", timeout="500ms"]
create_colliding [shape=parallelogram, script="mkdir -p assets/other assets/retry && touch -c -t 200001010000 assets/shared/report.txt assets/node_a/summary.txt assets/retry/report.txt && printf beta > assets/other/summary.txt && printf second > assets/retry/report.txt", max_retries=0]
start -> create_assets -> retry_assets -> create_colliding -> exit
}
"#,
);
write_text_file(
&workspace_dir.join("run.toml"),
r#"version = 1
graph = "artifact_run.fabro"
goal = "Exercise artifact commands"
[sandbox]
provider = "local"
preserve = true
[sandbox.local]
worktree_mode = "never"
[artifacts]
include = ["assets/**"]
"#,
);
let run = run_local_workflow(context, &workspace_dir, "run.toml");
WorkspaceRunSetup { run, workspace_dir }
}
pub(crate) fn setup_local_sandbox_run(context: &TestContext) -> WorkspaceRunSetup {
let workspace_dir = context.temp_dir.join("local-sandbox");
std::fs::create_dir_all(&workspace_dir)
.unwrap_or_else(|err| panic!("failed to create {}: {err}", workspace_dir.display()));
write_text_file(
&workspace_dir.join("sandbox_run.fabro"),
r#"digraph SandboxRun {
graph [goal="Exercise sandbox commands", default_max_retries=0]
start [shape=Mdiamond]
exit [shape=Msquare]
populate_sandbox [shape=parallelogram, script="mkdir -p sandbox_dir/download_me/nested && printf keep > sandbox_dir/download_me/root.txt && printf nested > sandbox_dir/download_me/nested/child.txt", max_retries=0]
start -> populate_sandbox -> exit
}
"#,
);
write_text_file(
&workspace_dir.join("run.toml"),
r#"version = 1
graph = "sandbox_run.fabro"
goal = "Exercise sandbox commands"
[sandbox]
provider = "local"
preserve = true
[sandbox.local]
worktree_mode = "never"
"#,
);
let run = run_local_workflow(context, &workspace_dir, "run.toml");
assert!(run_state(&run.run_dir).sandbox.is_some());
WorkspaceRunSetup { run, workspace_dir }
}
fn run_local_workflow(context: &TestContext, workspace_dir: &Path, workflow: &str) -> RunSetup {
let run_id = unique_run_id();
let mut cmd = context.run_cmd();
cmd.current_dir(workspace_dir);
cmd.timeout(COMMAND_TIMEOUT);
cmd.env("OPENAI_API_KEY", "test");
cmd.args([
"--run-id",
run_id.as_str(),
"--auto-approve",
"--no-retro",
"--sandbox",
"local",
"--provider",
"openai",
workflow,
]);
let output = cmd.output().expect("command should execute");
if !output.status.success() {
panic!(
"command failed: fabro run --auto-approve --no-retro --sandbox local --provider openai {workflow}\nstdout:\n{}\nstderr:\n{}",
stdout(&output),
stderr(&output)
);
}
RunSetup {
run_dir: context.find_run_dir(&run_id),
run_id,
}
}
pub(crate) fn add_project_workflow(
project: &ProjectFixture,
name: &str,
goal: &str,
dot_source: &str,
) -> PathBuf {
let workflow_dir = project.fabro_root.join("workflows").join(name);
std::fs::create_dir_all(&workflow_dir)
.unwrap_or_else(|err| panic!("failed to create {}: {err}", workflow_dir.display()));
write_text_file(&workflow_dir.join("workflow.fabro"), dot_source);
write_text_file(
&workflow_dir.join("workflow.toml"),
&format!("version = 1\ngoal = {goal:?}\ngraph = \"workflow.fabro\"\n"),
);
workflow_dir
}
pub(crate) fn add_user_workflow(context: &TestContext, name: &str, goal: &str) -> PathBuf {
let workflow_dir = context.home_dir.join(".fabro/workflows").join(name);
std::fs::create_dir_all(&workflow_dir)
.unwrap_or_else(|err| panic!("failed to create {}: {err}", workflow_dir.display()));
write_text_file(
&workflow_dir.join("workflow.toml"),
&format!("version = 1\ngoal = {goal:?}\ngraph = \"workflow.fabro\"\n"),
);
write_text_file(
&workflow_dir.join("workflow.fabro"),
&format!(
"digraph {} {{\n graph [goal={goal:?}]\n start [shape=Mdiamond]\n exit [shape=Msquare]\n start -> exit\n}}\n",
to_pascal_case(name),
),
);
workflow_dir
}
pub(crate) fn write_gated_workflow(path: &Path, name: &str, goal: &str) -> WorkflowGate {
let gate_path = path.with_extension("gate");
let _ = std::fs::remove_file(&gate_path);
let gate_path_str = gate_path.to_string_lossy().into_owned();
let quoted_gate_path = try_quote(&gate_path_str)
.unwrap_or_else(|_| panic!("failed to quote {}", gate_path.display()));
write_text_file(
path,
&format!(
"digraph {} {{\n graph [goal={goal:?}]\n start [shape=Mdiamond]\n exit [shape=Msquare]\n wait [shape=parallelogram, script=\"while [ ! -f {quoted_gate_path} ]; do sleep 0.01; done\"]\n start -> wait -> exit\n}}\n",
to_pascal_case(name),
),
);
WorkflowGate { gate_path }
}
pub(crate) fn wait_for_status(run_dir: &Path, expected: &[&str]) -> String {
let deadline = Instant::now() + COMMAND_TIMEOUT;
loop {
if let Some(status) = run_state(run_dir)
.status
.map(|record| record.status.to_string())
{
if expected.iter().any(|candidate| *candidate == status) {
return status;
}
}
assert!(
Instant::now() < deadline,
"timed out waiting for status {:?} in {}",
expected,
run_dir.display()
);
std::thread::sleep(Duration::from_millis(50));
}
}
pub(crate) fn run_count_for_test_case(context: &TestContext) -> usize {
run_dirs_for_test_case(context).len()
}
fn run_dirs_for_test_case(context: &TestContext) -> Vec<PathBuf> {
let runs: Option<Vec<RunSummaryRecord>> = block_on(try_get_server_json_for_storage(
&context.storage_dir,
"/api/v1/runs",
));
let Some(runs) = runs else {
return Vec::new();
};
runs.into_iter()
.filter(|run| {
run.labels
.get("fabro_test_case")
.is_some_and(|value| value == context.test_case_id())
})
.filter_map(|run| find_run_dir(&context.storage_dir, &run.run_id))
.collect()
}
pub(crate) fn git_filters(context: &TestContext) -> Vec<(String, String)> {
let mut filters = context.filters();
filters.push((r"\b[0-9a-f]{7,40}\b".to_string(), "[SHA]".to_string()));
filters.push((
r"(fabro resume )[0-9A-HJKMNP-TV-Z]{8}\b".to_string(),
"$1[RUN_PREFIX]".to_string(),
));
filters.push((
r"(Forked run )[0-9A-HJKMNP-TV-Z]{8}\b".to_string(),
"$1[RUN_PREFIX]".to_string(),
));
filters.push((
r"(-> )[0-9A-HJKMNP-TV-Z]{8}\b".to_string(),
"$1[RUN_PREFIX]".to_string(),
));
filters
}
pub(crate) fn resolve_run(context: &TestContext, run_id: &str) -> RunSetup {
let deadline = Instant::now() + COMMAND_TIMEOUT;
loop {
if let Some(run_dir) = find_run_dir(&context.storage_dir, run_id) {
return RunSetup {
run_id: run_id.to_string(),
run_dir,
};
}
assert!(
Instant::now() < deadline,
"timed out waiting for run dir for {run_id}"
);
std::thread::sleep(Duration::from_millis(50));
}
}
pub(crate) fn find_run_dir(storage_dir: &Path, run_id: &str) -> Option<PathBuf> {
if let Ok(run_id) = run_id.parse::<RunId>() {
let run_dir = Storage::new(storage_dir)
.run_scratch(&run_id)
.root()
.to_path_buf();
if run_dir.is_dir() {
return Some(run_dir);
}
}
let runs_dir = storage_dir.join("scratch");
let entries = std::fs::read_dir(&runs_dir).ok()?;
entries
.filter_map(Result::ok)
.map(|entry| entry.path())
.find(|path| {
path.is_dir()
&& path
.file_name()
.is_some_and(|name| name.to_string_lossy().ends_with(run_id))
})
}
fn infer_run_id(run_dir: &Path) -> String {
run_dir
.file_name()
.map(|name| name.to_string_lossy().to_string())
.and_then(|name| name.rsplit('-').next().map(ToOwned::to_owned))
.filter(|value| !value.is_empty())
.expect("run directory name should contain run id suffix")
}
fn block_on<T>(future: impl std::future::Future<Output = T>) -> T {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(future)
}
#[derive(Debug, serde::Deserialize)]
struct TestServerRecord {
bind: Bind,
}
fn server_endpoint(storage_dir: &Path) -> Option<(reqwest::Client, String)> {
let record_path = Storage::new(storage_dir).server_state().record_path();
let record = std::fs::read_to_string(record_path)
.ok()
.and_then(|content| serde_json::from_str::<TestServerRecord>(&content).ok())?;
match record.bind {
Bind::Unix(path) if path.exists() => Some((
reqwest::ClientBuilder::new()
.unix_socket(path)
.no_proxy()
.build()
.expect("test Unix-socket HTTP client should build"),
"http://fabro".to_string(),
)),
Bind::Unix(_) => None,
Bind::Tcp(addr) => Some((
reqwest::ClientBuilder::new()
.no_proxy()
.build()
.expect("test TCP HTTP client should build"),
format!("http://{addr}"),
)),
}
}
pub(crate) fn server_target(storage_dir: &Path) -> String {
let record_path = Storage::new(storage_dir).server_state().record_path();
let record = std::fs::read_to_string(record_path)
.ok()
.and_then(|content| serde_json::from_str::<TestServerRecord>(&content).ok())
.expect("server record should exist");
match record.bind {
Bind::Unix(path) => path.to_string_lossy().to_string(),
Bind::Tcp(addr) => format!("http://{addr}"),
}
}
async fn get_server_json<T: serde::de::DeserializeOwned>(run_dir: &Path, path: &str) -> T {
let runs_dir = run_dir.parent().expect("run dir should have parent");
let storage_dir = runs_dir.parent().expect("runs dir should have parent");
get_server_json_for_storage(storage_dir, path).await
}
async fn try_get_server_json_for_storage<T: serde::de::DeserializeOwned>(
storage_dir: &Path,
path: &str,
) -> Option<T> {
let (client, base_url) = server_endpoint(storage_dir)?;
let response = client.get(format!("{base_url}{path}")).send().await.ok()?;
if !response.status().is_success() {
return None;
}
response.json::<T>().await.ok()
}
async fn get_server_json_for_storage<T: serde::de::DeserializeOwned>(
storage_dir: &Path,
path: &str,
) -> T {
let (client, base_url) = server_endpoint(storage_dir).expect("server endpoint should exist");
let response = client
.get(format!("{base_url}{path}"))
.send()
.await
.expect("server request should succeed");
assert!(
response.status().is_success(),
"server request failed for {path}: {}",
response.status()
);
response
.json::<T>()
.await
.expect("server response should parse")
}
pub(crate) fn run_state(run_dir: &Path) -> RunProjection {
let run_id = infer_run_id(run_dir);
block_on(get_server_json(
run_dir,
&format!("/api/v1/runs/{run_id}/state"),
))
}
pub(crate) fn run_events(run_dir: &Path) -> Vec<EventEnvelope> {
let run_id = infer_run_id(run_dir);
let response: serde_json::Value = block_on(get_server_json(
run_dir,
&format!("/api/v1/runs/{run_id}/events"),
));
serde_json::from_value(response["data"].clone()).expect("event list should parse")
}
pub(crate) fn wait_for_event_names(run_dir: &Path, expected: &[&str]) {
let deadline = std::time::Instant::now() + COMMAND_TIMEOUT;
loop {
let event_names = run_events(run_dir)
.into_iter()
.filter_map(|event| {
event
.payload
.as_value()
.get("event")
.and_then(serde_json::Value::as_str)
.map(ToString::to_string)
})
.collect::<Vec<_>>();
if expected
.iter()
.all(|expected_name| event_names.iter().any(|name| name == expected_name))
{
return;
}
assert!(
std::time::Instant::now() < deadline,
"timed out waiting for events {expected:?}; saw {event_names:?}"
);
std::thread::sleep(std::time::Duration::from_millis(50));
}
}
pub(crate) fn git_stdout(repo_dir: &Path, args: &[&str]) -> String {
stdout(&git_success(repo_dir, args))
}
pub(crate) fn metadata_run_ids(repo_dir: &Path) -> BTreeSet<String> {
git_stdout(repo_dir, &["branch", "--format=%(refname:short)"])
.lines()
.map(str::trim)
.filter_map(|line| line.strip_prefix("fabro/meta/"))
.map(ToOwned::to_owned)
.collect()
}
pub(crate) fn run_branch_commits(repo_dir: &Path, run_id: &str) -> Vec<String> {
git_stdout(
repo_dir,
&["rev-list", "--reverse", &format!("fabro/run/{run_id}")],
)
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(ToOwned::to_owned)
.collect()
}
pub(crate) fn run_branch_commits_since_base(
repo_dir: &Path,
run_id: &str,
base_sha: &str,
) -> Vec<String> {
git_stdout(
repo_dir,
&[
"rev-list",
"--reverse",
&format!("{base_sha}..fabro/run/{run_id}"),
],
)
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(ToOwned::to_owned)
.collect()
}
pub(crate) fn git_show_json(repo_dir: &Path, revspec: &str) -> Value {
let output = git_success(repo_dir, &["show", revspec]);
serde_json::from_str(&stdout(&output))
.unwrap_or_else(|err| panic!("failed to parse git show {revspec}: {err}"))
}
pub(crate) fn text_tree(root: &Path) -> Vec<String> {
fn visit(root: &Path, dir: &Path, entries: &mut Vec<String>) {
let mut children: Vec<_> = std::fs::read_dir(dir)
.unwrap_or_else(|err| panic!("failed to read {}: {err}", dir.display()))
.filter_map(Result::ok)
.map(|entry| entry.path())
.collect();
children.sort();
for path in children {
if path.is_dir() {
visit(root, &path, entries);
continue;
}
let rel = path
.strip_prefix(root)
.unwrap_or_else(|err| panic!("failed to strip prefix {}: {err}", root.display()))
.display()
.to_string();
let contents = std::fs::read_to_string(&path)
.unwrap_or_else(|err| panic!("failed to read {}: {err}", path.display()));
entries.push(format!("{rel} = {contents}"));
}
}
if !root.exists() {
return Vec::new();
}
let mut entries = Vec::new();
visit(root, root, &mut entries);
entries
}
pub(crate) fn compact_inspect(output: &Output) -> Value {
let items: Vec<Value> =
serde_json::from_str(&stdout(output)).expect("inspect output should be valid JSON");
Value::Array(
items.into_iter()
.map(|item| {
let run_record = item["run_record"].clone();
let checkpoint = item["checkpoint"].clone();
let conclusion = item["conclusion"].clone();
let sandbox = item["sandbox"].clone();
serde_json::json!({
"run_id": "[ULID]",
"status": item["status"],
"run_record": {
"goal": run_record.pointer("/settings/goal"),
"workflow_name": run_record.pointer("/graph/name"),
"workflow_slug": run_record.pointer("/workflow_slug"),
"sandbox_provider": run_record.pointer("/settings/sandbox/provider"),
"dry_run": run_record.pointer("/settings/dry_run"),
"provenance": run_record.pointer("/provenance").as_ref().map(|_| {
serde_json::json!({
"server_version": "[VERSION]",
"client_name": run_record.pointer("/provenance/client/name"),
"client_version": "[VERSION]",
"subject_auth_method": run_record.pointer("/provenance/subject/auth_method"),
})
}),
},
"start_record": item["start_record"].as_object().map(|record| {
serde_json::json!({
"has_start_time": record.contains_key("start_time"),
})
}),
"conclusion": conclusion.as_object().map(|_| {
serde_json::json!({
"status": conclusion["status"],
"duration_ms": "[DURATION_MS]",
"stage_count": conclusion["stages"].as_array().map(|stages| stages.len()),
})
}),
"checkpoint": checkpoint.as_object().map(|_| {
serde_json::json!({
"current_node": checkpoint["current_node"],
"completed_nodes": checkpoint["completed_nodes"],
"next_node_id": checkpoint["next_node_id"],
})
}),
"sandbox": sandbox.as_object().map(|_| {
serde_json::json!({
"provider": sandbox["provider"],
})
}),
})
})
.collect(),
)
}
pub(crate) fn compact_git_inspect(output: &Output) -> Value {
let items: Vec<Value> =
serde_json::from_str(&stdout(output)).expect("inspect output should be valid JSON");
Value::Array(
items.into_iter()
.map(|item| {
let run_record = item["run_record"].clone();
let start_record = item["start_record"].clone();
let checkpoint = item["checkpoint"].clone();
let conclusion = item["conclusion"].clone();
let sandbox = item["sandbox"].clone();
serde_json::json!({
"run_id": "[ULID]",
"status": item["status"],
"run_record": {
"goal": run_record.pointer("/settings/goal"),
"workflow_name": run_record.pointer("/graph/name"),
"workflow_slug": run_record.pointer("/workflow_slug"),
"llm_provider": run_record.pointer("/settings/llm/provider"),
"sandbox_provider": run_record.pointer("/settings/sandbox/provider"),
"provenance": run_record.pointer("/provenance").as_ref().map(|_| {
serde_json::json!({
"server_version": "[VERSION]",
"client_name": run_record.pointer("/provenance/client/name"),
"client_version": "[VERSION]",
"subject_auth_method": run_record.pointer("/provenance/subject/auth_method"),
})
}),
},
"start_record": start_record.as_object().map(|_| {
serde_json::json!({
"has_start_time": true,
"run_branch": "fabro/run/[ULID]",
"base_sha": "[SHA]",
})
}),
"conclusion": conclusion.as_object().map(|_| {
serde_json::json!({
"status": conclusion["status"],
"duration_ms": "[DURATION_MS]",
"final_git_commit_sha": "[SHA]",
"stage_count": conclusion["stages"].as_array().map(|stages| stages.len()),
})
}),
"checkpoint": checkpoint.as_object().map(|_| {
serde_json::json!({
"current_node": checkpoint["current_node"],
"completed_nodes": checkpoint["completed_nodes"],
"next_node_id": checkpoint["next_node_id"],
"git_commit_sha": "[SHA]",
})
}),
"sandbox": sandbox.as_object().map(|_| {
serde_json::json!({
"provider": sandbox["provider"],
"working_directory": "[WORKTREE]",
})
}),
})
})
.collect(),
)
}
fn setup_git_backed_run(context: &TestContext, workflow: GitWorkflowKind) -> GitRunSetup {
let repo_dir = context.temp_dir.join(match workflow {
GitWorkflowKind::Changed => "git-changed",
GitWorkflowKind::Noop => "git-noop",
});
std::fs::create_dir_all(&repo_dir)
.unwrap_or_else(|err| panic!("failed to create {}: {err}", repo_dir.display()));
git_success(&repo_dir, &["init", "-q"]);
git_success(&repo_dir, &["config", "user.name", "Fabro Test"]);
git_success(&repo_dir, &["config", "user.email", "test@example.com"]);
write_text_file(&repo_dir.join("story.txt"), "line 1\n");
write_text_file(
&repo_dir.join("flow.fabro"),
match workflow {
GitWorkflowKind::Changed => {
r#"digraph Flow {
graph [goal="Edit a tracked file"];
start [shape=Mdiamond];
exit [shape=Msquare];
step_one [shape=parallelogram, script="printf 'line 1\nline 2\n' > story.txt"];
step_two [shape=parallelogram, script="printf 'line 1\nline 2\nline 3\n' > story.txt"];
start -> step_one -> step_two -> exit;
}
"#
}
GitWorkflowKind::Noop => {
r#"digraph Flow {
graph [goal="Leave tracked files unchanged"];
start [shape=Mdiamond];
exit [shape=Msquare];
check [shape=parallelogram, script="test -f story.txt"];
start -> check -> exit;
}
"#
}
},
);
git_success(&repo_dir, &["add", "story.txt", "flow.fabro"]);
git_success(&repo_dir, &["commit", "-qm", "init"]);
let base_sha = git_stdout(&repo_dir, &["rev-parse", "HEAD"])
.trim()
.to_string();
let run_id = unique_run_id();
let mut cmd = context.run_cmd();
cmd.current_dir(&repo_dir);
cmd.env("OPENAI_API_KEY", "test");
cmd.args([
"--run-id",
run_id.as_str(),
"--sandbox",
"local",
"--no-retro",
"--provider",
"openai",
"flow.fabro",
]);
let output = cmd.output().expect("command should execute");
if !output.status.success() {
panic!(
"command failed: fabro run --sandbox local --no-retro --provider openai flow.fabro\nstdout:\n{}\nstderr:\n{}",
stdout(&output),
stderr(&output)
);
}
let run = RunSetup {
run_dir: context.find_run_dir(&run_id),
run_id,
};
let start = serde_json::to_value(
run_state(&run.run_dir)
.start
.expect("start record should exist"),
)
.unwrap();
assert_eq!(
start["run_branch"].as_str(),
Some(format!("fabro/run/{}", run.run_id).as_str())
);
assert_eq!(start["base_sha"].as_str(), Some(base_sha.as_str()));
match workflow {
GitWorkflowKind::Changed => {
assert!(
run_state(&run.run_dir).final_patch.is_some(),
"changed git-backed run should persist final patch in store"
);
let state = run_state(&run.run_dir);
assert!(
state
.iter_nodes()
.any(|(node, state)| node.node_id() == "step_one" && state.diff.is_some())
);
assert!(
state
.iter_nodes()
.any(|(node, state)| node.node_id() == "step_two" && state.diff.is_some())
);
}
GitWorkflowKind::Noop => {
assert!(
run_state(&run.run_dir).final_patch.is_none(),
"no-op git-backed run should not persist final.patch"
);
}
}
GitRunSetup {
run,
repo_dir,
base_sha,
}
}
fn git_success(repo_dir: &Path, args: &[&str]) -> Output {
let output = std::process::Command::new("git")
.current_dir(repo_dir)
.args(args)
.output()
.expect("git command should execute");
if !output.status.success() {
panic!(
"git command failed: git {}\nstdout:\n{}\nstderr:\n{}",
args.join(" "),
stdout(&output),
stderr(&output)
);
}
output
}
fn write_text_file(path: &Path, content: &str) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.unwrap_or_else(|err| panic!("failed to create {}: {err}", parent.display()));
}
std::fs::write(path, content)
.unwrap_or_else(|err| panic!("failed to write {}: {err}", path.display()));
}
fn to_pascal_case(s: &str) -> String {
s.split(['-', '_'])
.filter(|part| !part.is_empty())
.map(|part| {
let mut chars = part.chars();
match chars.next() {
Some(first) => {
let upper: String = first.to_uppercase().collect();
format!("{upper}{rest}", rest = chars.as_str())
}
None => String::new(),
}
})
.collect()
}