fabro/lib/apps/fabro-cli/tests/it/cmd/dump.rs
Bryan Helmkamp ee6576cff7
Resolve one Git identity per run and inject it everywhere
A run now resolves a single author and committer identity once, after its
GitHub credentials are selected and before anything can commit, and uses it
for every commit it creates. Resolution order: a complete explicit
`run.git.author`; the run's GitHub App bot account
(`<slug>[bot] <id+slug[bot]@users.noreply.github.com>`); the authenticated
user of the run's PAT; the generic `Fabro <noreply@fabro.sh>`. A partial
explicit author overlays the fields it supplies. Only the selected
credential is consulted; a failed lookup is a setup error. A standalone
installation token falls back to the generic identity with a warning.

The resolved identity is carried on `RunOptions` and `EngineServices`,
recorded as a `git.identity.resolved` event and `RunProjection.git_identity`
so resume reuses it, and exposed through the run state API. Engine
checkpoints and metadata commits read it through `RunOptions::git_author`.
Every workflow execution path receives it as `GIT_AUTHOR_NAME`,
`GIT_AUTHOR_EMAIL`, `GIT_COMMITTER_NAME`, and `GIT_COMMITTER_EMAIL`, applied
last so it wins over inherited host variables and `[run.environment]`
entries: prepare steps, command stages, native agent shell tools, and ACP
launches. The identity is injected even without a Git origin, and the old
local `git config user.*` write is removed.

fabro-github gains `GET /user` and `/users/{slug}[bot]` lookups with mocked
tests for success, unauthorized, malformed, and transient cases. Real-Git
integration tests commit in the primary checkout, a clone, and a fresh
repository under conflicting local config, `[run.environment]`, and host
variables, and prove concurrent runs do not leak identities. CLI workflow
tests cover host script stages and ACP launch env through `fabro run`.

Docs and generated option metadata now describe the credential-derived
defaults instead of the stale `fabro`/`fabro@local` values.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 11:35:46 -06:00

346 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#![expect(
clippy::disallowed_methods,
reason = "integration tests stage fixtures with sync std::fs; test infrastructure, not Tokio-hot path"
)]
use std::fs;
use std::time::Duration;
use fabro_client::ServerTarget;
use fabro_test::{fabro_snapshot, test_context};
use insta::assert_snapshot;
use super::support::{
local_dev_token, run_state, server_target, setup_completed_dry_run,
setup_seeded_completed_dry_run, setup_seeded_created_dry_run,
};
use crate::support::{LightweightCli, seed_dev_token_auth};
#[test]
fn help() {
let context = test_context!();
let mut cmd = context.command();
cmd.args(["dump", "--help"]);
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
Export a run's durable state to a directory
Usage: fabro dump [OPTIONS] --output <OUTPUT> <RUN>
Arguments:
<RUN> Run ID prefix or workflow name
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=]
-o, --output <OUTPUT> Output directory (must not exist or be empty)
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
--quiet Suppress non-essential output [env: FABRO_QUIET=]
--verbose Enable verbose output [env: FABRO_VERBOSE=]
-h, --help Print help
----- stderr -----
");
}
#[test]
fn dump_accepts_server_target_from_separate_home() {
let context = test_context!();
let run = setup_seeded_completed_dry_run(&context);
let cli = LightweightCli::new();
let output_dir = context.temp_dir.join("remote-export");
let server = server_target(&context.storage_dir);
if let Some(dev_token) = local_dev_token(&context.storage_dir) {
let target = server
.parse::<ServerTarget>()
.expect("server target should parse");
seed_dev_token_auth(cli.home(), &target, &dev_token);
}
let mut cmd = cli.command();
cmd.args([
"dump",
"--server",
&server,
"--output",
output_dir.to_str().unwrap(),
&run.run_id,
]);
let output = cmd.output().expect("dump should execute");
assert!(
output.status.success(),
"dump via remote server target failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert!(output_dir.join("run.json").is_file());
}
#[test]
fn dump_exports_large_command_output_backed_by_blob_refs() {
let context = test_context!();
let workflow = context.temp_dir.join("large-output.fabro");
fs::write(
&workflow,
r#"digraph LargeOutput {
graph [goal="Generate oversized command output"]
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
big [shape=parallelogram, label="Big", script="printf '%*s' 120000 '' | tr ' ' x"]
start -> big -> exit
}
"#,
)
.unwrap();
let mut run_cmd = context.run_cmd();
run_cmd.current_dir(&context.temp_dir);
run_cmd.timeout(Duration::from_secs(30));
run_cmd.args(["--environment", "local"]);
run_cmd.arg(&workflow);
let run_output = run_cmd.output().expect("command should execute");
assert!(
run_output.status.success(),
"workflow run failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&run_output.stdout),
String::from_utf8_lossy(&run_output.stderr)
);
let run_id = run_state(&context.single_run_dir()).spec.run_id.to_string();
let mut inspect_cmd = context.command();
inspect_cmd.args(["inspect", "--json", &run_id]);
let inspect_output = inspect_cmd.output().expect("inspect should execute");
assert!(
inspect_output.status.success(),
"inspect failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&inspect_output.stdout),
String::from_utf8_lossy(&inspect_output.stderr)
);
let inspect_json = String::from_utf8(inspect_output.stdout).unwrap();
assert!(
inspect_json.contains("blob://sha256/"),
"inspect output should contain blob refs to exercise hydration\n{inspect_json}"
);
let output_dir = context.temp_dir.join("export");
let mut dump_cmd = context.command();
dump_cmd.args(["dump", "--output", output_dir.to_str().unwrap(), &run_id]);
let dump_output = dump_cmd.output().expect("dump should execute");
assert!(
dump_output.status.success(),
"dump failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&dump_output.stdout),
String::from_utf8_lossy(&dump_output.stderr)
);
let run_json = fs::read_to_string(output_dir.join("run.json")).unwrap();
assert!(
!run_json.contains("blob://sha256/"),
"run export should hydrate blob refs\n{run_json}"
);
}
#[test]
fn dump_exports_blob_refs_and_artifacts_together() {
let context = test_context!();
let workspace_dir = context.temp_dir.join("mixed-export");
fs::create_dir_all(&workspace_dir).unwrap();
fs::write(
workspace_dir.join("mixed-export.fabro"),
r#"digraph MixedExport {
graph [goal="Generate oversized command output and artifacts"]
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
big [shape=parallelogram, label="Big", script="mkdir -p assets/shared && printf exported > assets/shared/report.txt && printf '%*s' 120000 '' | tr ' ' x"]
start -> big -> exit
}
"#,
)
.unwrap();
fs::write(
workspace_dir.join("workflow.toml"),
r#"_version = 1
[workflow]
graph = "mixed-export.fabro"
[run]
goal = "Generate oversized command output and artifacts"
[run.environment]
id = "local"
[run.artifacts]
include = ["assets/**"]
"#,
)
.unwrap();
let mut run_cmd = context.run_cmd();
run_cmd.current_dir(&workspace_dir);
run_cmd.timeout(Duration::from_secs(30));
run_cmd.args(["--environment", "local", "workflow.toml"]);
let run_output = run_cmd.output().expect("command should execute");
assert!(
run_output.status.success(),
"workflow run failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&run_output.stdout),
String::from_utf8_lossy(&run_output.stderr)
);
let run_id = run_state(&context.single_run_dir()).spec.run_id.to_string();
let mut inspect_cmd = context.command();
inspect_cmd.args(["inspect", "--json", &run_id]);
let inspect_output = inspect_cmd.output().expect("inspect should execute");
assert!(
inspect_output.status.success(),
"inspect failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&inspect_output.stdout),
String::from_utf8_lossy(&inspect_output.stderr)
);
let inspect_json = String::from_utf8(inspect_output.stdout).unwrap();
assert!(
inspect_json.contains("blob://sha256/"),
"inspect output should contain blob refs to exercise hydration\n{inspect_json}"
);
let output_dir = context.temp_dir.join("export-mixed");
let mut dump_cmd = context.command();
dump_cmd.args(["dump", "--output", output_dir.to_str().unwrap(), &run_id]);
let dump_output = dump_cmd.output().expect("dump should execute");
assert!(
dump_output.status.success(),
"dump failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&dump_output.stdout),
String::from_utf8_lossy(&dump_output.stderr)
);
let run_json = fs::read_to_string(output_dir.join("run.json")).unwrap();
assert!(
!run_json.contains("blob://sha256/"),
"run export should hydrate blob refs\n{run_json}"
);
assert_eq!(
fs::read_to_string(
output_dir.join("artifacts/002-big@1/retry-0001/assets/shared/report.txt")
)
.unwrap(),
"exported"
);
}
#[test]
fn dump_exports_completed_run_snapshot() {
let context = test_context!();
let run = setup_completed_dry_run(&context);
let output_dir = context.temp_dir.join("export");
let mut cmd = context.command();
cmd.args([
"dump",
"--output",
output_dir.to_str().unwrap(),
&run.run_id,
]);
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
Exported 13 files for run [ULID] to [TEMP_DIR]/export
----- stderr -----
");
assert_snapshot!(dump_file_summary(&output_dir), @"
checkpoints/0018.json
checkpoints/0022.json
checkpoints/0026.json
events.jsonl
graph.fabro
run.json
run.log
stages/001-start@1/status.json
stages/002-run_tests@1/response.md
stages/002-run_tests@1/status.json
stages/003-report@1/response.md
stages/003-report@1/status.json
stages/004-exit@1/status.json
");
}
#[test]
fn dump_succeeds_when_run_log_is_missing() {
let context = test_context!();
let run = setup_seeded_created_dry_run(&context);
let output_dir = context.temp_dir.join("export-missing-log");
let mut cmd = context.command();
cmd.args([
"dump",
"--output",
output_dir.to_str().unwrap(),
&run.run_id,
]);
let output = cmd.output().expect("dump should execute");
assert!(
output.status.success(),
"dump failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert!(
!output_dir.join("run.log").exists(),
"dump should skip run.log when the server has no run log"
);
}
#[test]
fn dump_rejects_non_empty_output_dir() {
let context = test_context!();
let run = setup_seeded_completed_dry_run(&context);
let output_dir = context.temp_dir.join("nonempty");
std::fs::create_dir_all(&output_dir).unwrap();
std::fs::write(output_dir.join("file.txt"), "x").unwrap();
let mut cmd = context.command();
cmd.args([
"dump",
"--output",
output_dir.to_str().unwrap(),
&run.run_id,
]);
fabro_snapshot!(context.filters(), cmd, @"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
× output path [TEMP_DIR]/nonempty already exists and is not an empty directory; remove it first or choose a different path
");
}
fn dump_file_summary(output_dir: &std::path::Path) -> String {
let mut files: Vec<String> = walkdir::WalkDir::new(output_dir)
.into_iter()
.filter_map(Result::ok)
.filter(|entry| entry.file_type().is_file())
.map(|entry| {
entry
.path()
.strip_prefix(output_dir)
.expect("walked file should stay under the output directory")
.to_string_lossy()
.replace('\\', "/")
})
.collect();
files.sort();
files.join("\n") + "\n"
}