Refactor workflow lifecycle into operations

This commit is contained in:
Bryan Helmkamp 2026-03-25 09:29:26 -04:00
parent 032b3c33e1
commit 6ba28531ad
No known key found for this signature in database
36 changed files with 1003 additions and 1005 deletions

View file

@ -23,9 +23,10 @@ use crate::jwt_auth::{AuthMode, AuthenticatedService, AuthenticatedUser};
use fabro_interview::{Answer, Interviewer, QuestionType, WebInterviewer};
use fabro_workflows::checkpoint::Checkpoint;
use fabro_workflows::context::Context;
use fabro_workflows::engine::{RunSettings, WorkflowRunEngine};
use fabro_workflows::engine::WorkflowRunEngine;
use fabro_workflows::event::{EventEmitter, WorkflowRunEvent};
use fabro_workflows::handler::HandlerRegistry;
use fabro_workflows::run_settings::RunSettings;
pub use fabro_types::{
ApiQuestion, ApiQuestionOption, PaginatedRunList, PaginationMeta,

View file

@ -108,8 +108,8 @@ fn build_live_diff_cmd(base_sha: &str, stat: bool, shortstat: bool) -> String {
);
format!(
"{} add -N . && {} diff{flags} {quoted_sha}",
fabro_workflows::engine::GIT_REMOTE,
fabro_workflows::engine::GIT_REMOTE
fabro_workflows::sandbox_git::GIT_REMOTE,
fabro_workflows::sandbox_git::GIT_REMOTE
)
}

View file

@ -24,29 +24,28 @@ pub struct ForkArgs {
pub fn run(args: &ForkArgs, styles: &Styles) -> Result<()> {
let repo = Repository::discover(".").context("not in a git repository")?;
let run_id = fabro_workflows::run_rewind::find_run_id_by_prefix(&repo, &args.run_id)?;
let run_id = fabro_workflows::operations::find_run_id_by_prefix(&repo, &args.run_id)?;
let store = Store::new(repo);
let timeline = fabro_workflows::run_rewind::build_timeline(&store, &run_id)?;
let timeline = fabro_workflows::operations::build_timeline(&store, &run_id)?;
if args.list {
let parallel_map = fabro_workflows::run_rewind::load_parallel_map(&store, &run_id);
let parallel_map = fabro_workflows::operations::load_parallel_map(&store, &run_id);
super::rewind::print_timeline(&timeline, &parallel_map, styles);
return Ok(());
}
let entry = if let Some(target_str) = &args.target {
let target = fabro_workflows::run_rewind::parse_target(target_str)?;
let parallel_map = fabro_workflows::run_rewind::load_parallel_map(&store, &run_id);
fabro_workflows::run_rewind::resolve_target(&timeline, &target, &parallel_map)?
let target = fabro_workflows::operations::parse_target(target_str)?;
let parallel_map = fabro_workflows::operations::load_parallel_map(&store, &run_id);
fabro_workflows::operations::resolve_target(&timeline, &target, &parallel_map)?
} else {
timeline
.last()
.ok_or_else(|| anyhow::anyhow!("no checkpoints found for run {run_id}"))?
};
let new_run_id =
fabro_workflows::run_fork::execute_fork(&store, &run_id, entry, !args.no_push)?;
let new_run_id = fabro_workflows::operations::fork(&store, &run_id, entry, !args.no_push)?;
eprintln!(
"\nForked run {} -> {}",

View file

@ -57,7 +57,8 @@ static RANKDIR_RE: LazyLock<regex::Regex> =
pub fn run(args: &GraphArgs, styles: &Styles) -> anyhow::Result<()> {
let (dot_path, _cfg) = fabro_config::project::resolve_workflow(&args.workflow)?;
let (_graph, diagnostics) = fabro_workflows::workflow::prepare_from_file(&dot_path)?;
let validated = fabro_workflows::operations::create_from_file(&dot_path)?;
let diagnostics = validated.diagnostics();
print_diagnostics(&diagnostics, styles);

View file

@ -1,35 +1,38 @@
use std::io::IsTerminal;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
use std::time::Instant;
use anyhow::{bail, Context};
use clap::Args;
use fabro_agent::{DockerSandbox, DockerSandboxConfig, Sandbox, WorktreeConfig, WorktreeSandbox};
use fabro_config::config::FabroConfig;
use fabro_graphviz::graph::Graph;
use fabro_interview::{AutoApproveInterviewer, ConsoleInterviewer, Interviewer};
use fabro_model::{Catalog, Provider};
use fabro_util::terminal::Styles;
use fabro_workflows::backend::{AgentApiBackend, AgentCliBackend, BackendRouter};
use fabro_workflows::checkpoint::Checkpoint;
use fabro_workflows::engine::{GitCheckpointSettings, RunSettings};
use fabro_workflows::event::{EventEmitter, RunNoticeLevel};
use fabro_workflows::operations::{
create_from_graph, start, StartFinalizeConfig, StartOptions, StartRetroConfig,
};
use fabro_workflows::outcome::StageStatus;
use fabro_workflows::pipeline::{
build_conclusion, classify_engine_result, persist_terminal_outcome,
};
use fabro_workflows::run_record::RunRecord;
use fabro_workflows::run_settings::{GitCheckpointSettings, LifecycleConfig, RunSettings};
use fabro_workflows::sandbox_provider::SandboxProvider;
use indicatif::HumanDuration;
use super::detached_support::{DetachedRunBootstrapGuard, DetachedRunCompletionGuard};
use super::run::{
build_conclusion, build_event_envelope, cached_graph_path, classify_engine_result,
default_run_dir, emit_run_notice, generate_retro, local_sandbox_with_callback,
mint_github_token, persist_terminal_outcome, prepare_workflow_with_project_config,
print_assets, print_final_output, resolve_daytona_config, resolve_fallback_chain,
resolve_model_provider, resolve_ssh_clone_params, resolve_ssh_config, write_finalize_commit,
write_run_config_snapshot, CliSandboxProvider, RunArgs,
build_event_envelope, cached_graph_path, default_run_dir, emit_run_notice,
local_sandbox_with_callback, mint_github_token, prepare_workflow_with_project_config,
print_assets, print_final_output, print_retro_result, print_run_conclusion,
resolve_daytona_config, resolve_fallback_chain, resolve_model_provider,
resolve_ssh_clone_params, resolve_ssh_config, write_run_config_snapshot, CliSandboxProvider,
RunArgs,
};
use crate::commands::shared::tilde_path;
use fabro_config::project as project_config;
use fabro_config::run as run_config;
use fabro_workflows::devcontainer_bridge;
@ -94,7 +97,7 @@ pub struct ResumeArgs {
/// Intermediate state produced by the two resolution paths (checkpoint-file vs. git-branch).
struct ResumeContext {
checkpoint: Checkpoint,
graph: Graph,
validated: fabro_workflows::pipeline::Validated,
run_id: String,
run_dir: PathBuf,
run_cfg: Option<FabroConfig>,
@ -201,7 +204,9 @@ async fn prepare_from_checkpoint(
true,
false,
)?;
let (graph, source, _diagnostics) = prepared.validated.into_parts();
let source = prepared.raw_source.clone();
let validated = prepared.validated;
let graph = validated.graph().clone();
let run_cfg = prepared.run_cfg;
let sandbox_provider = prepared.sandbox_provider;
let workflow_slug = prepared.workflow_slug;
@ -473,7 +478,7 @@ async fn prepare_from_checkpoint(
Ok(ResumeContext {
checkpoint,
graph,
validated,
run_id,
run_dir,
run_cfg,
@ -509,7 +514,7 @@ async fn prepare_from_branch(
(stripped.to_string(), run_arg.to_string())
} else {
let repo = git2::Repository::discover(".").context("not in a git repository")?;
let id = fabro_workflows::run_rewind::find_run_id_by_prefix(&repo, run_arg)?;
let id = fabro_workflows::operations::find_run_id_by_prefix(&repo, run_arg)?;
let branch = format!("{}{}", fabro_workflows::git::RUN_BRANCH_PREFIX, id);
(id, branch)
};
@ -544,7 +549,7 @@ async fn prepare_from_branch(
.or_else(|| repo_info.as_ref().and_then(|(_, branch)| branch.clone()));
let base_sha = start_record.as_ref().and_then(|s| s.base_sha.clone());
let (graph, graph_source, run_cfg, mut sandbox_provider, workflow_slug) =
let (validated, graph_source, run_cfg, mut sandbox_provider, workflow_slug) =
if let Some(ref workflow_path) = args.workflow {
let prepared = prepare_workflow_with_project_config(
&resume_as_run_args(args, workflow_path.clone()),
@ -553,19 +558,16 @@ async fn prepare_from_branch(
true,
false,
)?;
{
let (graph, source, _diagnostics) = prepared.validated.into_parts();
(
graph,
source,
prepared.run_cfg,
prepared.sandbox_provider,
prepared.workflow_slug,
)
}
(
prepared.validated,
prepared.raw_source,
prepared.run_cfg,
prepared.sandbox_provider,
prepared.workflow_slug,
)
} else if let Some(ref rec) = record {
// Use the fully transformed graph from the RunRecord
let graph = rec.graph.clone();
let validated = create_from_graph(rec.graph.clone(), String::new());
let source = String::new(); // no DOT source needed — graph is from RunRecord
let run_cfg = Some(rec.config.clone());
let sandbox_provider = if args.dry_run {
@ -581,7 +583,7 @@ async fn prepare_from_branch(
args.sandbox.map(Into::into).unwrap_or(sp)
};
(
graph,
validated,
source,
run_cfg,
sandbox_provider,
@ -590,6 +592,7 @@ async fn prepare_from_branch(
} else {
bail!("no run.json found on metadata branch for run {run_id}");
};
let graph = validated.graph().clone();
eprintln!(
"{} {} from branch {} ({})",
@ -903,7 +906,7 @@ async fn prepare_from_branch(
Ok(ResumeContext {
checkpoint,
graph,
validated,
run_id,
run_dir,
run_cfg,
@ -931,7 +934,7 @@ async fn run_resumed(
) -> anyhow::Result<()> {
let ResumeContext {
checkpoint,
graph,
validated,
run_id,
run_dir,
mut run_cfg,
@ -948,21 +951,7 @@ async fn run_resumed(
github_app,
mut status_guard,
} = ctx;
// Track the last git commit SHA from CheckpointCompleted events
let last_git_sha: Arc<std::sync::Mutex<Option<String>>> = Arc::new(std::sync::Mutex::new(None));
{
let sha_clone = Arc::clone(&last_git_sha);
emitter.on_event(move |event| {
if let fabro_workflows::event::WorkflowRunEvent::CheckpointCompleted {
git_commit_sha: Some(sha),
..
} = event
{
*sha_clone.lock().unwrap() = Some(sha.clone());
}
});
}
let graph = validated.graph().clone();
// Create progress UI (verbose mode shows detailed turn/tool counts and token usage)
let is_tty = std::io::stderr().is_terminal();
@ -1238,34 +1227,7 @@ async fn run_resumed(
}
}
});
let mut engine = fabro_workflows::engine::WorkflowRunEngine::with_interviewer(
registry,
Arc::clone(&emitter),
interviewer,
Arc::clone(&sandbox),
);
if !sandbox_env.is_empty() {
engine.set_env(sandbox_env);
}
if dry_run_mode {
engine.set_dry_run(true);
}
// Wire up hook runner from run defaults (mirrors run_command)
{
let hooks = run_cfg
.as_ref()
.map(|cfg| &cfg.hooks)
.unwrap_or(&run_defaults.hooks);
if !hooks.is_empty() {
let hook_config = fabro_hooks::HookConfig {
hooks: hooks.clone(),
};
let runner = fabro_hooks::HookRunner::new(hook_config);
engine.set_hook_runner(Arc::new(runner));
}
}
let lifecycle = fabro_workflows::engine::LifecycleConfig {
let lifecycle = LifecycleConfig {
setup_commands,
setup_command_timeout_ms: 300_000,
devcontainer_phases,
@ -1274,29 +1236,56 @@ async fn run_resumed(
// Defuse the bootstrap guard — engine.run() has taken ownership of lifecycle status.
status_guard.defuse();
// Safety net: if we panic or return early, best-effort cleanup via spawn (mirrors run_command).
let preserve = super::run::resolve_preserve_sandbox(
args.preserve_sandbox,
run_cfg.as_ref(),
&run_defaults,
);
let sandbox_for_cleanup = Arc::clone(&sandbox);
let cleanup_guard = scopeguard::guard((), move |()| {
if preserve {
return;
}
let rt = tokio::runtime::Handle::try_current();
if let Ok(handle) = rt {
handle.spawn(async move {
let _ = sandbox_for_cleanup.cleanup().await;
});
}
});
let run_start = Instant::now();
let engine_result = engine
.run_with_lifecycle(&graph, &mut settings, lifecycle, Some(&checkpoint))
.await;
let pr_config = settings.pull_request().cloned();
let started = start(
validated,
StartOptions {
init: fabro_workflows::pipeline::InitOptions {
run_id: run_id.clone(),
run_dir: run_dir.clone(),
dry_run: dry_run_mode,
emitter: Arc::clone(&emitter),
sandbox: Arc::clone(&sandbox),
registry: Arc::new(registry),
lifecycle,
run_settings: settings,
hooks: fabro_hooks::HookConfig {
hooks: run_cfg
.as_ref()
.map(|cfg| cfg.hooks.clone())
.unwrap_or_else(|| run_defaults.hooks.clone()),
},
sandbox_env,
checkpoint: Some(checkpoint),
seed_context: None,
},
retro: StartRetroConfig {
enabled: !args.no_retro && project_config::is_retro_enabled(),
dry_run: dry_run_mode,
llm_client: if dry_run_mode {
None
} else {
fabro_llm::client::Client::from_env().await.ok()
},
provider: provider_enum,
model: model.clone(),
},
finalize: StartFinalizeConfig {
preserve_sandbox: preserve,
pr_config,
github_app: github_app.clone(),
origin_url: origin_url.clone(),
model: model.clone(),
},
},
)
.await;
let run_duration_ms = run_start.elapsed().as_millis() as u64;
let mut completion_guard = DetachedRunCompletionGuard::arm(&run_dir);
@ -1305,263 +1294,45 @@ async fn run_resumed(
let _ = std::env::set_current_dir(cwd);
}
let (final_status, failure_reason, run_status, status_reason) =
classify_engine_result(&engine_result);
let conclusion = build_conclusion(
&run_dir,
final_status.clone(),
failure_reason,
run_duration_ms,
last_git_sha.lock().unwrap().clone(),
);
// Auto-derive retro
if !args.no_retro && project_config::is_retro_enabled() {
let failed = match &engine_result {
Ok(ref o) => o.status == StageStatus::Fail,
Err(_) => true,
};
let llm_client = if dry_run_mode {
None
} else {
fabro_llm::client::Client::from_env().await.ok()
};
generate_retro(
&settings.run_id,
&graph.name,
graph.goal(),
&run_dir,
failed,
run_duration_ms,
dry_run_mode,
llm_client.as_ref(),
&sandbox,
provider_enum,
&model,
styles,
Some(Arc::clone(&emitter)),
)
.await;
}
// Finish progress bars after retro (retro stage uses the same ProgressUI)
progress_ui.lock().expect("progress lock poisoned").finish();
// Write finalize commit with retro.json + final node files (captures last diff.patch)
write_finalize_commit(&settings, &run_dir).await;
// Auto-create PR on successful completion (mirrors run_command)
let mut pushed_branch: Option<String> = None;
let mut pr_url: Option<String> = None;
if let Some(pr_cfg) = settings.pull_request() {
if settings.dry_run {
debug!("Skipping PR creation: dry-run mode");
} else if let Err(ref e) = engine_result {
debug!(error = %e, "Skipping PR creation: engine returned an error");
} else if let Ok(ref outcome) = engine_result {
if !matches!(
outcome.status,
StageStatus::Success | StageStatus::PartialSuccess
) {
debug!(status = ?outcome.status, "Skipping PR creation: run status is not success");
} else {
let diff = tokio::fs::read_to_string(run_dir.join("final.patch"))
.await
.unwrap_or_default();
if let (
Some(ref base_branch),
Some(ref run_branch),
Some(ref creds),
Some(ref origin),
) = (
&settings.base_branch,
settings.git.as_ref().and_then(|g| g.run_branch.as_ref()),
&github_app,
&origin_url,
) {
if settings.git.is_some() {
pushed_branch = Some(run_branch.to_string());
}
let auto_merge = if pr_cfg.auto_merge {
Some(fabro_workflows::pull_request::AutoMergeConfig {
merge_strategy: pr_cfg.merge_strategy,
})
} else {
None
};
match fabro_workflows::pull_request::maybe_open_pull_request(
creds,
origin,
base_branch,
run_branch,
graph.goal(),
&diff,
&model,
pr_cfg.draft,
auto_merge,
&run_dir,
)
.await
{
Ok(Some(record)) => {
emitter.emit(
&fabro_workflows::event::WorkflowRunEvent::PullRequestCreated {
pr_url: record.html_url.clone(),
pr_number: record.number,
draft: pr_cfg.draft,
},
);
pr_url = Some(record.html_url.clone());
if let Err(e) = record.save(&run_dir.join("pull_request.json")) {
tracing::warn!(error = %e, "Failed to save pull_request.json");
}
}
Ok(None) => {} // empty diff, logged at DEBUG
Err(e) => {
emitter.emit(
&fabro_workflows::event::WorkflowRunEvent::PullRequestFailed {
error: e.to_string(),
},
);
emit_run_notice(
&emitter,
RunNoticeLevel::Warn,
"pull_request_failed",
format!("PR creation failed: {e}"),
);
}
}
}
let final_status = match started {
Ok(started) => {
if let Some(ref retro) = started.retro {
print_retro_result(retro, started.retro_duration, &run_dir, styles);
}
}
} else {
debug!("Skipping PR creation: pull_request not enabled in config");
}
// Defuse the cleanup guard — we are about to do explicit cleanup
scopeguard::ScopeGuard::into_inner(cleanup_guard);
// Cleanup sandbox via engine (fires SandboxCleanup hook)
// Before cleanup, print preserve banner (mirrors run_command)
if preserve {
let info = sandbox.sandbox_info();
if !info.is_empty() {
emit_run_notice(
&emitter,
RunNoticeLevel::Info,
"sandbox_preserved",
format!("sandbox preserved: {info}"),
);
} else {
emit_run_notice(
&emitter,
RunNoticeLevel::Info,
"sandbox_preserved",
"sandbox preserved",
let finalized = started.finalized;
print_run_conclusion(
&finalized.conclusion,
&run_id,
&run_dir,
finalized.pushed_branch.as_deref(),
finalized.pr_url.as_deref(),
styles,
);
print_final_output(&run_dir, styles);
print_assets(&run_dir, styles);
finalized.conclusion.status.clone()
}
Err(err) => {
let engine_result: Result<fabro_workflows::outcome::Outcome, _> = Err(err.clone());
let (final_status, failure_reason, run_status, status_reason) =
classify_engine_result(&engine_result);
let conclusion = build_conclusion(
&run_dir,
final_status.clone(),
failure_reason,
run_duration_ms,
None,
);
persist_terminal_outcome(&run_dir, &conclusion, run_status, status_reason);
print_run_conclusion(&conclusion, &run_id, &run_dir, None, None, styles);
print_final_output(&run_dir, styles);
print_assets(&run_dir, styles);
final_status
}
}
if let Err(e) = engine
.cleanup_sandbox(&settings.run_id, &graph.name, preserve)
.await
{
tracing::warn!(error = %e, "Sandbox cleanup failed");
emit_run_notice(
&emitter,
RunNoticeLevel::Warn,
"sandbox_cleanup_failed",
format!("sandbox cleanup failed: {e}"),
);
}
persist_terminal_outcome(&run_dir, &conclusion, run_status, status_reason);
completion_guard.defuse();
eprintln!("\n{}", styles.bold.apply_to("=== Run Result ==="));
eprintln!("{}", styles.dim.apply_to(format!("Run: {run_id}")));
let status_str = final_status.to_string().to_uppercase();
let status_color = match final_status {
StageStatus::Success | StageStatus::PartialSuccess => &styles.bold_green,
_ => &styles.bold_red,
};
eprintln!("Status: {}", status_color.apply_to(&status_str));
eprintln!(
"Duration: {}",
HumanDuration(Duration::from_millis(run_duration_ms))
);
{
use crate::commands::shared::format_tokens_human;
use fabro_workflows::cost::format_cost;
let acc = accumulator.lock().unwrap();
let total_tokens = acc.total_input_tokens + acc.total_output_tokens;
if total_tokens > 0 {
if acc.has_pricing {
eprintln!(
"{}",
styles.dim.apply_to(format!(
"Cost: {} ({} toks)",
format_cost(acc.total_cost),
format_tokens_human(total_tokens)
))
);
} else {
eprintln!(
"{}",
styles
.dim
.apply_to(format!("Toks: {}", format_tokens_human(total_tokens)))
);
}
if acc.total_cache_read_tokens > 0 {
eprintln!(
"{}",
styles.dim.apply_to(format!(
"Cache: {} read, {} write",
format_tokens_human(acc.total_cache_read_tokens),
format_tokens_human(acc.total_cache_write_tokens),
)),
);
}
if acc.total_reasoning_tokens > 0 {
eprintln!(
"{}",
styles.dim.apply_to(format!(
"Reasoning: {} tokens",
format_tokens_human(acc.total_reasoning_tokens),
)),
);
}
}
}
eprintln!(
"{}",
styles
.dim
.apply_to(format!("Run: {}", tilde_path(&run_dir)))
);
if let Some(failure) = conclusion.failure_reason.as_deref() {
eprintln!("Failure: {}", styles.red.apply_to(failure));
}
if pushed_branch.is_some() || pr_url.is_some() {
eprintln!();
if let Some(ref branch) = pushed_branch {
eprintln!("{} {branch}", styles.bold.apply_to("Pushed branch:"));
}
if let Some(ref url) = pr_url {
eprintln!("{} {url}", styles.bold.apply_to("Pull request:"));
}
}
print_final_output(&run_dir, styles);
print_assets(&run_dir, styles);
completion_guard.defuse();
fabro_util::run_log::deactivate();
match final_status {

View file

@ -28,22 +28,22 @@ pub struct RewindArgs {
pub fn run(args: &RewindArgs, styles: &Styles) -> Result<()> {
let repo = Repository::discover(".").context("not in a git repository")?;
let run_id = fabro_workflows::run_rewind::find_run_id_by_prefix(&repo, &args.run_id)?;
let run_id = fabro_workflows::operations::find_run_id_by_prefix(&repo, &args.run_id)?;
let store = Store::new(repo);
let timeline = fabro_workflows::run_rewind::build_timeline(&store, &run_id)?;
let timeline = fabro_workflows::operations::build_timeline(&store, &run_id)?;
if args.list || args.target.is_none() {
let parallel_map = fabro_workflows::run_rewind::load_parallel_map(&store, &run_id);
let parallel_map = fabro_workflows::operations::load_parallel_map(&store, &run_id);
print_timeline(&timeline, &parallel_map, styles);
return Ok(());
}
let target = fabro_workflows::run_rewind::parse_target(args.target.as_deref().unwrap())?;
let parallel_map = fabro_workflows::run_rewind::load_parallel_map(&store, &run_id);
let entry = fabro_workflows::run_rewind::resolve_target(&timeline, &target, &parallel_map)?;
let target = fabro_workflows::operations::parse_target(args.target.as_deref().unwrap())?;
let parallel_map = fabro_workflows::operations::load_parallel_map(&store, &run_id);
let entry = fabro_workflows::operations::resolve_target(&timeline, &target, &parallel_map)?;
fabro_workflows::run_rewind::execute_rewind(&store, &run_id, entry, !args.no_push)?;
fabro_workflows::operations::rewind(&store, &run_id, entry, !args.no_push)?;
eprintln!(
"\nTo resume: fabro resume {}",
@ -54,7 +54,7 @@ pub fn run(args: &RewindArgs, styles: &Styles) -> Result<()> {
}
pub(crate) fn print_timeline(
timeline: &[fabro_workflows::run_rewind::TimelineEntry],
timeline: &[fabro_workflows::operations::TimelineEntry],
parallel_map: &std::collections::HashMap<String, String>,
styles: &Styles,
) {

View file

@ -19,11 +19,17 @@ use fabro_workflows::backend::{AgentApiBackend, AgentCliBackend, BackendRouter};
use fabro_workflows::checkpoint::Checkpoint;
use fabro_workflows::cost::{compute_stage_cost, format_cost};
use fabro_workflows::devcontainer_bridge;
use fabro_workflows::engine::{GitCheckpointSettings, RunSettings, WorkflowRunEngine};
use fabro_workflows::event::{EventEmitter, RunNoticeLevel, WorkflowRunEvent};
use fabro_workflows::git::GitSyncStatus;
use fabro_workflows::handler::default_registry;
use fabro_workflows::operations::{
create_from_graph, start, StartFinalizeConfig, StartOptions, StartRetroConfig,
};
use fabro_workflows::outcome::StageStatus;
use fabro_workflows::pipeline::{
build_conclusion, classify_engine_result, persist_terminal_outcome,
};
use fabro_workflows::run_settings::{GitCheckpointSettings, LifecycleConfig, RunSettings};
use fabro_workflows::sandbox_provider::SandboxProvider;
use indicatif::HumanDuration;
use std::time::Duration;
@ -35,10 +41,6 @@ use crate::commands::shared::{
format_tokens_human, print_diagnostics, read_workflow_file, relative_path, tilde_path,
};
pub(crate) use fabro_workflows::pipeline::{
build_conclusion, classify_engine_result, persist_terminal_outcome, write_finalize_commit,
};
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum CliSandboxProvider {
Local,
@ -743,7 +745,7 @@ pub(crate) fn prepare_workflow_with_project_config(
/// Pre-prepared run state, used to skip workflow preparation in `run_command_impl`.
struct RecordBasedRun {
graph: fabro_graphviz::graph::Graph,
validated: fabro_workflows::pipeline::Validated,
raw_source: String,
run_cfg: Option<FabroConfig>,
sandbox_provider: SandboxProvider,
@ -789,7 +791,7 @@ pub async fn run_from_record(
let record_run = RecordBasedRun {
raw_source: String::new(), // Raw DOT provenance is best-effort for record-based runs
graph: record.graph.clone(),
validated: create_from_graph(record.graph.clone(), String::new()),
run_cfg: Some(record.config.clone()),
sandbox_provider,
model: model.clone(),
@ -853,10 +855,9 @@ pub async fn run_command(
run_defaults,
workflow_toml_path,
} = prepare_workflow(&args, run_defaults, styles, false)?;
let (graph, _source, _diagnostics) = validated.into_parts();
let record_run = RecordBasedRun {
graph,
validated,
raw_source,
run_cfg,
sandbox_provider,
@ -878,7 +879,7 @@ async fn run_command_impl(
record_run: Option<RecordBasedRun>,
) -> anyhow::Result<()> {
let (
graph,
validated,
raw_source,
mut run_cfg,
sandbox_provider,
@ -889,7 +890,7 @@ async fn run_command_impl(
workflow_toml_path,
) = match record_run {
Some(rr) => (
rr.graph,
rr.validated,
rr.raw_source,
rr.run_cfg,
rr.sandbox_provider,
@ -901,6 +902,7 @@ async fn run_command_impl(
),
None => unreachable!("run_command_impl always receives a RecordBasedRun"),
};
let graph = validated.graph().clone();
// For record-based runs from run_from_record, workflow is None (preparation was skipped).
let from_record = args.workflow.is_none();
@ -1048,21 +1050,6 @@ async fn run_command_impl(
// 3. Build event emitter
let emitter = EventEmitter::new();
// Track the last git commit SHA from CheckpointCompleted events
let last_git_sha: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
{
let sha_clone = Arc::clone(&last_git_sha);
emitter.on_event(move |event| {
if let fabro_workflows::event::WorkflowRunEvent::CheckpointCompleted {
git_commit_sha: Some(sha),
..
} = event
{
*sha_clone.lock().unwrap() = Some(sha.clone());
}
});
}
// Cost accumulator — shared across all verbosity levels
let accumulator = Arc::new(Mutex::new(CostAccumulator::default()));
let acc_clone = Arc::clone(&accumulator);
@ -1689,32 +1676,6 @@ async fn run_command_impl(
}
}
});
let mut engine = WorkflowRunEngine::with_interviewer(
registry,
Arc::clone(&emitter),
interviewer,
Arc::clone(&sandbox),
);
if !sandbox_env.is_empty() {
engine.set_env(sandbox_env);
}
if dry_run_mode {
engine.set_dry_run(true);
}
// Wire up hook runner from run config or run defaults
{
let hooks = run_cfg
.as_ref()
.map(|c| &c.hooks)
.unwrap_or(&run_defaults.hooks);
if !hooks.is_empty() {
let hook_config = fabro_hooks::HookConfig {
hooks: hooks.clone(),
};
let runner = fabro_hooks::HookRunner::new(hook_config);
engine.set_hook_runner(Arc::new(runner));
}
}
// 7. Execute
// Set up metadata branch for git checkpointing (host or remote — engine fills remote)
@ -1728,7 +1689,7 @@ async fn run_command_impl(
None
};
let mut config = RunSettings {
let config = RunSettings {
config: settings_config,
run_dir: run_dir.clone(),
cancel_token: None,
@ -1754,7 +1715,7 @@ async fn run_command_impl(
};
// Build lifecycle config for sandbox init, setup commands, and devcontainer phases
let lifecycle = fabro_workflows::engine::LifecycleConfig {
let lifecycle = LifecycleConfig {
setup_commands,
setup_command_timeout_ms: 300_000,
devcontainer_phases: if let Some(ref dc) = devcontainer_config {
@ -1771,287 +1732,97 @@ async fn run_command_impl(
// Defuse the bootstrap guard — engine.run() has taken ownership of lifecycle status.
status_guard.defuse();
// Safety net: if we panic or return early, best-effort cleanup via spawn.
let sandbox_for_cleanup = Arc::clone(&sandbox);
let cleanup_guard = scopeguard::guard((), move |()| {
if preserve_sandbox {
return;
}
let rt = tokio::runtime::Handle::try_current();
if let Ok(handle) = rt {
handle.spawn(async move {
let _ = sandbox_for_cleanup.cleanup().await;
});
}
});
let run_start = Instant::now();
let engine_result = engine
.run_with_lifecycle(&graph, &mut config, lifecycle, None)
.await;
let pr_config = config.pull_request().cloned();
let started = start(
validated,
StartOptions {
init: fabro_workflows::pipeline::InitOptions {
run_id: run_id.clone(),
run_dir: run_dir.clone(),
dry_run: dry_run_mode,
emitter: Arc::clone(&emitter),
sandbox: Arc::clone(&sandbox),
registry: Arc::new(registry),
lifecycle,
run_settings: config,
hooks: fabro_hooks::HookConfig {
hooks: run_cfg
.as_ref()
.map(|c| c.hooks.clone())
.unwrap_or_else(|| run_defaults.hooks.clone()),
},
sandbox_env,
checkpoint: None,
seed_context: None,
},
retro: StartRetroConfig {
enabled: !no_retro_flag && project_config::is_retro_enabled(),
dry_run: dry_run_mode,
llm_client: llm_client.clone(),
provider: provider_enum,
model: model.clone(),
},
finalize: StartFinalizeConfig {
preserve_sandbox,
pr_config,
github_app: github_app.clone(),
origin_url: origin_url.clone(),
model: model.clone(),
},
},
)
.await;
let run_duration_ms = run_start.elapsed().as_millis() as u64;
let mut completion_guard = DetachedRunCompletionGuard::arm(&run_dir);
// Restore cwd (worktree is kept for `fabro cp` access; pruned separately)
let _ = std::env::set_current_dir(&original_cwd);
let (final_status, failure_reason, run_status, status_reason) =
classify_engine_result(&engine_result);
let conclusion = build_conclusion(
&run_dir,
final_status.clone(),
failure_reason,
run_duration_ms,
last_git_sha.lock().unwrap().clone(),
);
// Auto-derive retro (always, cheap) and optionally run retro agent
if !no_retro_flag && project_config::is_retro_enabled() {
let failed = match &engine_result {
Ok(ref o) => o.status == StageStatus::Fail,
Err(_) => true,
};
generate_retro(
&config.run_id,
&graph.name,
graph.goal(),
&run_dir,
failed,
run_duration_ms,
dry_run_mode,
llm_client.as_ref(),
&sandbox,
provider_enum,
&model,
styles,
Some(Arc::clone(&emitter)),
)
.await;
}
// Finish progress bars after retro (retro stage uses the same ProgressUI)
progress_ui.lock().expect("progress lock poisoned").finish();
// Write finalize commit with retro.json + final node files (captures last diff.patch)
write_finalize_commit(&config, &run_dir).await;
// Auto-create PR on successful completion (skip in dry-run mode)
let mut pushed_branch: Option<String> = None;
let mut pr_url: Option<String> = None;
if let Some(pr_cfg) = config.pull_request() {
if dry_run_mode {
debug!("Skipping PR creation: dry-run mode");
} else if let Err(ref e) = engine_result {
debug!(error = %e, "Skipping PR creation: engine returned an error");
} else if let Ok(ref outcome) = engine_result {
if !matches!(
outcome.status,
StageStatus::Success | StageStatus::PartialSuccess
) {
debug!(status = ?outcome.status, "Skipping PR creation: run status is not success");
} else {
let diff = tokio::fs::read_to_string(run_dir.join("final.patch"))
.await
.unwrap_or_default();
if let (
Some(ref base_branch),
Some(ref run_branch),
Some(ref creds),
Some(ref origin),
) = (
&config.base_branch,
config.git.as_ref().and_then(|g| g.run_branch.as_ref()),
&github_app,
&origin_url,
) {
// Run branch was pushed during checkpoint commits;
// just record it for the PR creation.
if config.git.is_some() {
pushed_branch = Some(run_branch.to_string());
}
let auto_merge = if pr_cfg.auto_merge {
Some(fabro_workflows::pull_request::AutoMergeConfig {
merge_strategy: pr_cfg.merge_strategy,
})
} else {
None
};
match fabro_workflows::pull_request::maybe_open_pull_request(
creds,
origin,
base_branch,
run_branch,
graph.goal(),
&diff,
&model,
pr_cfg.draft,
auto_merge,
&run_dir,
)
.await
{
Ok(Some(record)) => {
emitter.emit(
&fabro_workflows::event::WorkflowRunEvent::PullRequestCreated {
pr_url: record.html_url.clone(),
pr_number: record.number,
draft: pr_cfg.draft,
},
);
pr_url = Some(record.html_url.clone());
if let Err(e) = record.save(&run_dir.join("pull_request.json")) {
tracing::warn!(error = %e, "Failed to save pull_request.json");
}
}
Ok(None) => {} // empty diff, logged at DEBUG
Err(e) => {
emitter.emit(
&fabro_workflows::event::WorkflowRunEvent::PullRequestFailed {
error: e.to_string(),
},
);
emit_run_notice(
&emitter,
RunNoticeLevel::Warn,
"pull_request_failed",
format!("PR creation failed: {e}"),
);
}
}
}
let final_status = match started {
Ok(started) => {
if let Some(ref retro) = started.retro {
print_retro_result(retro, started.retro_duration, &run_dir, styles);
}
let finalized = started.finalized;
print_run_conclusion(
&finalized.conclusion,
&run_id,
&run_dir,
finalized.pushed_branch.as_deref(),
finalized.pr_url.as_deref(),
styles,
);
print_final_output(&run_dir, styles);
print_assets(&run_dir, styles);
finalized.conclusion.status.clone()
}
Err(err) => {
let engine_result = Err(err.clone());
let (final_status, failure_reason, run_status, status_reason) =
classify_engine_result(&engine_result);
let conclusion = build_conclusion(
&run_dir,
final_status.clone(),
failure_reason,
run_duration_ms,
None,
);
persist_terminal_outcome(&run_dir, &conclusion, run_status, status_reason);
print_run_conclusion(&conclusion, &run_id, &run_dir, None, None, styles);
print_final_output(&run_dir, styles);
print_assets(&run_dir, styles);
final_status
}
} else {
debug!("Skipping PR creation: pull_request not enabled in config");
}
// 8. Print result
eprintln!("\n{}", styles.bold.apply_to("=== Run Result ==="),);
eprintln!("{}", styles.dim.apply_to(format!("Run: {run_id}")));
let status_str = final_status.to_string().to_uppercase();
let status_color = match final_status {
StageStatus::Success | StageStatus::PartialSuccess => &styles.bold_green,
_ => &styles.bold_red,
};
eprintln!("Status: {}", status_color.apply_to(&status_str),);
eprintln!(
"Duration: {}",
HumanDuration(Duration::from_millis(run_duration_ms))
);
{
let acc = accumulator.lock().unwrap();
let total_tokens = acc.total_input_tokens + acc.total_output_tokens;
if total_tokens > 0 {
if acc.has_pricing {
eprintln!(
"{}",
styles.dim.apply_to(format!(
"Cost: {} ({} toks)",
format_cost(acc.total_cost),
format_tokens_human(total_tokens)
))
);
} else {
eprintln!(
"{}",
styles
.dim
.apply_to(format!("Toks: {}", format_tokens_human(total_tokens)))
);
}
if acc.total_cache_read_tokens > 0 {
eprintln!(
"{}",
styles.dim.apply_to(format!(
"Cache: {} read, {} write",
format_tokens_human(acc.total_cache_read_tokens),
format_tokens_human(acc.total_cache_write_tokens),
)),
);
}
if acc.total_reasoning_tokens > 0 {
eprintln!(
"{}",
styles.dim.apply_to(format!(
"Reasoning: {} tokens",
format_tokens_human(acc.total_reasoning_tokens),
)),
);
}
}
}
eprintln!(
"{}",
styles
.dim
.apply_to(format!("Run: {}", tilde_path(&run_dir)))
);
if let Some(failure) = conclusion.failure_reason.as_deref() {
eprintln!("Failure: {}", styles.red.apply_to(failure));
}
if pushed_branch.is_some() || pr_url.is_some() {
eprintln!();
if let Some(ref branch) = pushed_branch {
eprintln!("{} {branch}", styles.bold.apply_to("Pushed branch:"));
}
if let Some(ref url) = pr_url {
eprintln!("{} {url}", styles.bold.apply_to("Pull request:"));
}
}
print_final_output(&run_dir, styles);
print_assets(&run_dir, styles);
// 9. Cleanup sandbox (defuse the scopeguard so we await properly)
scopeguard::ScopeGuard::into_inner(cleanup_guard);
if preserve_sandbox {
let info = sandbox.sandbox_info();
if !info.is_empty() {
emit_run_notice(
&emitter,
RunNoticeLevel::Info,
"sandbox_preserved",
format!("sandbox preserved: {info}"),
);
} else {
emit_run_notice(
&emitter,
RunNoticeLevel::Info,
"sandbox_preserved",
"sandbox preserved",
);
}
}
if let Err(e) = engine
.cleanup_sandbox(&run_id, &graph.name, preserve_sandbox)
.await
{
tracing::warn!(error = %e, "Sandbox cleanup failed");
emit_run_notice(
&emitter,
RunNoticeLevel::Warn,
"sandbox_cleanup_failed",
format!("sandbox cleanup failed: {e}"),
);
}
persist_terminal_outcome(&run_dir, &conclusion, run_status, status_reason);
completion_guard.defuse();
// 10. Exit code
fabro_util::run_log::deactivate();
match final_status {
StageStatus::Success | StageStatus::PartialSuccess => Ok(()),
_ => {
std::process::exit(1);
}
_ => std::process::exit(1),
}
}
@ -2078,6 +1849,36 @@ pub fn print_run_summary(run_dir: &Path, run_id: &str, styles: &Styles) {
return;
};
// PR info from pull_request.json (saved by _run_engine)
let pr_url = std::fs::read_to_string(run_dir.join("pull_request.json"))
.ok()
.and_then(|content| {
serde_json::from_str::<fabro_workflows::pull_request::PullRequestRecord>(&content)
.ok()
.map(|record| record.html_url)
});
print_run_conclusion(
&conclusion,
run_id,
run_dir,
None,
pr_url.as_deref(),
styles,
);
print_final_output(run_dir, styles);
print_assets(run_dir, styles);
}
pub(crate) fn print_run_conclusion(
conclusion: &fabro_workflows::conclusion::Conclusion,
run_id: &str,
run_dir: &Path,
pushed_branch: Option<&str>,
pr_url: Option<&str>,
styles: &Styles,
) {
eprintln!("\n{}", styles.bold.apply_to("=== Run Result ==="));
eprintln!("{}", styles.dim.apply_to(format!("Run: {run_id}")));
@ -2147,22 +1948,74 @@ pub fn print_run_summary(run_dir: &Path, run_id: &str, styles: &Styles) {
eprintln!("Failure: {}", styles.red.apply_to(failure));
}
// PR info from pull_request.json (saved by _run_engine)
if let Ok(content) = std::fs::read_to_string(run_dir.join("pull_request.json")) {
if let Ok(record) =
serde_json::from_str::<fabro_workflows::pull_request::PullRequestRecord>(&content)
{
eprintln!();
eprintln!(
"{} {}",
styles.bold.apply_to("Pull request:"),
record.html_url
);
if pushed_branch.is_some() || pr_url.is_some() {
eprintln!();
if let Some(branch) = pushed_branch {
eprintln!("{} {branch}", styles.bold.apply_to("Pushed branch:"));
}
if let Some(url) = pr_url {
eprintln!("{} {url}", styles.bold.apply_to("Pull request:"));
}
}
}
print_final_output(run_dir, styles);
print_assets(run_dir, styles);
pub(crate) fn print_retro_result(
retro: &fabro_retro::retro::Retro,
duration: Duration,
run_dir: &Path,
styles: &Styles,
) {
eprintln!("\n{}", styles.bold.apply_to("=== Retro ==="));
let retro_dur = run_progress::format_duration_short(duration);
let smoothness_str = retro
.smoothness
.as_ref()
.map(|s| s.to_string())
.unwrap_or_else(|| "unknown".to_string());
let outcome_str = retro.outcome.as_deref().unwrap_or("No outcome recorded");
let line1_content = format!("Retro: {smoothness_str} - {outcome_str}");
let term_width = console::Term::stderr().size().1 as usize;
let pad1 = term_width.saturating_sub(line1_content.len() + retro_dur.len());
eprintln!(
"{} {}{:pad1$}{}",
styles.bold.apply_to("Retro:"),
styles
.dim
.apply_to(format!("{smoothness_str} - {outcome_str}")),
"",
styles.dim.apply_to(&retro_dur),
);
let friction_count = retro.friction_points.as_ref().map(|v| v.len()).unwrap_or(0);
let open_count = retro.open_items.as_ref().map(|v| v.len()).unwrap_or(0);
if friction_count > 0 || open_count > 0 {
let mut parts = Vec::new();
if friction_count > 0 {
let noun = if friction_count == 1 {
"friction point"
} else {
"friction points"
};
parts.push(format!("{friction_count} {noun}"));
}
if open_count > 0 {
let noun = if open_count == 1 {
"open item"
} else {
"open items"
};
parts.push(format!("{open_count} {noun}"));
}
eprintln!(" {}", styles.dim.apply_to(parts.join(" · ")));
}
let retro_path = format!("{}/retro.json", tilde_path(run_dir));
eprintln!(
" {} {}",
styles.dim.apply_to("Retro saved to"),
styles.underline.apply_to(&retro_path),
);
}
/// Print the final stage output from the checkpoint, if available.
@ -2557,108 +2410,6 @@ async fn run_preflight(
}
}
/// Generate a retro report for a completed workflow run.
///
/// Derives a basic retro from the checkpoint, then optionally runs the retro agent
/// for a richer narrative. Errors are logged as warnings rather than propagated.
#[allow(clippy::too_many_arguments)]
pub(crate) async fn generate_retro(
run_id: &str,
workflow_name: &str,
goal: &str,
run_dir: &std::path::Path,
failed: bool,
run_duration_ms: u64,
dry_run_mode: bool,
llm_client: Option<&fabro_llm::client::Client>,
sandbox: &Arc<dyn fabro_agent::Sandbox>,
provider_enum: Provider,
model: &str,
styles: &'static Styles,
emitter: Option<Arc<EventEmitter>>,
) {
eprintln!("\n{}", styles.bold.apply_to("=== Retro ==="));
if emitter.is_none() {
eprintln!(
"{}",
styles.dim.apply_to(format!("Running retro ({model})..."))
);
}
let retro_start = std::time::Instant::now();
let retro = fabro_workflows::pipeline::run_retro(&fabro_workflows::pipeline::RetroOptions {
run_id: run_id.to_string(),
workflow_name: workflow_name.to_string(),
goal: goal.to_string(),
run_dir: run_dir.to_path_buf(),
sandbox: Arc::clone(sandbox),
emitter,
failed,
run_duration_ms,
enabled: true,
dry_run: dry_run_mode,
llm_client: llm_client.cloned(),
provider: provider_enum,
model: model.to_string(),
})
.await;
let retro_dur = run_progress::format_duration_short(retro_start.elapsed());
if let Some(retro) = retro {
let smoothness_str = retro
.smoothness
.as_ref()
.map(|s| s.to_string())
.unwrap_or_else(|| "unknown".to_string());
let outcome_str = retro.outcome.as_deref().unwrap_or("No outcome recorded");
let line1_content = format!("Retro: {smoothness_str} \u{2014} {outcome_str}");
let term_width = console::Term::stderr().size().1 as usize;
let dur_len = retro_dur.len();
let pad1 = term_width.saturating_sub(line1_content.len() + dur_len);
eprintln!(
"{} {}{:pad1$}{}",
styles.bold.apply_to("Retro:"),
styles
.dim
.apply_to(format!("{smoothness_str} \u{2014} {outcome_str}")),
"",
styles.dim.apply_to(&retro_dur),
);
let friction_count = retro.friction_points.as_ref().map(|v| v.len()).unwrap_or(0);
let open_count = retro.open_items.as_ref().map(|v| v.len()).unwrap_or(0);
if friction_count > 0 || open_count > 0 {
let mut parts = Vec::new();
if friction_count > 0 {
let noun = if friction_count == 1 {
"friction point"
} else {
"friction points"
};
parts.push(format!("{friction_count} {noun}"));
}
if open_count > 0 {
let noun = if open_count == 1 {
"open item"
} else {
"open items"
};
parts.push(format!("{open_count} {noun}"));
}
eprintln!(" {}", styles.dim.apply_to(parts.join(" \u{00b7} ")));
}
let retro_path = format!("{}/retro.json", tilde_path(run_dir));
eprintln!(
" {} {}",
styles.dim.apply_to("Retro saved to"),
styles.underline.apply_to(&retro_path),
);
} else {
eprintln!("{}", styles.dim.apply_to("Retro unavailable"));
}
}
pub(crate) fn build_event_envelope(
event: &fabro_workflows::event::WorkflowRunEvent,
run_id: &str,

View file

@ -16,7 +16,9 @@ pub struct ValidateArgs {
pub fn run(args: &ValidateArgs, styles: &Styles) -> anyhow::Result<()> {
let (dot_path, _cfg) = fabro_config::project::resolve_workflow(&args.workflow)?;
let (graph, diagnostics) = fabro_workflows::workflow::prepare_from_file(&dot_path)?;
let validated = fabro_workflows::operations::create_from_file(&dot_path)?;
let graph = validated.graph();
let diagnostics = validated.diagnostics();
eprintln!(
"{} ({} nodes, {} edges)",

View file

@ -6,7 +6,7 @@ use fabro_core::graph::{EdgeSelection, EdgeSpec, Graph, NodeSpec};
use fabro_graphviz::graph::types::{Edge as GvEdge, Graph as GvGraph, Node as GvNode};
use crate::context::Context;
use crate::engine;
use crate::graph_ops;
use crate::outcome::{Outcome, StageUsage};
// ---- WorkflowNode ----
@ -26,7 +26,7 @@ impl NodeSpec for WorkflowNode {
}
fn is_terminal(&self) -> bool {
engine::is_terminal(&self.0)
graph_ops::is_terminal(&self.0)
}
fn max_visits(&self) -> Option<usize> {
@ -103,7 +103,7 @@ impl Graph for WorkflowGraph {
outcome: &Outcome,
context: &Context,
) -> Option<EdgeSelection<Self>> {
let selection = engine::select_edge(
let selection = graph_ops::select_edge(
node.inner(),
outcome,
context,
@ -120,10 +120,10 @@ impl Graph for WorkflowGraph {
&self,
outcomes: &HashMap<String, Outcome>,
) -> std::result::Result<(), String> {
engine::check_goal_gates(self.inner(), outcomes)
graph_ops::check_goal_gates(self.inner(), outcomes)
}
fn get_retry_target(&self, failed_node_id: &str) -> Option<String> {
engine::get_retry_target(failed_node_id, self.inner())
graph_ops::get_retry_target(failed_node_id, self.inner())
}
}

View file

@ -14,9 +14,9 @@ use crate::context::Context;
use super::graph::WorkflowGraph;
use super::WorkflowNode;
use crate::engine;
use crate::handler::{format_panic_message, EngineServices};
use crate::outcome::{Outcome, StageStatus};
use crate::{graph_ops, run_dir};
/// Production node handler that bridges fabro-core's NodeHandler to the
/// existing fabro-workflows Handler trait via EngineServices.
@ -98,7 +98,7 @@ impl NodeHandler<WorkflowGraph> for WorkflowNodeHandler {
Err(panic_payload) => {
let msg = format_panic_message(panic_payload);
let visit = context.node_visit_count().max(1);
let panic_dir = crate::engine::node_dir(&self.run_dir, &gv_node.id, visit);
let panic_dir = run_dir::node_dir(&self.run_dir, &gv_node.id, visit);
let _ = std::fs::create_dir_all(&panic_dir);
let _ = std::fs::write(panic_dir.join("panic.txt"), &msg);
Err(CoreError::handler(HandlerErrorDetail {
@ -113,7 +113,7 @@ impl NodeHandler<WorkflowGraph> for WorkflowNodeHandler {
fn retry_policy(&self, node: &WorkflowNode, _graph: &WorkflowGraph) -> CoreRetryPolicy {
let gv_node = node.inner();
let wf_policy = engine::build_retry_policy(gv_node, &self.graph);
let wf_policy = graph_ops::build_retry_policy(gv_node, &self.graph);
CoreRetryPolicy {
max_attempts: wf_policy.max_attempts,
backoff: wf_policy.backoff,

View file

@ -10,8 +10,8 @@ use fabro_core::state::RunState;
use super::super::graph::WorkflowGraph;
use super::super::WorkflowNode;
use crate::engine;
use crate::error::{FailureCategory, FailureSignature};
use crate::graph_ops::classify_outcome;
use crate::outcome::{OutcomeExt, StageStatus, StageUsage};
type WfRunState = RunState<Option<StageUsage>>;
@ -69,7 +69,7 @@ impl RunLifecycle<WorkflowGraph> for CircuitBreakerLifecycle {
let outcome = &result.outcome;
let outcome_failure_category = if outcome.status == StageStatus::Fail {
engine::classify_outcome(outcome)
classify_outcome(outcome)
} else {
None
};
@ -117,7 +117,7 @@ impl RunLifecycle<WorkflowGraph> for CircuitBreakerLifecycle {
let outcome = ctx.outcome;
// Guard: only TransientInfra failures may trigger loop_restart
let failure_class = engine::classify_outcome(outcome);
let failure_class = classify_outcome(outcome);
if let Some(fc) = failure_class {
if fc != FailureCategory::TransientInfra {
return Ok(EdgeDecision::Block(format!(

View file

@ -12,9 +12,10 @@ use super::super::graph::WorkflowGraph;
use super::super::WorkflowNode;
use super::circuit_breaker::CircuitBreakerLifecycle;
use crate::checkpoint::Checkpoint;
use crate::engine::{self, RunSettings};
use crate::event::{EventEmitter, RunNoticeLevel, WorkflowRunEvent};
use crate::outcome::StageUsage;
use crate::run_dir::{write_node_status, write_start_record};
use crate::run_settings::RunSettings;
type WfRunState = RunState<Option<StageUsage>>;
type WfNodeResult = NodeResult<Option<StageUsage>>;
@ -38,7 +39,7 @@ impl RunLifecycle<WorkflowGraph> for DiskLifecycle {
_state: &WfRunState,
) -> fabro_core::error::Result<()> {
// Write start.json
engine::write_start_record(&self.run_dir, &self.config);
write_start_record(&self.run_dir, &self.config);
// Write run status as Running
crate::run_status::write_run_status(
&self.run_dir,
@ -56,7 +57,7 @@ impl RunLifecycle<WorkflowGraph> for DiskLifecycle {
) -> fabro_core::error::Result<()> {
let gv = node.inner();
let visit = state.node_visits.get(gv.id.as_str()).copied().unwrap_or(1);
engine::write_node_status(&self.run_dir, &gv.id, visit, &result.outcome);
write_node_status(&self.run_dir, &gv.id, visit, &result.outcome);
Ok(())
}

View file

@ -12,8 +12,8 @@ use super::super::graph::WorkflowGraph;
use super::super::WorkflowNode;
use super::git::GitCheckpointResult;
use crate::artifact::ArtifactStore;
use crate::engine;
use crate::event::{EventEmitter, WorkflowRunEvent};
use crate::graph_ops::node_script;
use crate::outcome::{FailureCategory, FailureDetail, Outcome, StageStatus, StageUsage};
type WfRunState = RunState<Option<StageUsage>>;
@ -87,7 +87,7 @@ impl RunLifecycle<WorkflowGraph> for EventLifecycle {
name: gv.label().to_string(),
index: stage_index,
handler_type: gv.handler_type().map(String::from),
script: engine::node_script(gv),
script: node_script(gv),
attempt: 1,
max_attempts: 1,
});
@ -119,7 +119,7 @@ impl RunLifecycle<WorkflowGraph> for EventLifecycle {
name: gv.label().to_string(),
index: state.stage_index,
handler_type: gv.handler_type().map(String::from),
script: engine::node_script(gv),
script: node_script(gv),
attempt: ctx.attempt as usize,
max_attempts: ctx.max_attempts as usize,
});

View file

@ -9,7 +9,7 @@ use fabro_core::state::RunState;
use super::super::graph::WorkflowGraph;
use super::super::WorkflowNode;
use crate::context::keys;
use crate::engine;
use crate::graph_ops::{resolve_fidelity, resolve_thread_id};
use crate::outcome::StageUsage;
use crate::preamble::build_preamble;
@ -67,7 +67,7 @@ impl RunLifecycle<WorkflowGraph> for FidelityLifecycle {
// 1. Fidelity resolution via resolve_fidelity: edge → node → graph default → Compact
let incoming_edge_ref = incoming.as_ref().map(|d| d.edge.as_ref());
let fidelity = engine::resolve_fidelity(incoming_edge_ref, gv_node, &self.graph);
let fidelity = resolve_fidelity(incoming_edge_ref, gv_node, &self.graph);
// 2. Fidelity degradation on resume (full → summary:high)
let fidelity = {
@ -99,7 +99,7 @@ impl RunLifecycle<WorkflowGraph> for FidelityLifecycle {
.set(keys::CURRENT_PREAMBLE, serde_json::json!(preamble));
// 5. Thread ID resolution via resolve_thread_id: edge → node → graph default → class → previous
let thread_id = engine::resolve_thread_id(
let thread_id = resolve_thread_id(
incoming_edge_ref,
gv_node,
&self.graph,

View file

@ -12,9 +12,11 @@ use fabro_core::state::RunState;
use super::super::graph::WorkflowGraph;
use super::super::WorkflowNode;
use crate::artifact::ArtifactStore;
use crate::engine::{self, RunSettings};
use crate::event::{EventEmitter, RunNoticeLevel, WorkflowRunEvent};
use crate::outcome::{Outcome, StageStatus, StageUsage};
use crate::run_dir::node_dir;
use crate::run_settings::RunSettings;
use crate::sandbox_git::{git_checkpoint, git_diff, git_push_host};
type WfRunState = RunState<Option<StageUsage>>;
type WfNodeResult = NodeResult<Option<StageUsage>>;
@ -149,7 +151,7 @@ impl RunLifecycle<WorkflowGraph> for GitLifecycle {
// Run branch commit via sandbox
let completed_count = state.completed_nodes.len();
let commit_result = engine::git_checkpoint(
let commit_result = git_checkpoint(
&*self.sandbox,
&self.run_id,
node_id,
@ -192,7 +194,7 @@ impl RunLifecycle<WorkflowGraph> for GitLifecycle {
true
} else if let Some(repo_path) = self.config.host_repo_path.as_ref() {
let refspec = format!("refs/heads/{branch}");
engine::git_push_host(
git_push_host(
repo_path,
&refspec,
&self.config.github_app,
@ -213,7 +215,7 @@ impl RunLifecycle<WorkflowGraph> for GitLifecycle {
self.config.host_repo_path.as_ref(),
) {
let refspec = format!("refs/heads/{meta_branch}");
let meta_push_ok = engine::git_push_host(
let meta_push_ok = git_push_host(
repo_path,
&refspec,
&self.config.github_app,
@ -235,9 +237,9 @@ impl RunLifecycle<WorkflowGraph> for GitLifecycle {
.clone()
.or_else(|| self.config.git.as_ref().and_then(|g| g.base_sha.clone()))
.unwrap_or_else(|| sha.clone());
let diff_dest = engine::node_dir(&self.run_dir, node_id, visit).join("diff.patch");
let diff_dest = node_dir(&self.run_dir, node_id, visit).join("diff.patch");
match engine::git_diff(&*self.sandbox, &prev).await {
match git_diff(&*self.sandbox, &prev).await {
Ok(patch) if !patch.is_empty() => {
let _ = std::fs::write(&diff_dest, patch);
}
@ -277,7 +279,7 @@ impl RunLifecycle<WorkflowGraph> for GitLifecycle {
{
if let Some(base_sha) = self.config.git.as_ref().and_then(|g| g.base_sha.clone()) {
let diff_dest = self.run_dir.join("final.patch");
match engine::git_diff(&*self.sandbox, &base_sha).await {
match git_diff(&*self.sandbox, &base_sha).await {
Ok(patch) if !patch.is_empty() => {
let _ = std::fs::write(&diff_dest, patch);
}

View file

@ -12,7 +12,7 @@ use fabro_core::state::RunState;
use super::super::graph::WorkflowGraph;
use super::super::WorkflowNode;
use crate::engine::set_hook_node;
use crate::graph_ops::set_hook_node;
use crate::outcome::{Outcome, OutcomeExt, StageStatus, StageUsage};
use fabro_hooks::{HookContext, HookDecision, HookEvent, HookRunner};
use fabro_sandbox::Sandbox;

View file

@ -27,9 +27,9 @@ use super::graph::WorkflowGraph;
use super::WorkflowNode;
use crate::artifact::ArtifactStore;
use crate::context;
use crate::engine::RunSettings;
use crate::event::EventEmitter;
use crate::outcome::{Outcome, StageUsage};
use crate::run_settings::RunSettings;
use fabro_hooks::HookRunner;
use fabro_sandbox::Sandbox;
@ -307,7 +307,7 @@ impl RunLifecycle<WorkflowGraph> for WorkflowLifecycle {
) -> CoreResult<()> {
let outcome = &result.outcome;
let retry_count = state.node_retries.get(node.id()).copied().unwrap_or(0);
let failure_class = crate::engine::classify_outcome(outcome);
let failure_class = crate::graph_ops::classify_outcome(outcome);
let failure_signature = failure_class
.map(|category| {
let signature_hint = outcome

View file

@ -28,17 +28,11 @@ use fabro_graphviz::graph::{Edge, Node};
use fabro_hooks::{HookContext, HookDecision, HookEvent, HookRunner};
use fabro_interview::Interviewer;
pub(crate) use crate::graph_ops::{
build_retry_policy, check_goal_gates, classify_outcome, get_retry_target, is_terminal,
node_script, set_hook_node,
};
pub use crate::graph_ops::{
resolve_fidelity, resolve_thread_id, select_edge, EdgeSelection, RetryPolicy,
};
pub use crate::run_dir::{node_dir, visit_from_context};
pub(crate) use crate::run_dir::{write_node_status, write_start_record};
pub use crate::run_settings::{GitCheckpointSettings, LifecycleConfig, RunSettings};
pub(crate) use crate::sandbox_git::git_diff;
pub use crate::sandbox_git::{
git_add_worktree, git_checkpoint, git_create_branch_at, git_merge_ff_only, git_push_host,
git_remove_worktree, git_replace_worktree, GitState, GIT_REMOTE,
@ -613,6 +607,7 @@ mod tests {
use super::*;
use crate::handler::start::StartHandler;
use crate::handler::Handler as HandlerTrait;
use crate::outcome::OutcomeExt;
use async_trait::async_trait;
use fabro_graphviz::graph::AttrValue;
use std::time::Duration;

View file

@ -249,8 +249,8 @@ impl Handler for AgentHandler {
};
// 2. Write prompt to logs
let visit = crate::engine::visit_from_context(context);
let stage_dir = crate::engine::node_dir(run_dir, &node.id, visit);
let visit = crate::run_dir::visit_from_context(context);
let stage_dir = crate::run_dir::node_dir(run_dir, &node.id, visit);
tokio::fs::create_dir_all(&stage_dir).await?;
tokio::fs::write(stage_dir.join("prompt.md"), &prompt).await?;

View file

@ -84,8 +84,8 @@ impl Handler for CommandHandler {
)));
}
let visit = crate::engine::visit_from_context(context);
let stage_dir = crate::engine::node_dir(run_dir, &node.id, visit);
let visit = crate::run_dir::visit_from_context(context);
let stage_dir = crate::run_dir::node_dir(run_dir, &node.id, visit);
tokio::fs::create_dir_all(&stage_dir).await?;
let invocation = serde_json::json!({

View file

@ -116,7 +116,7 @@ impl Handler for FanInHandler {
};
if let (Some(ref sha), Some(_)) = (&best_head_sha, services.git_state()) {
crate::engine::git_merge_ff_only(&*services.sandbox, sha).await;
crate::sandbox_git::git_merge_ff_only(&*services.sandbox, sha).await;
}
let mut outcome = Outcome::success();
@ -231,8 +231,8 @@ async fn llm_evaluate(
);
// Write prompt to logs
let visit = crate::engine::visit_from_context(context);
let stage_dir = crate::engine::node_dir(run_dir, node_id, visit);
let visit = crate::run_dir::visit_from_context(context);
let stage_dir = crate::run_dir::node_dir(run_dir, node_id, visit);
tokio::fs::create_dir_all(&stage_dir).await?;
tokio::fs::write(stage_dir.join("prompt.md"), &full_prompt).await?;

View file

@ -9,10 +9,11 @@ use async_trait::async_trait;
use crate::condition::evaluate_condition;
use crate::context::keys;
use crate::context::{Context, WorkflowContext};
use crate::engine::{RunSettings, WorkflowRunEngine};
use crate::engine::WorkflowRunEngine;
use crate::error::FabroError;
use crate::operations::{create, create_from_file, CreateOptions};
use crate::outcome::{Outcome, OutcomeExt, StageStatus};
use crate::workflow::{prepare_from_file, prepare_from_source};
use crate::run_settings::RunSettings;
use fabro_graphviz::graph::{Graph, Node};
use super::{EngineServices, Handler};
@ -52,7 +53,10 @@ fn parse_child_graph(node: &Node) -> Result<Graph, FabroError> {
.get("stack.child_dot_source")
.and_then(|v| v.as_str())
{
return prepare_from_source(dot);
let validated = create(dot, CreateOptions::default())?;
validated.raise_on_errors()?;
let (graph, _, _) = validated.into_parts();
return Ok(graph);
}
if let Some(path) = node
.attrs
@ -60,8 +64,9 @@ fn parse_child_graph(node: &Node) -> Result<Graph, FabroError> {
.or_else(|| node.attrs.get("stack.child_dotfile"))
.and_then(|v| v.as_str())
{
let (graph, diagnostics) = prepare_from_file(std::path::Path::new(path))?;
fabro_validate::raise_on_errors(&diagnostics)?;
let validated = create_from_file(std::path::Path::new(path))?;
validated.raise_on_errors()?;
let (graph, _, _) = validated.into_parts();
return Ok(graph);
}
Err(FabroError::handler("No child workflow source".to_string()))
@ -128,7 +133,7 @@ impl Handler for SubWorkflowHandler {
};
// Build child RunSettings
let visit = crate::engine::visit_from_context(context) as u64;
let visit = crate::run_dir::visit_from_context(context) as u64;
let child_logs = run_dir.join(format!("nodes/{}_{visit}/child", node.id));
let _ = std::fs::create_dir_all(&child_logs);

View file

@ -19,10 +19,10 @@ use async_trait::async_trait;
use fabro_agent::Sandbox;
use crate::context::Context;
use crate::engine::GitState;
use crate::error::FabroError;
use crate::event::EventEmitter;
use crate::outcome::{Outcome, OutcomeExt};
use crate::sandbox_git::GitState;
use fabro_graphviz::graph::{shape_to_handler_type, Graph, Node};
use fabro_hooks::{HookContext, HookDecision, HookRunner};
use fabro_interview::Interviewer;

View file

@ -8,9 +8,9 @@ use tokio::sync::Semaphore;
use crate::context::keys;
use crate::context::{Context, WorkflowContext};
use crate::engine::set_hook_node;
use crate::error::FabroError;
use crate::event::WorkflowRunEvent;
use crate::graph_ops::set_hook_node;
use crate::millis_u64;
use crate::outcome::{Outcome, OutcomeExt, StageStatus};
use fabro_graphviz::graph::{Graph, Node};
@ -162,7 +162,7 @@ impl Handler for ParallelHandler {
// --- Git isolation: checkpoint "parallel base" before fan-out ---
let base_sha: Option<String> = if let Some(ref gs) = git_state {
let result = crate::engine::git_checkpoint(
let result = crate::sandbox_git::git_checkpoint(
&*services.sandbox,
&gs.run_id,
&node.id,
@ -205,7 +205,7 @@ impl Handler for ParallelHandler {
(&git_state, &base_sha)
{
let branch_key = &target_id;
let visit = crate::engine::visit_from_context(&branch_context);
let visit = crate::run_dir::visit_from_context(&branch_context);
let branch_name = format!(
"fabro/run/parallel/{}/{}/pass{}/{}",
gs.run_id,
@ -327,7 +327,7 @@ impl Handler for ParallelHandler {
let nid = &setup.target_id;
let status_str = outcome.status.to_string();
// Use exec_command to commit and capture HEAD in the branch worktree
let git_r = crate::engine::GIT_REMOTE;
let git_r = crate::sandbox_git::GIT_REMOTE;
let add_cmd = format!("{git_r} add -A");
let add_result = setup
.sandbox
@ -414,7 +414,7 @@ impl Handler for ParallelHandler {
for result in &results {
if let Some(ref wt_path) = result.worktree_path {
let wt_str = wt_path.to_string_lossy().into_owned();
crate::engine::git_remove_worktree(&*services.sandbox, &wt_str).await;
crate::sandbox_git::git_remove_worktree(&*services.sandbox, &wt_str).await;
services
.emitter
.emit(&WorkflowRunEvent::GitWorktreeRemove { path: wt_str });
@ -431,7 +431,7 @@ impl Handler for ParallelHandler {
successful.sort_by(|a, b| a.id.cmp(&b.id));
if let Some(winner) = successful.first() {
let sha = winner.head_sha.as_ref().unwrap();
crate::engine::git_merge_ff_only(&*services.sandbox, sha).await;
crate::sandbox_git::git_merge_ff_only(&*services.sandbox, sha).await;
}
}
@ -463,8 +463,8 @@ impl Handler for ParallelHandler {
context.set(keys::PARALLEL_RESULTS, serde_json::json!(results_json));
context.set(keys::PARALLEL_BRANCH_COUNT, serde_json::json!(total));
let visit = crate::engine::visit_from_context(context);
let node_dir = crate::engine::node_dir(run_dir, &node.id, visit);
let visit = crate::run_dir::visit_from_context(context);
let node_dir = crate::run_dir::node_dir(run_dir, &node.id, visit);
let _ = tokio::fs::create_dir_all(&node_dir).await;
if let Ok(json) = serde_json::to_string_pretty(&results_json) {
let _ = tokio::fs::write(node_dir.join("parallel_results.json"), json).await;

View file

@ -86,8 +86,8 @@ impl Handler for PromptHandler {
};
// 2. Write prompt to logs
let visit = crate::engine::visit_from_context(context);
let stage_dir = crate::engine::node_dir(run_dir, &node.id, visit);
let visit = crate::run_dir::visit_from_context(context);
let stage_dir = crate::run_dir::node_dir(run_dir, &node.id, visit);
tokio::fs::create_dir_all(&stage_dir).await?;
tokio::fs::write(stage_dir.join("prompt.md"), &prompt).await?;

View file

@ -106,6 +106,7 @@ pub mod git;
pub mod graph_ops;
pub mod graph_render;
pub mod handler;
pub mod operations;
pub mod outcome;
pub mod pipeline;
pub mod preamble;
@ -123,6 +124,8 @@ pub mod sandbox_reconnect;
pub mod sandbox_record;
pub mod start_record;
pub mod stylesheet;
#[doc(hidden)]
pub mod test_support;
pub mod transform;
pub mod vars;
pub mod workflow;

View file

@ -0,0 +1,191 @@
use std::path::{Path, PathBuf};
use fabro_graphviz::graph::Graph;
use crate::error::FabroError;
use crate::pipeline::{self, TransformOptions, Validated};
use crate::transform::Transform;
pub struct CreateOptions {
pub base_dir: Option<PathBuf>,
pub custom_transforms: Vec<Box<dyn Transform>>,
}
impl Default for CreateOptions {
fn default() -> Self {
Self {
base_dir: None,
custom_transforms: Vec::new(),
}
}
}
/// Parse, transform, and validate a DOT source string.
///
/// Returns `Validated` even when validation produced errors. Call
/// `validated.raise_on_errors()` if the caller wants to fail fast.
pub fn create(dot_source: &str, options: CreateOptions) -> Result<Validated, FabroError> {
let parsed = pipeline::parse(dot_source)?;
let transformed = pipeline::transform(
parsed,
&TransformOptions {
base_dir: options.base_dir,
custom_transforms: options.custom_transforms,
},
);
Ok(pipeline::validate(transformed, &[]))
}
/// Read a DOT file, apply file inlining from its parent directory, then create.
pub fn create_from_file(path: &Path) -> Result<Validated, FabroError> {
let source = std::fs::read_to_string(path)
.map_err(|e| FabroError::Parse(format!("Failed to read {}: {e}", path.display())))?;
let base_dir = path.parent().unwrap_or(Path::new("."));
create(
&source,
CreateOptions {
base_dir: Some(base_dir.to_path_buf()),
..Default::default()
},
)
}
/// Build a validated workflow from an already-materialized graph.
///
/// This is used by detached/resume CLI paths that load a graph from `RunRecord`
/// instead of re-parsing DOT source.
#[doc(hidden)]
pub fn create_from_graph(graph: Graph, source: impl Into<String>) -> Validated {
Validated::new(graph, source.into(), vec![])
}
#[cfg(test)]
mod tests {
use super::*;
use fabro_graphviz::graph::AttrValue;
const MINIMAL_DOT: &str = r#"digraph Test {
graph [goal="Build feature"]
start [shape=Mdiamond]
exit [shape=Msquare]
start -> exit
}"#;
#[test]
fn create_minimal() {
let validated = create(MINIMAL_DOT, CreateOptions::default()).unwrap();
validated.raise_on_errors().unwrap();
assert_eq!(validated.graph().name, "Test");
assert!(validated.graph().find_start_node().is_some());
assert!(validated.graph().find_exit_node().is_some());
}
#[test]
fn create_applies_variable_expansion() {
let dot = r#"digraph Test {
graph [goal="Fix bugs"]
start [shape=Mdiamond]
work [prompt="Goal: $goal"]
exit [shape=Msquare]
start -> work -> exit
}"#;
let validated = create(dot, CreateOptions::default()).unwrap();
validated.raise_on_errors().unwrap();
let prompt = validated.graph().nodes["work"]
.attrs
.get("prompt")
.and_then(AttrValue::as_str)
.unwrap();
assert_eq!(prompt, "Goal: Fix bugs");
}
#[test]
fn create_applies_stylesheet() {
let dot = r#"digraph Test {
graph [goal="Test", model_stylesheet="* { model: sonnet; }"]
start [shape=Mdiamond]
work [label="Work"]
exit [shape=Msquare]
start -> work -> exit
}"#;
let validated = create(dot, CreateOptions::default()).unwrap();
validated.raise_on_errors().unwrap();
assert_eq!(
validated.graph().nodes["work"].attrs.get("model"),
Some(&AttrValue::String("claude-sonnet-4-6".into()))
);
}
#[test]
fn create_returns_error_on_invalid_dot() {
let result = create("not a graph", CreateOptions::default());
assert!(result.is_err());
}
#[test]
fn create_returns_validation_diagnostics() {
let dot = r#"digraph Test {
graph [goal="Test"]
work [label="Work"]
}"#;
let validated = create(dot, CreateOptions::default()).unwrap();
assert!(validated.has_errors());
assert!(validated.raise_on_errors().is_err());
}
#[test]
fn create_supports_custom_transforms() {
struct TagTransform;
impl Transform for TagTransform {
fn apply(&self, graph: &mut fabro_graphviz::graph::Graph) {
for node in graph.nodes.values_mut() {
node.attrs
.insert("tagged".to_string(), AttrValue::Boolean(true));
}
}
}
let validated = create(
MINIMAL_DOT,
CreateOptions {
custom_transforms: vec![Box::new(TagTransform)],
..Default::default()
},
)
.unwrap();
validated.raise_on_errors().unwrap();
assert_eq!(
validated.graph().nodes["start"].attrs.get("tagged"),
Some(&AttrValue::Boolean(true))
);
}
#[test]
fn create_from_file_uses_parent_directory_for_inlining() {
let dir = tempfile::tempdir().unwrap();
let data_path = dir.path().join("goal.txt");
let dot_path = dir.path().join("workflow.fabro");
std::fs::write(&data_path, "ship it").unwrap();
std::fs::write(
&dot_path,
r#"digraph Test {
graph [goal="@goal.txt"]
start [shape=Mdiamond]
exit [shape=Msquare]
start -> exit
}"#,
)
.unwrap();
let validated = create_from_file(&dot_path).unwrap();
validated.raise_on_errors().unwrap();
assert_eq!(validated.graph().goal(), "ship it");
}
}

View file

@ -0,0 +1 @@
pub use crate::run_fork::execute_fork as fork;

View file

@ -0,0 +1,12 @@
mod create;
mod fork;
mod rewind;
mod start;
pub use create::{create, create_from_file, create_from_graph, CreateOptions};
pub use fork::fork;
pub use rewind::{
build_timeline, find_run_id_by_prefix, load_parallel_map, parse_target, resolve_target,
rewind, TimelineEntry,
};
pub use start::{start, StartFinalizeConfig, StartOptions, StartRetroConfig, Started};

View file

@ -0,0 +1,4 @@
pub use crate::run_rewind::{
build_timeline, execute_rewind as rewind, find_run_id_by_prefix, load_parallel_map,
parse_target, resolve_target, TimelineEntry,
};

View file

@ -0,0 +1,117 @@
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use crate::error::FabroError;
use crate::event::WorkflowRunEvent;
use crate::outcome::StageStatus;
use crate::pipeline::{self, FinalizeOptions, Finalized, InitOptions, RetroOptions, Validated};
pub struct StartRetroConfig {
pub enabled: bool,
pub dry_run: bool,
pub llm_client: Option<fabro_llm::client::Client>,
pub provider: fabro_llm::Provider,
pub model: String,
}
pub struct StartFinalizeConfig {
pub preserve_sandbox: bool,
pub pr_config: Option<fabro_config::run::PullRequestConfig>,
pub github_app: Option<fabro_github::GitHubAppCredentials>,
pub origin_url: Option<String>,
pub model: String,
}
pub struct StartOptions {
pub init: InitOptions,
pub retro: StartRetroConfig,
pub finalize: StartFinalizeConfig,
}
pub struct Started {
pub finalized: Finalized,
pub retro: Option<fabro_retro::retro::Retro>,
pub retro_duration: Duration,
}
/// Run a validated workflow through initialize, execute, retro, and finalize.
pub async fn start(validated: Validated, options: StartOptions) -> Result<Started, FabroError> {
let preserve_sandbox = options.finalize.preserve_sandbox;
let sandbox_for_cleanup = Arc::clone(&options.init.sandbox);
let cleanup_guard = scopeguard::guard((), move |()| {
if preserve_sandbox {
return;
}
if let Ok(handle) = tokio::runtime::Handle::try_current() {
handle.spawn(async move {
let _ = sandbox_for_cleanup.cleanup().await;
});
}
});
let initialized = pipeline::initialize(validated, options.init).await?;
let last_git_sha: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
{
let sha_clone = Arc::clone(&last_git_sha);
initialized.emitter.on_event(move |event| {
if let WorkflowRunEvent::CheckpointCompleted {
git_commit_sha: Some(sha),
..
} = event
{
*sha_clone.lock().unwrap() = Some(sha.clone());
}
});
}
let executed = pipeline::execute(initialized).await;
let failed = !matches!(
executed.outcome.as_ref().map(|outcome| &outcome.status),
Ok(StageStatus::Success) | Ok(StageStatus::PartialSuccess)
);
let retro_opts = RetroOptions {
run_id: executed.settings.run_id.clone(),
workflow_name: executed.graph.name.clone(),
goal: executed.graph.goal().to_string(),
run_dir: executed.settings.run_dir.clone(),
sandbox: Arc::clone(&executed.sandbox),
emitter: Some(Arc::clone(&executed.emitter)),
failed,
run_duration_ms: executed.duration_ms,
enabled: options.retro.enabled,
dry_run: options.retro.dry_run,
llm_client: options.retro.llm_client,
provider: options.retro.provider,
model: options.retro.model,
};
let retro_start = Instant::now();
let retroed = pipeline::retro(executed, &retro_opts).await;
let retro_duration = retro_start.elapsed();
let finalize_opts = FinalizeOptions {
run_dir: retroed.settings.run_dir.clone(),
run_id: retroed.settings.run_id.clone(),
workflow_name: retroed.graph.name.clone(),
hook_runner: retroed.hook_runner.clone(),
preserve_sandbox: options.finalize.preserve_sandbox,
pr_config: options.finalize.pr_config,
github_app: options.finalize.github_app,
origin_url: options.finalize.origin_url,
model: options.finalize.model,
last_git_sha: last_git_sha.lock().unwrap().clone(),
};
let retro = retroed.retro.clone();
let finalized = pipeline::finalize(retroed, &finalize_opts).await?;
scopeguard::ScopeGuard::into_inner(cleanup_guard);
Ok(Started {
finalized,
retro,
retro_duration,
})
}

View file

@ -357,6 +357,7 @@ pub async fn finalize(
run_id: settings.run_id,
outcome,
conclusion,
pushed_branch: settings.git.as_ref().and_then(|g| g.run_branch.clone()),
pr_url,
})
}

View file

@ -158,6 +158,7 @@ pub struct Finalized {
pub run_id: String,
pub outcome: Result<Outcome, FabroError>,
pub conclusion: Conclusion,
pub pushed_branch: Option<String>,
pub pr_url: Option<String>,
}

View file

@ -0,0 +1,119 @@
use std::collections::HashMap;
use std::sync::Arc;
use fabro_agent::Sandbox;
use crate::checkpoint::Checkpoint;
use crate::engine::WorkflowRunEngine;
use crate::error::Result;
use crate::event::EventEmitter;
use crate::handler::HandlerRegistry;
use crate::outcome::Outcome;
use crate::run_settings::RunSettings;
pub async fn run_graph(
registry: HandlerRegistry,
emitter: Arc<EventEmitter>,
sandbox: Arc<dyn Sandbox>,
graph: &fabro_graphviz::graph::Graph,
settings: &RunSettings,
) -> Result<Outcome> {
let engine = WorkflowRunEngine::new(registry, emitter, sandbox);
engine.run(graph, settings).await
}
pub async fn run_graph_with_hooks(
registry: HandlerRegistry,
emitter: Arc<EventEmitter>,
sandbox: Arc<dyn Sandbox>,
graph: &fabro_graphviz::graph::Graph,
settings: &RunSettings,
hook_runner: Arc<fabro_hooks::HookRunner>,
env: Option<HashMap<String, String>>,
) -> Result<Outcome> {
let mut engine = WorkflowRunEngine::new(registry, emitter, sandbox);
engine.set_hook_runner(hook_runner);
if let Some(env) = env {
engine.set_env(env);
}
engine.run(graph, settings).await
}
pub async fn run_graph_from_checkpoint(
registry: HandlerRegistry,
emitter: Arc<EventEmitter>,
sandbox: Arc<dyn Sandbox>,
graph: &fabro_graphviz::graph::Graph,
settings: &RunSettings,
checkpoint: &Checkpoint,
) -> Result<Outcome> {
let engine = WorkflowRunEngine::new(registry, emitter, sandbox);
engine
.run_from_checkpoint(graph, settings, checkpoint)
.await
}
pub struct WorkflowRunner {
registry: std::sync::Mutex<Option<HandlerRegistry>>,
emitter: Arc<EventEmitter>,
sandbox: Arc<dyn Sandbox>,
}
impl WorkflowRunner {
#[must_use]
pub fn new(
registry: HandlerRegistry,
emitter: Arc<EventEmitter>,
sandbox: Arc<dyn Sandbox>,
) -> Self {
Self {
registry: std::sync::Mutex::new(Some(registry)),
emitter,
sandbox,
}
}
pub async fn run(
&self,
graph: &fabro_graphviz::graph::Graph,
settings: &RunSettings,
) -> Result<Outcome> {
let registry = self
.registry
.lock()
.unwrap()
.take()
.expect("WorkflowRunner may only be used once");
run_graph(
registry,
Arc::clone(&self.emitter),
Arc::clone(&self.sandbox),
graph,
settings,
)
.await
}
pub async fn run_from_checkpoint(
&self,
graph: &fabro_graphviz::graph::Graph,
settings: &RunSettings,
checkpoint: &Checkpoint,
) -> Result<Outcome> {
let registry = self
.registry
.lock()
.unwrap()
.take()
.expect("WorkflowRunner may only be used once");
run_graph_from_checkpoint(
registry,
Arc::clone(&self.emitter),
Arc::clone(&self.sandbox),
graph,
settings,
checkpoint,
)
.await
}
}

View file

@ -15,13 +15,14 @@ use fabro_sandbox::daytona::{DaytonaConfig, DaytonaSandbox, DaytonaSnapshotConfi
use fabro_workflows::artifact::sync_artifacts_to_env;
use fabro_workflows::checkpoint::Checkpoint;
use fabro_workflows::context::Context;
use fabro_workflows::engine::{GitCheckpointSettings, RunSettings, WorkflowRunEngine};
use fabro_workflows::error::FabroError;
use fabro_workflows::event::EventEmitter;
use fabro_workflows::handler::exit::ExitHandler;
use fabro_workflows::handler::start::StartHandler;
use fabro_workflows::handler::{Handler, HandlerRegistry};
use fabro_workflows::outcome::{Outcome, OutcomeExt, StageStatus};
use fabro_workflows::run_settings::{GitCheckpointSettings, RunSettings};
use fabro_workflows::test_support::WorkflowRunner;
async fn create_env() -> DaytonaSandbox {
let creds = load_github_app_credentials();
@ -386,7 +387,7 @@ async fn daytona_pipeline_artifact_offload_and_sync() {
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), env.clone());
let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), env.clone());
let config = RunSettings {
config: FabroConfig::default(),
run_dir: dir.path().to_path_buf(),
@ -577,7 +578,7 @@ async fn daytona_git_checkpoint_remote_emits_events() {
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
let engine = WorkflowRunEngine::new(registry, Arc::new(emitter), env.clone());
let engine = WorkflowRunner::new(registry, Arc::new(emitter), env.clone());
let config = RunSettings {
config: FabroConfig::default(),
run_dir: dir.path().to_path_buf(),
@ -762,7 +763,7 @@ async fn daytona_parallel_git_branching_e2e() {
registry.register("parallel", Box::new(ParallelHandler));
registry.register("parallel.fan_in", Box::new(FanInHandler::new(None)));
let engine = WorkflowRunEngine::new(registry, Arc::new(emitter), Arc::clone(&env));
let engine = WorkflowRunner::new(registry, Arc::new(emitter), Arc::clone(&env));
let config = RunSettings {
config: FabroConfig::default(),
@ -1139,7 +1140,7 @@ async fn daytona_git_checkpoint_with_shadow_branch() {
registry.register("exit", Box::new(ExitHandler));
let meta_branch = MetadataStore::branch_name(&run_id);
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), env.clone());
let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), env.clone());
let config = RunSettings {
config: FabroConfig::default(),
run_dir: dir.path().to_path_buf(),
@ -1246,7 +1247,7 @@ async fn daytona_asset_collection() {
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), env.clone());
let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), env.clone());
let mut graph = Graph::new("DaytonaAssetTest");
graph.attrs.insert(
@ -1535,7 +1536,7 @@ async fn daytona_git_push_run_branch_to_origin() {
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), env.clone());
let engine = WorkflowRunner::new(registry, Arc::new(EventEmitter::new()), env.clone());
let config = RunSettings {
config: FabroConfig::default(),
run_dir: dir.path().to_path_buf(),

File diff suppressed because it is too large Load diff