mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
## Summary Removes Fabro's automatic retro generation stage so workflow runs go directly from execution to finalization and optional PR creation. This drops the retro-specific crate, events, projection fields, config/API knobs, and user-facing docs in favor of the existing durable run observability surfaces. ## What Changed - Deleted the `fabro-retro` crate and the workflow `retro` pipeline phase, with finalization now consuming `Executed` state directly. - Removed retro configuration and API surface area, including `--no-retro`, `[run.execution].retros`, manifest `no_retro`, `features.retros`, and run projection `retro*` fields. - Retired typed `retro.*` events while keeping historical event logs readable by deserializing retired retro event names as `Unknown`. - Stopped appending retro sections to generated PR bodies and updated docs, marketing copy, screenshots, and navigation to point users toward observability/event-stream inspection. ## Testing Not run during PR creation; this branch already contained the implementation commit. --- [](https://github.com/EveryInc/compound-engineering-plugin) 🤖 Generated with GPT-5 (unknown context, reasoning unspecified) via [Codex](https://openai.com/codex)
441 lines
15 KiB
Rust
441 lines
15 KiB
Rust
use fabro_client::ServerTarget;
|
|
use fabro_config::{Storage, envfile};
|
|
use fabro_test::{fabro_snapshot, test_context};
|
|
use fabro_util::dev_token;
|
|
use httpmock::MockServer;
|
|
use serde_json::Value;
|
|
|
|
use super::support::{
|
|
local_dev_token, setup_seeded_completed_dry_run, setup_seeded_created_dry_run,
|
|
};
|
|
use crate::support::{fatal_error_line, seed_dev_token_auth, unique_run_id};
|
|
|
|
const TEST_DEV_TOKEN: &str =
|
|
"fabro_dev_abababababababababababababababababababababababababababababababab";
|
|
const TEST_SESSION_SECRET: &str =
|
|
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
|
|
|
fn provision_local_server_auth(context: &fabro_test::TestContext, storage_dir: &std::path::Path) {
|
|
context.ensure_home_server_auth_methods();
|
|
let runtime_directory = Storage::new(storage_dir).runtime_directory();
|
|
let server_env_path = runtime_directory.env_path();
|
|
envfile::merge_env_file(&server_env_path, [
|
|
("FABRO_DEV_TOKEN", TEST_DEV_TOKEN),
|
|
("SESSION_SECRET", TEST_SESSION_SECRET),
|
|
])
|
|
.expect("merging server auth into server.env");
|
|
dev_token::write_dev_token(&runtime_directory.dev_token_path(), TEST_DEV_TOKEN)
|
|
.expect("writing runtime dev-token");
|
|
}
|
|
|
|
#[test]
|
|
fn help() {
|
|
let context = test_context!();
|
|
let mut cmd = context.command();
|
|
cmd.args(["ps", "--help"]);
|
|
fabro_snapshot!(context.filters(), cmd, @"
|
|
success: true
|
|
exit_code: 0
|
|
----- stdout -----
|
|
List workflow runs
|
|
|
|
Usage: fabro ps [OPTIONS]
|
|
|
|
Options:
|
|
--json Output as JSON [env: FABRO_JSON=]
|
|
--server <SERVER> Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=]
|
|
--before <BEFORE> Only include runs started before this date (YYYY-MM-DD prefix match)
|
|
--debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
|
|
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
|
|
--workflow <WORKFLOW> Filter by workflow name (substring match)
|
|
--label <KEY=VALUE> Filter by label (KEY=VALUE, repeatable, AND semantics)
|
|
--orphans Include orphan directories (no matching durable run)
|
|
--verbose Enable verbose output [env: FABRO_VERBOSE=]
|
|
-a, --all Show all runs, not just running (like docker ps -a)
|
|
-q, --quiet Only display run IDs
|
|
-h, --help Print help
|
|
----- stderr -----
|
|
");
|
|
}
|
|
|
|
#[test]
|
|
fn ps_explicit_local_tcp_server_target_ignores_env_dev_token() {
|
|
let context = test_context!();
|
|
let storage_root = tempfile::tempdir_in("/tmp").unwrap();
|
|
let storage_dir = storage_root.path().join("storage");
|
|
std::fs::create_dir_all(&storage_dir).unwrap();
|
|
provision_local_server_auth(&context, &storage_dir);
|
|
|
|
context
|
|
.command()
|
|
.env("FABRO_STORAGE_DIR", &storage_dir)
|
|
.args(["server", "start", "--bind", "127.0.0.1"])
|
|
.assert()
|
|
.success();
|
|
|
|
let status_output = context
|
|
.command()
|
|
.env("FABRO_STORAGE_DIR", &storage_dir)
|
|
.args(["server", "status", "--json"])
|
|
.assert()
|
|
.success()
|
|
.get_output()
|
|
.stdout
|
|
.clone();
|
|
let status_json: Value = serde_json::from_slice(&status_output).unwrap();
|
|
let bind = status_json["bind"]
|
|
.as_str()
|
|
.expect("bind should be present");
|
|
|
|
let output = context
|
|
.command()
|
|
.env("FABRO_STORAGE_DIR", &storage_dir)
|
|
.env("FABRO_DEV_TOKEN", TEST_DEV_TOKEN)
|
|
.args(["ps", "-a", "--json", "--server", &format!("http://{bind}")])
|
|
.output()
|
|
.expect("ps should run");
|
|
|
|
context
|
|
.command()
|
|
.env("FABRO_STORAGE_DIR", &storage_dir)
|
|
.args(["server", "stop"])
|
|
.assert()
|
|
.success();
|
|
|
|
assert!(
|
|
!output.status.success(),
|
|
"ps against an explicit local TCP target should ignore FABRO_DEV_TOKEN and require persisted auth:\nstdout:\n{}\nstderr:\n{}",
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
assert_eq!(output.status.code(), Some(4));
|
|
assert_eq!(fatal_error_line(&output.stderr), "Authentication required.");
|
|
}
|
|
|
|
#[test]
|
|
fn ps_explicit_local_tcp_server_target_accepts_explicit_dev_token() {
|
|
let context = test_context!();
|
|
let storage_root = tempfile::tempdir_in("/tmp").unwrap();
|
|
let storage_dir = storage_root.path().join("storage");
|
|
std::fs::create_dir_all(&storage_dir).unwrap();
|
|
provision_local_server_auth(&context, &storage_dir);
|
|
|
|
context
|
|
.command()
|
|
.env("FABRO_STORAGE_DIR", &storage_dir)
|
|
.args(["server", "start", "--bind", "127.0.0.1"])
|
|
.assert()
|
|
.success();
|
|
|
|
let status_output = context
|
|
.command()
|
|
.env("FABRO_STORAGE_DIR", &storage_dir)
|
|
.args(["server", "status", "--json"])
|
|
.assert()
|
|
.success()
|
|
.get_output()
|
|
.stdout
|
|
.clone();
|
|
let status_json: Value = serde_json::from_slice(&status_output).unwrap();
|
|
let bind = status_json["bind"]
|
|
.as_str()
|
|
.expect("bind should be present");
|
|
let token = local_dev_token(&storage_dir).expect("local dev token should exist");
|
|
let target = ServerTarget::http_url(format!("http://{bind}")).expect("bind should parse");
|
|
seed_dev_token_auth(&context.home_dir, &target, &token);
|
|
|
|
let output = context
|
|
.command()
|
|
.args(["ps", "-a", "--json", "--server", &format!("http://{bind}")])
|
|
.output()
|
|
.expect("ps should run");
|
|
|
|
context
|
|
.command()
|
|
.env("FABRO_STORAGE_DIR", &storage_dir)
|
|
.args(["server", "stop"])
|
|
.assert()
|
|
.success();
|
|
|
|
assert!(
|
|
output.status.success(),
|
|
"ps against local TCP target with explicit auth failed:\nstdout:\n{}\nstderr:\n{}",
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
let runs: Vec<Value> = serde_json::from_slice(&output.stdout).unwrap();
|
|
assert!(runs.is_empty(), "new local TCP server should have no runs");
|
|
}
|
|
|
|
#[test]
|
|
fn ps_default_excludes_non_running_runs() {
|
|
let context = test_context!();
|
|
setup_seeded_completed_dry_run(&context);
|
|
let mut cmd = context.ps();
|
|
cmd.args(["--label", &context.test_case_label()]);
|
|
|
|
fabro_snapshot!(context.filters(), cmd, @"
|
|
success: true
|
|
exit_code: 0
|
|
----- stdout -----
|
|
----- stderr -----
|
|
No running processes found. Use -a to show all runs (including archived).
|
|
");
|
|
}
|
|
|
|
#[test]
|
|
fn ps_all_json_lists_created_and_completed_runs() {
|
|
let context = test_context!();
|
|
setup_seeded_completed_dry_run(&context);
|
|
setup_seeded_created_dry_run(&context);
|
|
let output = context
|
|
.ps()
|
|
.args(["-a", "--json", "--label", &context.test_case_label()])
|
|
.output()
|
|
.expect("ps should run");
|
|
|
|
assert!(output.status.success(), "ps should succeed");
|
|
let runs: Vec<Value> = serde_json::from_slice(&output.stdout).expect("ps JSON should parse");
|
|
assert_eq!(runs.len(), 2, "expected submitted + completed runs");
|
|
assert!(
|
|
runs.iter().all(|run| run["workflow_name"] == "Simple"),
|
|
"all runs should belong to the Simple workflow: {runs:#?}"
|
|
);
|
|
assert!(
|
|
runs.iter()
|
|
.all(|run| run["labels"]["fabro_test_case"] == context.test_case_id()),
|
|
"all runs should be scoped to the current test case: {runs:#?}"
|
|
);
|
|
assert!(
|
|
runs.iter()
|
|
.all(|run| run["labels"]["fabro_test_run"] == context.test_run_id()),
|
|
"all runs should be scoped to the current test session: {runs:#?}"
|
|
);
|
|
assert!(
|
|
runs.iter().any(|run| run["status"]["kind"] == "submitted"),
|
|
"ps should include the created run: {runs:#?}"
|
|
);
|
|
assert!(
|
|
runs.iter().any(|run| {
|
|
run["status"]["kind"] == "succeeded" && run["status"]["reason"] == "completed"
|
|
}),
|
|
"ps should include the completed run: {runs:#?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn setup_seeded_run_helpers_preserve_handle_when_another_run_exists() {
|
|
let context = test_context!();
|
|
|
|
let created = setup_seeded_created_dry_run(&context);
|
|
let completed = setup_seeded_completed_dry_run(&context);
|
|
|
|
assert_ne!(created.run_id, completed.run_id);
|
|
assert_ne!(created.run_dir, completed.run_dir);
|
|
assert!(created.run_dir.exists(), "created run dir should exist");
|
|
assert!(completed.run_dir.exists(), "completed run dir should exist");
|
|
}
|
|
|
|
#[test]
|
|
fn ps_quiet_outputs_run_ids_only() {
|
|
let context = test_context!();
|
|
setup_seeded_completed_dry_run(&context);
|
|
setup_seeded_created_dry_run(&context);
|
|
let mut cmd = context.ps();
|
|
cmd.args(["-a", "--quiet", "--label", &context.test_case_label()]);
|
|
|
|
fabro_snapshot!(context.filters(), cmd, @r###"
|
|
success: true
|
|
exit_code: 0
|
|
----- stdout -----
|
|
[ULID]
|
|
[ULID]
|
|
----- stderr -----
|
|
"###);
|
|
}
|
|
|
|
#[test]
|
|
fn ps_filters_by_workflow_and_label() {
|
|
let context = test_context!();
|
|
let simple = context.temp_dir.join("simple.fabro");
|
|
let branching = context.temp_dir.join("branching.fabro");
|
|
context.write_temp(
|
|
"simple.fabro",
|
|
r#"digraph Simple {
|
|
start [shape=Mdiamond]
|
|
exit [shape=Msquare]
|
|
run [shape=parallelogram, script="true"]
|
|
start -> run -> exit
|
|
}
|
|
"#,
|
|
);
|
|
context.write_temp(
|
|
"branching.fabro",
|
|
r#"digraph Branching {
|
|
start [shape=Mdiamond]
|
|
exit [shape=Msquare]
|
|
run [shape=parallelogram, script="true"]
|
|
start -> run -> exit
|
|
}
|
|
"#,
|
|
);
|
|
|
|
context
|
|
.run_cmd()
|
|
.args(["--dry-run", "--auto-approve", "--label", "suite=alpha"])
|
|
.arg(&simple)
|
|
.assert()
|
|
.success();
|
|
context
|
|
.create_cmd()
|
|
.args(["--dry-run", "--auto-approve", "--label", "suite=beta"])
|
|
.arg(&branching)
|
|
.assert()
|
|
.success();
|
|
|
|
let output = context
|
|
.ps()
|
|
.args([
|
|
"-a",
|
|
"--json",
|
|
"--workflow",
|
|
"Simple",
|
|
"--label",
|
|
"suite=alpha",
|
|
"--label",
|
|
&context.test_case_label(),
|
|
])
|
|
.output()
|
|
.expect("ps should run");
|
|
|
|
assert!(output.status.success(), "ps should succeed");
|
|
let runs: Vec<Value> = serde_json::from_slice(&output.stdout).expect("ps JSON should parse");
|
|
assert_eq!(
|
|
runs.len(),
|
|
1,
|
|
"workflow+label filter should isolate one run"
|
|
);
|
|
let run = &runs[0];
|
|
assert_eq!(run["workflow_name"], "Simple");
|
|
assert_eq!(run["status"]["kind"], "succeeded");
|
|
assert_eq!(run["status"]["reason"], "completed");
|
|
assert_eq!(run["labels"]["suite"], "alpha");
|
|
assert_eq!(run["labels"]["fabro_test_case"], context.test_case_id());
|
|
assert_eq!(run["labels"]["fabro_test_run"], context.test_run_id());
|
|
}
|
|
|
|
#[test]
|
|
fn ps_uses_configured_server_target_without_server_flag() {
|
|
let context = test_context!();
|
|
let server = MockServer::start();
|
|
let run_id = unique_run_id();
|
|
let mock = server.mock(|when, then| {
|
|
when.method("GET").path("/api/v1/runs");
|
|
then.status(200)
|
|
.header("Content-Type", "application/json")
|
|
.body(
|
|
serde_json::json!({
|
|
"data": [{
|
|
"run_id": run_id,
|
|
"workflow_name": "Remote Workflow",
|
|
"workflow_slug": "remote-workflow",
|
|
"goal": "Remote goal",
|
|
"title": "Remote goal",
|
|
"labels": {
|
|
"suite": "remote"
|
|
},
|
|
"source_directory": "/srv/repo",
|
|
"repository": { "name": "repo" },
|
|
"start_time": "2026-04-05T12:00:00Z",
|
|
"created_at": "2026-04-05T12:00:00Z",
|
|
"status": {
|
|
"kind": "succeeded",
|
|
"reason": "completed"
|
|
},
|
|
"duration_ms": 123,
|
|
"total_usd_micros": null
|
|
}],
|
|
"meta": { "has_more": false }
|
|
})
|
|
.to_string(),
|
|
);
|
|
});
|
|
context.set_http_target(&server.base_url());
|
|
|
|
let output = context
|
|
.ps()
|
|
.args(["-a", "--json"])
|
|
.output()
|
|
.expect("ps should execute");
|
|
|
|
assert!(output.status.success(), "ps should succeed");
|
|
let runs: Vec<Value> = serde_json::from_slice(&output.stdout).expect("ps JSON should parse");
|
|
mock.assert();
|
|
assert_eq!(runs.len(), 1);
|
|
assert_eq!(runs[0]["workflow_name"], "Remote Workflow");
|
|
assert_eq!(runs[0]["source_directory"], "/srv/repo");
|
|
}
|
|
|
|
#[test]
|
|
fn ps_explicit_remote_target_ignores_broken_local_storage_settings() {
|
|
let context = test_context!();
|
|
let server = MockServer::start();
|
|
let run_id = unique_run_id();
|
|
let mock = server.mock(|when, then| {
|
|
when.method("GET").path("/api/v1/runs");
|
|
then.status(200)
|
|
.header("Content-Type", "application/json")
|
|
.body(
|
|
serde_json::json!({
|
|
"data": [{
|
|
"run_id": run_id,
|
|
"workflow_name": "Explicit Remote",
|
|
"workflow_slug": "explicit-remote",
|
|
"goal": "Remote goal",
|
|
"title": "Remote goal",
|
|
"labels": {},
|
|
"source_directory": "/srv/repo",
|
|
"repository": { "name": "repo" },
|
|
"start_time": "2026-04-20T12:00:00Z",
|
|
"created_at": "2026-04-20T12:00:00Z",
|
|
"status": {
|
|
"kind": "succeeded",
|
|
"reason": "completed"
|
|
},
|
|
"duration_ms": 123,
|
|
"total_usd_micros": null
|
|
}],
|
|
"meta": { "has_more": false }
|
|
})
|
|
.to_string(),
|
|
);
|
|
});
|
|
context.write_home(
|
|
".fabro/settings.toml",
|
|
"_version = 1\n\n[server.storage]\nroot = \"${FABRO_MISSING_STORAGE_ROOT}\"\n",
|
|
);
|
|
|
|
let output = context
|
|
.command()
|
|
.env_remove("FABRO_STORAGE_DIR")
|
|
.args([
|
|
"ps",
|
|
"-a",
|
|
"--json",
|
|
"--server",
|
|
&format!("{}/api/v1", server.base_url()),
|
|
])
|
|
.output()
|
|
.expect("ps should execute");
|
|
|
|
assert!(
|
|
output.status.success(),
|
|
"explicit remote ps should ignore broken local storage settings\nstdout:\n{}\nstderr:\n{}",
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
mock.assert();
|
|
let runs: Vec<Value> = serde_json::from_slice(&output.stdout).expect("ps JSON should parse");
|
|
assert_eq!(runs.len(), 1);
|
|
assert_eq!(runs[0]["workflow_name"], "Explicit Remote");
|
|
}
|