mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-05 08:10:39 +00:00
843 lines
28 KiB
Rust
843 lines
28 KiB
Rust
use std::collections::BTreeSet;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Output;
|
|
use std::sync::Arc;
|
|
use std::time::{Duration, Instant};
|
|
|
|
use fabro_store::{RunSnapshot, RunStore, SlateStore, Store};
|
|
use fabro_test::TestContext;
|
|
use fabro_types::RunId;
|
|
use object_store::local::LocalFileSystem;
|
|
use serde_json::Value;
|
|
use shlex::try_quote;
|
|
|
|
const COMMAND_TIMEOUT: Duration = Duration::from_secs(30);
|
|
|
|
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_success_in(
|
|
context,
|
|
&[
|
|
"run",
|
|
"--dry-run",
|
|
"--auto-approve",
|
|
"--no-retro",
|
|
"--sandbox",
|
|
"local",
|
|
workflow.to_str().unwrap(),
|
|
],
|
|
&context.temp_dir,
|
|
);
|
|
only_run(context)
|
|
}
|
|
|
|
pub(crate) fn setup_created_dry_run(context: &TestContext) -> RunSetup {
|
|
let workflow = fixture("simple.fabro");
|
|
let output = run_success_in(
|
|
context,
|
|
&[
|
|
"create",
|
|
"--dry-run",
|
|
"--auto-approve",
|
|
"--no-retro",
|
|
"--sandbox",
|
|
"local",
|
|
workflow.to_str().unwrap(),
|
|
],
|
|
&context.temp_dir,
|
|
);
|
|
let run_id = stdout(&output)
|
|
.lines()
|
|
.find(|line| !line.trim().is_empty())
|
|
.map(str::trim)
|
|
.expect("create should print a run ID")
|
|
.to_string();
|
|
resolve_run(context, &run_id)
|
|
}
|
|
|
|
pub(crate) fn setup_detached_dry_run(context: &TestContext) -> RunSetup {
|
|
let workflow = fixture("simple.fabro");
|
|
let output = run_success_in(
|
|
context,
|
|
&[
|
|
"run",
|
|
"--detach",
|
|
"--dry-run",
|
|
"--auto-approve",
|
|
"--no-retro",
|
|
"--sandbox",
|
|
"local",
|
|
workflow.to_str().unwrap(),
|
|
],
|
|
&context.temp_dir,
|
|
);
|
|
let run_id = stdout(&output)
|
|
.lines()
|
|
.find(|line| !line.trim().is_empty())
|
|
.map(str::trim)
|
|
.expect("run --detach should print a run ID")
|
|
.to_string();
|
|
let run = resolve_run(context, &run_id);
|
|
let deadline = Instant::now() + COMMAND_TIMEOUT;
|
|
while run_store(&run.run_dir)
|
|
.and_then(|store| block_on(store.list_events()).ok())
|
|
.is_none_or(|events| events.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_asset_run(context: &TestContext) -> WorkspaceRunSetup {
|
|
let workspace_dir = context.temp_dir.join("asset-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("asset_run.fabro"),
|
|
r#"digraph AssetRun {
|
|
graph [goal="Exercise asset 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 0.2; else printf second > assets/retry/report.txt; fi", retry_policy="linear", timeout="50ms"]
|
|
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 = "asset_run.fabro"
|
|
goal = "Exercise asset commands"
|
|
|
|
[sandbox]
|
|
provider = "local"
|
|
preserve = true
|
|
|
|
[sandbox.local]
|
|
worktree_mode = "never"
|
|
|
|
[assets]
|
|
include = ["assets/**"]
|
|
"#,
|
|
);
|
|
|
|
let run = run_local_workflow(context, &workspace_dir, "run.toml");
|
|
assert!(
|
|
run.run_dir
|
|
.join("cache/artifacts/assets/retry_assets/retry_2/manifest.json")
|
|
.exists(),
|
|
"setup_asset_run should materialize retry_2 assets"
|
|
);
|
|
|
|
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_store(&run.run_dir)
|
|
.and_then(|store| block_on(store.get_sandbox()).ok())
|
|
.flatten()
|
|
.is_some()
|
|
);
|
|
|
|
WorkspaceRunSetup { run, workspace_dir }
|
|
}
|
|
|
|
fn run_local_workflow(context: &TestContext, workspace_dir: &Path, workflow: &str) -> RunSetup {
|
|
let mut cmd = context.command();
|
|
cmd.current_dir(workspace_dir);
|
|
cmd.timeout(COMMAND_TIMEOUT);
|
|
cmd.env("OPENAI_API_KEY", "test");
|
|
cmd.args([
|
|
"run",
|
|
"--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)
|
|
);
|
|
}
|
|
|
|
only_run(context)
|
|
}
|
|
|
|
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; sleep 0.2\"]\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_store(run_dir)
|
|
.and_then(|store| block_on(store.get_status()).ok())
|
|
.flatten()
|
|
.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 only_run(context: &TestContext) -> RunSetup {
|
|
let runs_dir = context.storage_dir.join("runs");
|
|
let entries: Vec<_> = std::fs::read_dir(&runs_dir)
|
|
.unwrap_or_else(|err| panic!("failed to read {}: {err}", runs_dir.display()))
|
|
.filter_map(Result::ok)
|
|
.map(|entry| entry.path())
|
|
.filter(|path| path.is_dir())
|
|
.collect();
|
|
assert_eq!(
|
|
entries.len(),
|
|
1,
|
|
"expected exactly one run under {}",
|
|
runs_dir.display()
|
|
);
|
|
let run_dir = entries[0].clone();
|
|
let run_id = infer_run_id(&run_dir);
|
|
RunSetup { run_id, run_dir }
|
|
}
|
|
|
|
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> {
|
|
let runs_dir = storage_dir.join("runs");
|
|
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 {
|
|
if let Ok(id) = std::fs::read_to_string(run_dir.join("id.txt")) {
|
|
return id.trim().to_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)
|
|
}
|
|
|
|
fn run_store(run_dir: &Path) -> Option<Arc<dyn RunStore>> {
|
|
let runs_dir = run_dir.parent()?;
|
|
let storage_dir = runs_dir.parent()?;
|
|
let run_id: RunId = infer_run_id(run_dir).parse().ok()?;
|
|
let object_store = Arc::new(LocalFileSystem::new_with_prefix(storage_dir.join("store")).ok()?);
|
|
let store = Arc::new(SlateStore::new(object_store, "", Duration::from_millis(1)));
|
|
block_on(store.open_run_reader(&run_id)).ok().flatten()
|
|
}
|
|
|
|
pub(crate) fn run_snapshot(run_dir: &Path) -> RunSnapshot {
|
|
run_store(run_dir)
|
|
.and_then(|store| block_on(store.get_snapshot()).ok())
|
|
.flatten()
|
|
.expect("run store snapshot should exist")
|
|
}
|
|
|
|
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"),
|
|
},
|
|
"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"),
|
|
},
|
|
"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 mut cmd = context.command();
|
|
cmd.current_dir(&repo_dir);
|
|
cmd.env("OPENAI_API_KEY", "test");
|
|
cmd.args([
|
|
"run",
|
|
"--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 = only_run(context);
|
|
let start = serde_json::to_value(
|
|
run_snapshot(&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_snapshot(&run.run_dir).final_patch.is_some(),
|
|
"changed git-backed run should persist final patch in store"
|
|
);
|
|
let snapshot = run_snapshot(&run.run_dir);
|
|
assert!(
|
|
snapshot
|
|
.nodes
|
|
.iter()
|
|
.any(|node| node.node_id == "step_one" && node.diff.is_some())
|
|
);
|
|
assert!(
|
|
snapshot
|
|
.nodes
|
|
.iter()
|
|
.any(|node| node.node_id == "step_two" && node.diff.is_some())
|
|
);
|
|
}
|
|
GitWorkflowKind::Noop => {
|
|
assert!(
|
|
run_snapshot(&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()
|
|
}
|