fabro/lib/crates/fabro-cli/tests/it/cmd/ps.rs
Bryan Helmkamp 5fc9157017
refactor(workflow): remove retro stage (#230)
## 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.

---

[![Compound
Engineering](https://img.shields.io/badge/Compound_Engineering-6366f1)](https://github.com/EveryInc/compound-engineering-plugin)
🤖 Generated with GPT-5 (unknown context, reasoning unspecified) via
[Codex](https://openai.com/codex)
2026-05-09 10:18:20 -04:00

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");
}