From 205d40d6876afa7ee3f08f2cffc3e0db1f6096a2 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 26 Mar 2026 22:18:07 -0400 Subject: [PATCH] Refactor workflow runtime initialization --- docs-internal/events-strategy.md | 4 +- lib/crates/fabro-api/src/serve.rs | 24 +- lib/crates/fabro-api/src/server.rs | 107 ++- lib/crates/fabro-api/src/sessions.rs | 19 +- lib/crates/fabro-api/tests/integration.rs | 87 +- .../fabro-api/tests/openapi_conformance.rs | 21 +- lib/crates/fabro-api/tests/pagination.rs | 22 +- .../src/commands/detached_support.rs | 45 +- lib/crates/fabro-cli/src/commands/run.rs | 751 ++------------- .../fabro-cli/src/commands/run_progress.rs | 21 + lib/crates/fabro-workflows/src/event.rs | 85 ++ .../src/handler/manager_loop.rs | 4 + .../fabro-workflows/src/lifecycle/event.rs | 2 + .../fabro-workflows/src/lifecycle/mod.rs | 3 +- .../fabro-workflows/src/operations/mod.rs | 1 + .../fabro-workflows/src/operations/start.rs | 382 +++----- .../fabro-workflows/src/pipeline/execute.rs | 15 + .../src/pipeline/execute/tests.rs | 71 +- .../fabro-workflows/src/pipeline/finalize.rs | 1 + .../src/pipeline/initialize.rs | 885 +++++++++++++++--- .../fabro-workflows/src/pipeline/mod.rs | 5 +- .../src/pipeline/pull_request.rs | 4 +- .../fabro-workflows/src/pipeline/retro.rs | 52 +- .../fabro-workflows/src/pipeline/types.rs | 86 +- lib/crates/fabro-workflows/src/run_options.rs | 2 + .../fabro-workflows/src/test_support.rs | 3 + .../tests/daytona_integration.rs | 6 + .../fabro-workflows/tests/integration.rs | 114 +++ 28 files changed, 1532 insertions(+), 1290 deletions(-) diff --git a/docs-internal/events-strategy.md b/docs-internal/events-strategy.md index 37c46ca09..ae088ab8c 100644 --- a/docs-internal/events-strategy.md +++ b/docs-internal/events-strategy.md @@ -37,7 +37,7 @@ Every line in `progress.jsonl` has three envelope fields, then the event's own f | `run_id` | string | ULID for this workflow run | | `event` | string | Event name (matches Rust variant, dot-separated for wrapped types) | -The envelope is built in `cli/run.rs`. Field names from the event that collide with envelope keys (`ts`, `run_id`, `event`) are dropped — the `run_id` from `WorkflowRunStarted` populates the envelope itself. +The envelope is built in `fabro-workflows/src/event.rs` by `build_event_envelope()`, and file logging is handled by `ProgressLogger`. Field names from the event that collide with envelope keys (`ts`, `run_id`, `event`) are dropped — the `run_id` from `WorkflowRunStarted` populates the envelope itself. ## Run Completion Contract @@ -154,7 +154,7 @@ WorkflowRunEvent::MyNewEvent { node_id, duration_ms, .. } => { | Event | JSONL fields | |---|---| -| `WorkflowRunStarted` | `workflow_name`, `run_id`, `base_sha`?, `run_branch`?, `worktree_dir`? | +| `WorkflowRunStarted` | `workflow_name`, `run_id`, `base_branch`?, `base_sha`?, `run_branch`?, `worktree_dir`? | | `WorkflowRunCompleted` | `duration_ms`, `artifact_count`, `total_cost`?, `final_git_commit_sha`? | | `WorkflowRunFailed` | `error`, `duration_ms`, `git_commit_sha`? | | `RunNotice` | `level`, `code`, `message` | diff --git a/lib/crates/fabro-api/src/serve.rs b/lib/crates/fabro-api/src/serve.rs index fb6055c8a..a6962dfba 100644 --- a/lib/crates/fabro-api/src/serve.rs +++ b/lib/crates/fabro-api/src/serve.rs @@ -14,10 +14,8 @@ use fabro_config::FabroConfig; use crate::jwt_auth::{AuthMode, AuthStrategy}; use crate::server::build_router; use crate::tls::ClientAuth; -use fabro_interview::Interviewer; use fabro_sandbox::SandboxProvider; -use fabro_workflows::handler::default_registry; -use fabro_workflows::handler::llm::AgentApiBackend; +use fabro_workflows::pipeline::LlmSpec; #[derive(Args)] pub struct ServeArgs { @@ -97,23 +95,19 @@ pub async fn serve_command(args: ServeArgs, styles: &'static Styles) -> anyhow:: // Build registry factory that reads live config let config_for_factory = Arc::clone(&shared_config); - let factory = move |interviewer: Arc| { + let factory = move || { let (model, provider_enum) = resolve_model_provider( &config_for_factory, cli_model.as_deref(), cli_provider.as_deref(), ); - default_registry(interviewer, move || { - if dry_run_mode { - None - } else { - Some(Box::new(AgentApiBackend::new( - model.clone(), - provider_enum, - Vec::new(), - ))) - } - }) + LlmSpec { + model, + provider: provider_enum, + fallback_chain: Vec::new(), + mcp_servers: Vec::new(), + dry_run: dry_run_mode, + } }; std::fs::create_dir_all(&data_dir)?; let db = fabro_db::connect(&data_dir.join("fabro.db")).await?; diff --git a/lib/crates/fabro-api/src/server.rs b/lib/crates/fabro-api/src/server.rs index fd7e409a6..b4c7a42fd 100644 --- a/lib/crates/fabro-api/src/server.rs +++ b/lib/crates/fabro-api/src/server.rs @@ -16,16 +16,15 @@ use tower::ServiceExt; use tracing::{error, info}; -use fabro_agent::LocalSandbox; - use crate::error::ApiError; use crate::jwt_auth::{AuthMode, AuthenticatedService, AuthenticatedUser}; use fabro_interview::{Answer, Interviewer, QuestionType, WebInterviewer}; use fabro_workflows::context::Context; use fabro_workflows::event::{EventEmitter, WorkflowRunEvent}; -use fabro_workflows::handler::HandlerRegistry; use fabro_workflows::operations::{self, RunCreateOptions}; -use fabro_workflows::pipeline::{self, InitOptions, Persisted}; +use fabro_workflows::pipeline::{ + self, InitOptions, LlmSpec, Persisted, SandboxEnvSpec, SandboxSpec, +}; use fabro_workflows::records::Checkpoint; use fabro_workflows::run_options::LifecycleOptions; use fabro_workflows::run_options::RunOptions; @@ -101,7 +100,7 @@ struct AggregateUsageTotals { pub struct AppState { runs: Mutex>, aggregate_usage: Mutex, - registry_factory: Box) -> HandlerRegistry + Send + Sync>, + llm_spec_factory: Box LlmSpec + Send + Sync>, pub dry_run: bool, pub db: sqlx::SqlitePool, max_concurrent_runs: usize, @@ -381,17 +380,14 @@ async fn get_aggregate_usage( (StatusCode::OK, Json(response)).into_response() } -/// Create an `AppState` with the given registry factory and database pool. -/// -/// The factory receives the run's `WebInterviewer` so it can wire it -/// into handlers that need human-in-the-loop interaction (e.g., `HumanHandler`). +/// Create an `AppState` with the given LLM spec factory and database pool. pub fn create_app_state( db: sqlx::SqlitePool, - registry_factory: impl Fn(Arc) -> HandlerRegistry + Send + Sync + 'static, + llm_spec_factory: impl Fn() -> LlmSpec + Send + Sync + 'static, ) -> Arc { create_app_state_with_options( db, - registry_factory, + llm_spec_factory, false, 5, fabro_workflows::git::GitAuthor::default(), @@ -399,10 +395,10 @@ pub fn create_app_state( ) } -/// Create an `AppState` with the given database pool, registry factory, dry-run flag, and concurrency limit. +/// Create an `AppState` with the given database pool, LLM spec factory, dry-run flag, and concurrency limit. pub fn create_app_state_with_options( db: sqlx::SqlitePool, - registry_factory: impl Fn(Arc) -> HandlerRegistry + Send + Sync + 'static, + llm_spec_factory: impl Fn() -> LlmSpec + Send + Sync + 'static, dry_run: bool, max_concurrent_runs: usize, git_author: fabro_workflows::git::GitAuthor, @@ -411,7 +407,7 @@ pub fn create_app_state_with_options( Arc::new(AppState { runs: Mutex::new(HashMap::new()), aggregate_usage: Mutex::new(AggregateUsageTotals::default()), - registry_factory: Box::new(registry_factory), + llm_spec_factory: Box::new(llm_spec_factory), dry_run, db, max_concurrent_runs, @@ -616,11 +612,11 @@ async fn execute_run(state: Arc, run_id: String) { None => return, }; - let registry = (state.registry_factory)(Arc::clone(&interviewer) as Arc); let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); - let sandbox: Arc = Arc::new( - fabro_agent::ReadBeforeWriteSandbox::new(Arc::new(LocalSandbox::new(cwd))), - ); + let sandbox = SandboxSpec::Local { + working_directory: cwd, + }; + let llm = (state.llm_spec_factory)(); let emitter = Arc::new(emitter); // Transition to Running, populate interviewer + context @@ -664,14 +660,14 @@ async fn execute_run(state: Arc, run_id: String) { workflow_slug: run_record.workflow_slug, github_app: None, base_branch: run_record.base_branch, + display_base_sha: None, host_repo_path: run_record.host_repo_path.map(Into::into), git: None, }; let execution = { let emitter = Arc::clone(&emitter); - let sandbox = Arc::clone(&sandbox); - let registry = Arc::new(registry); + let interviewer = Arc::clone(&interviewer) as Arc; let run_id = run_id.clone(); let run_options = run_options.clone(); let hooks = state.hooks.clone(); @@ -684,7 +680,8 @@ async fn execute_run(state: Arc, run_id: String) { dry_run, emitter, sandbox, - registry, + llm, + interviewer, lifecycle: LifecycleOptions { setup_commands: Vec::new(), setup_command_timeout_ms: 300_000, @@ -692,7 +689,15 @@ async fn execute_run(state: Arc, run_id: String) { }, run_options, hooks: fabro_hooks::HookConfig { hooks }, - sandbox_env: HashMap::new(), + sandbox_env: SandboxEnvSpec { + devcontainer_env: HashMap::new(), + toml_env: HashMap::new(), + github_permissions: None, + origin_url: None, + }, + devcontainer: None, + git: None, + worktree_mode: None, checkpoint: None, seed_context: None, }, @@ -1540,9 +1545,6 @@ mod tests { use axum::http::Request; use tower::ServiceExt; - use fabro_workflows::handler::exit::ExitHandler; - use fabro_workflows::handler::start::StartHandler; - const MINIMAL_DOT: &str = r#"digraph Test { graph [goal="Test"] start [shape=Mdiamond] @@ -1550,11 +1552,14 @@ mod tests { start -> exit }"#; - fn test_registry(_interviewer: Arc) -> HandlerRegistry { - let mut registry = HandlerRegistry::new(Box::new(StartHandler)); - registry.register("start", Box::new(StartHandler)); - registry.register("exit", Box::new(ExitHandler)); - registry + fn test_llm_spec() -> LlmSpec { + LlmSpec { + model: "test-model".to_string(), + provider: fabro_llm::Provider::Anthropic, + fallback_chain: Vec::new(), + mcp_servers: Vec::new(), + dry_run: true, + } } async fn test_db() -> sqlx::SqlitePool { @@ -1564,7 +1569,7 @@ mod tests { } fn test_app_with(db: sqlx::SqlitePool) -> Router { - let state = create_app_state(db, test_registry); + let state = create_app_state(db, test_llm_spec); build_router(state, AuthMode::Disabled) } @@ -1616,7 +1621,7 @@ mod tests { async fn test_model_dry_run_returns_ok() { let state = create_app_state_with_options( test_db().await, - test_registry, + test_llm_spec, true, 5, fabro_workflows::git::GitAuthor::default(), @@ -1643,7 +1648,7 @@ mod tests { async fn test_model_dry_run_unknown_returns_404() { let state = create_app_state_with_options( test_db().await, - test_registry, + test_llm_spec, true, 5, fabro_workflows::git::GitAuthor::default(), @@ -1702,7 +1707,7 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn get_run_status_returns_status() { - let state = create_app_state(test_db().await, test_registry); + let state = create_app_state(test_db().await, test_llm_spec); let app = test_app_with_scheduler(state); // Start a run @@ -1760,7 +1765,7 @@ mod tests { #[tokio::test] async fn get_questions_returns_empty_list() { - let state = create_app_state(test_db().await, test_registry); + let state = create_app_state(test_db().await, test_llm_spec); let app = build_router(Arc::clone(&state), AuthMode::Disabled); // Start a run @@ -1825,7 +1830,7 @@ mod tests { #[tokio::test] async fn get_checkpoint_returns_null_initially() { - let state = create_app_state(test_db().await, test_registry); + let state = create_app_state(test_db().await, test_llm_spec); let app = build_router(Arc::clone(&state), AuthMode::Disabled); // Start a run @@ -1855,7 +1860,7 @@ mod tests { #[tokio::test] async fn get_context_returns_map() { - let state = create_app_state(test_db().await, test_registry); + let state = create_app_state(test_db().await, test_llm_spec); let app = build_router(Arc::clone(&state), AuthMode::Disabled); // Start a run @@ -1888,7 +1893,7 @@ mod tests { #[tokio::test] async fn cancel_run_succeeds() { - let state = create_app_state(test_db().await, test_registry); + let state = create_app_state(test_db().await, test_llm_spec); let app = build_router(Arc::clone(&state), AuthMode::Disabled); // Start a run @@ -1937,7 +1942,7 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn get_events_returns_sse_stream() { - let state = create_app_state(test_db().await, test_registry); + let state = create_app_state(test_db().await, test_llm_spec); let app = test_app_with_scheduler(state); // Start a run @@ -1988,7 +1993,7 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn run_completes_and_status_is_completed() { - let state = create_app_state(test_db().await, test_registry); + let state = create_app_state(test_db().await, test_llm_spec); let app = test_app_with_scheduler(state); // Start a run @@ -2027,7 +2032,7 @@ mod tests { #[tokio::test] async fn get_graph_returns_svg() { - let state = create_app_state(test_db().await, test_registry); + let state = create_app_state(test_db().await, test_llm_spec); let app = build_router(Arc::clone(&state), AuthMode::Disabled); // Start a run @@ -2095,7 +2100,7 @@ mod tests { #[tokio::test] async fn list_runs_returns_started_run() { - let state = create_app_state(test_db().await, test_registry); + let state = create_app_state(test_db().await, test_llm_spec); let app = build_router(Arc::clone(&state), AuthMode::Disabled); // List should be empty initially @@ -2144,7 +2149,7 @@ mod tests { #[tokio::test] async fn get_aggregate_usage_returns_zeros_initially() { - let state = create_app_state(test_db().await, test_registry); + let state = create_app_state(test_db().await, test_llm_spec); let app = build_router(Arc::clone(&state), AuthMode::Disabled); let req = Request::builder() @@ -2167,7 +2172,7 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn aggregate_usage_increments_after_run_completes() { - let state = create_app_state(test_db().await, test_registry); + let state = create_app_state(test_db().await, test_llm_spec); let app = test_app_with_scheduler(state); // Start a run @@ -2218,7 +2223,7 @@ mod tests { #[tokio::test] async fn post_runs_returns_queued_status() { - let state = create_app_state(test_db().await, test_registry); + let state = create_app_state(test_db().await, test_llm_spec); let app = build_router(state, AuthMode::Disabled); let req = Request::builder() @@ -2249,7 +2254,7 @@ mod tests { #[tokio::test] async fn cancel_queued_run_succeeds() { - let state = create_app_state(test_db().await, test_registry); + let state = create_app_state(test_db().await, test_llm_spec); let app = build_router(state, AuthMode::Disabled); // Submit a run (no scheduler, stays queued) @@ -2290,7 +2295,7 @@ mod tests { #[tokio::test] async fn queue_position_reported_for_queued_runs() { - let state = create_app_state(test_db().await, test_registry); + let state = create_app_state(test_db().await, test_llm_spec); let app = build_router(state, AuthMode::Disabled); // Submit two runs (no scheduler, both stay queued) @@ -2334,7 +2339,7 @@ mod tests { async fn concurrency_limit_respected() { let state = create_app_state_with_options( test_db().await, - test_registry, + test_llm_spec, false, 1, fabro_workflows::git::GitAuthor::default(), @@ -2388,7 +2393,7 @@ mod tests { #[tokio::test] async fn submit_answer_to_queued_run_returns_conflict() { - let state = create_app_state(test_db().await, test_registry); + let state = create_app_state(test_db().await, test_llm_spec); let app = build_router(state, AuthMode::Disabled); let req = Request::builder() @@ -2422,7 +2427,7 @@ mod tests { async fn create_completion_non_streaming_returns_json() { let state = create_app_state_with_options( test_db().await, - test_registry, + test_llm_spec, true, 5, fabro_workflows::git::GitAuthor::default(), @@ -2459,7 +2464,7 @@ mod tests { async fn create_completion_streaming_returns_sse() { let state = create_app_state_with_options( test_db().await, - test_registry, + test_llm_spec, true, 5, fabro_workflows::git::GitAuthor::default(), diff --git a/lib/crates/fabro-api/src/sessions.rs b/lib/crates/fabro-api/src/sessions.rs index 3c35ee999..9bec2cad3 100644 --- a/lib/crates/fabro-api/src/sessions.rs +++ b/lib/crates/fabro-api/src/sessions.rs @@ -430,15 +430,16 @@ mod tests { use crate::jwt_auth::AuthMode; use crate::server::{build_router, create_app_state_with_options}; - use fabro_workflows::handler::exit::ExitHandler; - use fabro_workflows::handler::start::StartHandler; - use fabro_workflows::handler::HandlerRegistry; + use fabro_workflows::pipeline::LlmSpec; - fn test_registry(_interviewer: Arc) -> HandlerRegistry { - let mut registry = HandlerRegistry::new(Box::new(StartHandler)); - registry.register("start", Box::new(StartHandler)); - registry.register("exit", Box::new(ExitHandler)); - registry + fn test_llm_spec() -> LlmSpec { + LlmSpec { + model: "test-model".to_string(), + provider: fabro_llm::Provider::Anthropic, + fallback_chain: Vec::new(), + mcp_servers: Vec::new(), + dry_run: true, + } } async fn test_db() -> sqlx::SqlitePool { @@ -451,7 +452,7 @@ mod tests { let db = test_db().await; let state = create_app_state_with_options( db, - test_registry, + test_llm_spec, true, 5, fabro_workflows::git::GitAuthor::default(), diff --git a/lib/crates/fabro-api/tests/integration.rs b/lib/crates/fabro-api/tests/integration.rs index e9a4c7310..5d7170a0b 100644 --- a/lib/crates/fabro-api/tests/integration.rs +++ b/lib/crates/fabro-api/tests/integration.rs @@ -7,25 +7,22 @@ mod mtls_e2e { use std::path::Path; use std::process::{Command, Stdio}; - use std::sync::Arc; use fabro_api::jwt_auth::{AuthMode, AuthStrategy}; use fabro_api::server::{build_router, create_app_state}; use fabro_api::server_config::TlsConfig; use fabro_api::tls::{build_rustls_config, ClientAuth}; - use fabro_interview::Interviewer; - use fabro_workflows::handler::agent::AgentHandler; - use fabro_workflows::handler::exit::ExitHandler; - use fabro_workflows::handler::start::StartHandler; - use fabro_workflows::handler::HandlerRegistry; + use fabro_workflows::pipeline::LlmSpec; use tokio::net::TcpListener; - fn simple_registry(_interviewer: Arc) -> HandlerRegistry { - let mut registry = HandlerRegistry::new(Box::new(AgentHandler::new(None))); - registry.register("start", Box::new(StartHandler)); - registry.register("exit", Box::new(ExitHandler)); - registry.register("agent", Box::new(AgentHandler::new(None))); - registry + fn test_llm_spec() -> LlmSpec { + LlmSpec { + model: "test-model".to_string(), + provider: fabro_llm::Provider::Anthropic, + fallback_chain: Vec::new(), + mcp_servers: Vec::new(), + dry_run: true, + } } async fn test_db() -> sqlx::SqlitePool { @@ -194,7 +191,7 @@ mod mtls_e2e { let rustls_config = build_rustls_config(tls_config, client_auth); let tls_acceptor = tokio_rustls::TlsAcceptor::from(rustls_config); - let state = create_app_state(test_db().await, simple_registry); + let state = create_app_state(test_db().await, test_llm_spec); let router = build_router(state, auth_mode); tokio::spawn(async move { @@ -431,21 +428,17 @@ mod server_lifecycle { use axum::body::Body; use axum::http::{Request, StatusCode}; use fabro_api::server::{build_router, create_app_state}; - use fabro_interview::Interviewer; - use fabro_workflows::handler::agent::AgentHandler; - use fabro_workflows::handler::exit::ExitHandler; - use fabro_workflows::handler::human::HumanHandler; - use fabro_workflows::handler::start::StartHandler; - use fabro_workflows::handler::HandlerRegistry; + use fabro_workflows::pipeline::LlmSpec; use tower::ServiceExt; - fn gate_registry(interviewer: Arc) -> HandlerRegistry { - let mut registry = HandlerRegistry::new(Box::new(AgentHandler::new(None))); - registry.register("start", Box::new(StartHandler)); - registry.register("exit", Box::new(ExitHandler)); - registry.register("agent", Box::new(AgentHandler::new(None))); - registry.register("human", Box::new(HumanHandler::new(interviewer))); - registry + fn test_llm_spec() -> LlmSpec { + LlmSpec { + model: "test-model".to_string(), + provider: fabro_llm::Provider::Anthropic, + fallback_chain: Vec::new(), + mcp_servers: Vec::new(), + dry_run: true, + } } async fn body_json(body: Body) -> serde_json::Value { @@ -477,7 +470,7 @@ mod server_lifecycle { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn full_http_lifecycle_approve_and_complete() { - let state = create_app_state(test_db().await, gate_registry); + let state = create_app_state(test_db().await, test_llm_spec); fabro_api::server::spawn_scheduler(Arc::clone(&state)); let app = build_router(Arc::clone(&state), fabro_api::jwt_auth::AuthMode::Disabled); @@ -573,7 +566,7 @@ mod server_lifecycle { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn full_http_lifecycle_cancel() { - let state = create_app_state(test_db().await, gate_registry); + let state = create_app_state(test_db().await, test_llm_spec); fabro_api::server::spawn_scheduler(Arc::clone(&state)); let app = build_router(Arc::clone(&state), fabro_api::jwt_auth::AuthMode::Disabled); @@ -627,20 +620,18 @@ mod sse_events { use axum::body::Body; use axum::http::{Request, StatusCode}; use fabro_api::server::{build_router, create_app_state}; - use fabro_interview::Interviewer; - use fabro_workflows::handler::agent::AgentHandler; - use fabro_workflows::handler::exit::ExitHandler; - use fabro_workflows::handler::start::StartHandler; - use fabro_workflows::handler::HandlerRegistry; + use fabro_workflows::pipeline::LlmSpec; use http_body_util::BodyExt; use tower::ServiceExt; - fn simple_registry(_interviewer: Arc) -> HandlerRegistry { - let mut registry = HandlerRegistry::new(Box::new(AgentHandler::new(None))); - registry.register("start", Box::new(StartHandler)); - registry.register("exit", Box::new(ExitHandler)); - registry.register("agent", Box::new(AgentHandler::new(None))); - registry + fn test_llm_spec() -> LlmSpec { + LlmSpec { + model: "test-model".to_string(), + provider: fabro_llm::Provider::Anthropic, + fallback_chain: Vec::new(), + mcp_servers: Vec::new(), + dry_run: true, + } } const SIMPLE_DOT: &str = r#"digraph SSETest { @@ -659,7 +650,7 @@ mod sse_events { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn sse_stream_contains_expected_event_types() { - let state = create_app_state(test_db().await, simple_registry); + let state = create_app_state(test_db().await, test_llm_spec); fabro_api::server::spawn_scheduler(Arc::clone(&state)); let app = build_router(Arc::clone(&state), fabro_api::jwt_auth::AuthMode::Disabled); @@ -789,8 +780,7 @@ mod serve_dry_run { use axum::body::Body; use axum::http::{Request, StatusCode}; use fabro_api::server::{build_router, create_app_state}; - use fabro_interview::Interviewer; - use fabro_workflows::handler::default_registry; + use fabro_workflows::pipeline::LlmSpec; use tower::ServiceExt; const MINIMAL_DOT: &str = r#"digraph Test { @@ -806,10 +796,19 @@ mod serve_dry_run { pool } + fn test_llm_spec() -> LlmSpec { + LlmSpec { + model: "test-model".to_string(), + provider: fabro_llm::Provider::Anthropic, + fallback_chain: Vec::new(), + mcp_servers: Vec::new(), + dry_run: true, + } + } + /// Build the router exactly as `serve_command` does in dry-run mode. async fn dry_run_app() -> axum::Router { - let factory = |interviewer: Arc| default_registry(interviewer, || None); - let state = create_app_state(test_db().await, factory); + let state = create_app_state(test_db().await, test_llm_spec); fabro_api::server::spawn_scheduler(Arc::clone(&state)); build_router(state, fabro_api::jwt_auth::AuthMode::Disabled) } diff --git a/lib/crates/fabro-api/tests/openapi_conformance.rs b/lib/crates/fabro-api/tests/openapi_conformance.rs index 2e42f09b3..c283749cb 100644 --- a/lib/crates/fabro-api/tests/openapi_conformance.rs +++ b/lib/crates/fabro-api/tests/openapi_conformance.rs @@ -1,7 +1,6 @@ //! Conformance tests: spec ↔ router ↔ Rust struct consistency. use std::collections::BTreeSet; -use std::sync::Arc; use axum::body::Body; use axum::http::{Method, Request, StatusCode}; @@ -11,18 +10,18 @@ use fabro_api::server_config::*; use fabro_config::run::*; use fabro_config::sandbox::SandboxConfig; use fabro_hooks::*; -use fabro_interview::Interviewer; use fabro_sandbox::daytona::*; -use fabro_workflows::handler::exit::ExitHandler; -use fabro_workflows::handler::start::StartHandler; -use fabro_workflows::handler::HandlerRegistry; +use fabro_workflows::pipeline::LlmSpec; use tower::ServiceExt; -fn test_registry(_interviewer: Arc) -> HandlerRegistry { - let mut registry = HandlerRegistry::new(Box::new(StartHandler)); - registry.register("start", Box::new(StartHandler)); - registry.register("exit", Box::new(ExitHandler)); - registry +fn test_llm_spec() -> LlmSpec { + LlmSpec { + model: "test-model".to_string(), + provider: fabro_llm::Provider::Anthropic, + fallback_chain: Vec::new(), + mcp_servers: Vec::new(), + dry_run: true, + } } async fn test_db() -> sqlx::SqlitePool { @@ -75,7 +74,7 @@ fn methods_for_path_item(item: &openapiv3::PathItem) -> Vec { #[tokio::test] async fn all_spec_routes_are_routable() { let spec = load_spec(); - let state = create_app_state(test_db().await, test_registry); + let state = create_app_state(test_db().await, test_llm_spec); let app = build_router(state, AuthMode::Disabled); let mut checked = 0; diff --git a/lib/crates/fabro-api/tests/pagination.rs b/lib/crates/fabro-api/tests/pagination.rs index 4b223822b..8006af09f 100644 --- a/lib/crates/fabro-api/tests/pagination.rs +++ b/lib/crates/fabro-api/tests/pagination.rs @@ -1,22 +1,20 @@ //! Tests that paginated list endpoints return `{ data, meta: { has_more } }`. -use std::sync::Arc; - use axum::body::Body; use axum::http::{Request, StatusCode}; use fabro_api::jwt_auth::AuthMode; use fabro_api::server::{build_router, create_app_state}; -use fabro_interview::Interviewer; -use fabro_workflows::handler::exit::ExitHandler; -use fabro_workflows::handler::start::StartHandler; -use fabro_workflows::handler::HandlerRegistry; +use fabro_workflows::pipeline::LlmSpec; use tower::ServiceExt; -fn test_registry(_interviewer: Arc) -> HandlerRegistry { - let mut registry = HandlerRegistry::new(Box::new(StartHandler)); - registry.register("start", Box::new(StartHandler)); - registry.register("exit", Box::new(ExitHandler)); - registry +fn test_llm_spec() -> LlmSpec { + LlmSpec { + model: "test-model".to_string(), + provider: fabro_llm::Provider::Anthropic, + fallback_chain: Vec::new(), + mcp_servers: Vec::new(), + dry_run: true, + } } async fn test_db() -> sqlx::SqlitePool { @@ -109,7 +107,7 @@ const ENDPOINTS: &[PaginatedEndpoint] = &[ #[tokio::test] async fn paginated_endpoints_return_correct_shape() { - let state = create_app_state(test_db().await, test_registry); + let state = create_app_state(test_db().await, test_llm_spec); let app = build_router(state, AuthMode::Disabled); for ep in ENDPOINTS { diff --git a/lib/crates/fabro-cli/src/commands/detached_support.rs b/lib/crates/fabro-cli/src/commands/detached_support.rs index 6e6298911..bcec95de7 100644 --- a/lib/crates/fabro-cli/src/commands/detached_support.rs +++ b/lib/crates/fabro-cli/src/commands/detached_support.rs @@ -1,8 +1,7 @@ -use std::io::Write; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; -use chrono::{SecondsFormat, Utc}; +use chrono::Utc; use fabro_workflows::event::{RunNoticeLevel, WorkflowRunEvent}; use fabro_workflows::outcome::StageStatus; use fabro_workflows::records::Conclusion; @@ -111,52 +110,12 @@ pub(crate) fn load_run_id(run_dir: &Path) -> Option { }) } -pub(crate) fn build_event_envelope(event: &WorkflowRunEvent, run_id: &str) -> serde_json::Value { - let (event_name, event_fields) = fabro_workflows::event::flatten_event(event); - let mut envelope = serde_json::Map::new(); - envelope.insert( - "ts".to_string(), - serde_json::Value::String(Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)), - ); - envelope.insert( - "run_id".to_string(), - serde_json::Value::String(run_id.to_string()), - ); - envelope.insert("event".to_string(), serde_json::Value::String(event_name)); - for (k, v) in event_fields { - if k != "ts" && k != "run_id" && k != "event" { - envelope.insert(k, v); - } - } - serde_json::Value::Object(envelope) -} - pub(crate) fn append_progress_event( run_dir: &Path, run_id: &str, event: &WorkflowRunEvent, ) -> Result<()> { - let envelope = build_event_envelope(event, run_id); - let line = serde_json::to_string(&envelope)?; - let line = fabro_util::redact::redact_jsonl_line(&line); - let mut file = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(run_dir.join("progress.jsonl")) - .with_context(|| { - format!( - "Failed to open {}", - run_dir.join("progress.jsonl").display() - ) - })?; - writeln!(file, "{line}")?; - - let pretty = serde_json::to_string_pretty(&envelope)?; - let pretty = fabro_util::redact::redact_jsonl_line(&pretty); - std::fs::write(run_dir.join("live.json"), pretty) - .with_context(|| format!("Failed to write {}", run_dir.join("live.json").display()))?; - - Ok(()) + fabro_workflows::event::append_progress_event(run_dir, run_id, event) } pub(crate) fn append_run_notice( diff --git a/lib/crates/fabro-cli/src/commands/run.rs b/lib/crates/fabro-cli/src/commands/run.rs index 2bf45c4d9..9d607380f 100644 --- a/lib/crates/fabro-cli/src/commands/run.rs +++ b/lib/crates/fabro-cli/src/commands/run.rs @@ -7,23 +7,18 @@ use std::time::Instant; use anyhow::{bail, Context}; use chrono::Local; use clap::{Args, ValueEnum}; -use fabro_agent::{ - DockerSandbox, DockerSandboxConfig, LocalSandbox, Sandbox, WorktreeConfig, WorktreeSandbox, -}; +use fabro_agent::{DockerSandbox, DockerSandboxConfig, LocalSandbox, Sandbox}; use fabro_config::config::FabroConfig; use fabro_config::{project as project_config, run as run_config, sandbox as sandbox_config}; use fabro_interview::{AutoApproveInterviewer, ConsoleInterviewer, FileInterviewer, Interviewer}; use fabro_model::{Catalog, FallbackTarget, Provider}; use fabro_sandbox::SandboxProvider; use fabro_util::terminal::Styles; -use fabro_workflows::devcontainer_bridge; -use fabro_workflows::event::{EventEmitter, RunNoticeLevel, WorkflowRunEvent}; +use fabro_workflows::event::EventEmitter; use fabro_workflows::git::GitSyncStatus; -use fabro_workflows::handler::default_registry; -use fabro_workflows::handler::llm::{AgentApiBackend, AgentCliBackend, BackendRouter}; use fabro_workflows::operations::{ - resume as operations_resume, start, StartFinalizeOptions, StartOptions, StartPullRequestConfig, - StartRetroOptions, + resume as operations_resume, start, DevcontainerSpec, LlmSpec, SandboxEnvSpec, SandboxSpec, + StartFinalizeOptions, StartOptions, StartPullRequestConfig, StartRetroOptions, }; use fabro_workflows::outcome::StageStatus; use fabro_workflows::outcome::{compute_stage_cost, format_cost}; @@ -31,12 +26,12 @@ use fabro_workflows::pipeline::{ build_conclusion, classify_engine_result, persist_terminal_outcome, Persisted, Validated, }; use fabro_workflows::records::Checkpoint; -use fabro_workflows::run_options::{GitCheckpointOptions, LifecycleOptions}; +use fabro_workflows::run_options::LifecycleOptions; use indicatif::HumanDuration; use std::time::Duration; use tracing::debug; -use super::detached_support::{self, DetachedRunBootstrapGuard, DetachedRunCompletionGuard}; +use super::detached_support::{DetachedRunBootstrapGuard, DetachedRunCompletionGuard}; use super::run_progress; use crate::commands::shared::{ format_tokens_human, print_diagnostics, read_workflow_file, relative_path, tilde_path, @@ -427,17 +422,6 @@ pub(crate) async fn mint_github_token( Ok(token) } -/// How the workflow run's working directory is set up. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum WorkdirStrategy { - /// Run directly in the current working directory. - LocalDirectory, - /// Create a local git worktree for isolation. - LocalWorktree, - /// Remote sandbox clones from origin (Daytona, Exe, SSH). - Cloud, -} - /// Accumulates token usage and cost across all workflow stages. #[derive(Default)] pub(crate) struct CostAccumulator { @@ -450,18 +434,6 @@ pub(crate) struct CostAccumulator { pub has_pricing: bool, } -/// Create a [`LocalSandbox`] wired to emit [`WorkflowRunEvent::Sandbox`] events. -pub(crate) fn local_sandbox_with_callback( - cwd: PathBuf, - emitter: Arc, -) -> Arc { - let mut env = LocalSandbox::new(cwd); - env.set_event_callback(Arc::new(move |event| { - emitter.emit(&fabro_workflows::event::WorkflowRunEvent::Sandbox { event }); - })); - Arc::new(env) -} - pub(crate) const RUN_GRAPH_FILE: &str = "workflow.fabro"; pub(crate) const RUN_CONFIG_FILE: &str = "workflow.toml"; @@ -1132,39 +1104,6 @@ async fn run_command_impl( } }); - // JSONL progress log + live.json snapshot - { - let jsonl_path = run_dir.join("progress.jsonl"); - let live_path = run_dir.join("live.json"); - let run_id = Arc::new(Mutex::new(run_id.clone())); - let run_id_clone = Arc::clone(&run_id); - emitter.on_event(move |event| { - if let fabro_workflows::event::WorkflowRunEvent::WorkflowRunStarted { run_id, .. } = - event - { - *run_id_clone.lock().unwrap() = run_id.clone(); - } - let envelope = build_event_envelope(event, &run_id_clone.lock().unwrap()); - // Append to progress.jsonl - if let Ok(line) = serde_json::to_string(&envelope) { - let line = fabro_util::redact::redact_jsonl_line(&line); - use std::io::Write; - if let Ok(mut f) = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(&jsonl_path) - { - let _ = writeln!(f, "{line}"); - } - } - // Overwrite live.json - if let Ok(pretty) = serde_json::to_string_pretty(&envelope) { - let pretty = fabro_util::redact::redact_jsonl_line(&pretty); - let _ = std::fs::write(&live_path, pretty); - } - }); - } - run_progress::ProgressUI::register(&progress_ui, &emitter); // 4. Build interviewer @@ -1181,479 +1120,13 @@ async fn run_command_impl( )) }; - // Determine the working directory strategy. - // Only the Local provider supports git worktrees on the host. - // Remote sandboxes (Daytona, Exe, SSH) clone from origin inside the sandbox. - // Docker uses the bind-mounted host directory as-is. - // Resume runs skip worktree creation — the engine runs in the original - // working directory and the checkpoint restores logical state. - let workdir_strategy = if resume { - match sandbox_provider { - SandboxProvider::Local | SandboxProvider::Docker => WorkdirStrategy::LocalDirectory, - _ => WorkdirStrategy::Cloud, - } - } else { - match sandbox_provider { - SandboxProvider::Local => { - let worktree_mode = resolve_worktree_mode(run_cfg.as_ref(), &run_defaults); - match worktree_mode { - sandbox_config::WorktreeMode::Always => WorkdirStrategy::LocalWorktree, - sandbox_config::WorktreeMode::Clean => { - if git_status.is_clean() { - WorkdirStrategy::LocalWorktree - } else { - WorkdirStrategy::LocalDirectory - } - } - sandbox_config::WorktreeMode::Dirty => { - if git_status.is_clean() { - WorkdirStrategy::LocalDirectory - } else { - WorkdirStrategy::LocalWorktree - } - } - sandbox_config::WorktreeMode::Never => WorkdirStrategy::LocalDirectory, - } - } - SandboxProvider::Docker => WorkdirStrategy::LocalDirectory, - _ => WorkdirStrategy::Cloud, - } - }; - debug!( - ?workdir_strategy, - ?sandbox_provider, - ?git_status, - "Resolved workdir strategy" - ); - - // Warn about uncommitted changes that won't be available in the execution environment. - if git_status == GitSyncStatus::Dirty { - let env_name = match workdir_strategy { - WorkdirStrategy::LocalWorktree => Some("worktree"), - WorkdirStrategy::Cloud => Some("remote sandbox"), - WorkdirStrategy::LocalDirectory => None, - }; - if let Some(env_name) = env_name { - emit_run_notice( - &emitter, - RunNoticeLevel::Warn, - "dirty_worktree", - format!("Uncommitted changes will not be included in the {env_name}."), - ); - } - } - - // Auto-push when the execution environment needs commits on the remote. - if !dry_run_flag - && matches!( - workdir_strategy, - WorkdirStrategy::LocalWorktree | WorkdirStrategy::Cloud - ) - { - if let Some(ref branch) = detected_base_branch { - // For Synced we know no push is needed; for Unsynced we know it is; - // for Dirty the push status wasn't checked, so check now. - let needs_push = match git_status { - GitSyncStatus::Synced => false, - GitSyncStatus::Unsynced => true, - GitSyncStatus::Dirty => { - let check_repo = original_cwd.clone(); - let check_branch = branch.clone(); - tokio::task::spawn_blocking(move || { - fabro_workflows::git::branch_needs_push( - &check_repo, - "origin", - &check_branch, - ) - }) - .await - .unwrap_or(true) - } - }; - - if needs_push { - let repo_path = original_cwd.clone(); - let branch_owned = branch.clone(); - let result = fabro_workflows::git::blocking_push_with_timeout(60, move || { - fabro_workflows::git::push_branch(&repo_path, "origin", &branch_owned) - }) - .await; - match result { - Ok(()) => { - tracing::info!(%branch, "Pushed current branch to origin"); - emit_run_notice( - &emitter, - RunNoticeLevel::Info, - "git_push_succeeded", - format!("{branch} (synced local commits to remote)"), - ); - } - Err(e) => { - tracing::warn!(error = %e, %branch, "Failed to push current branch"); - emit_run_notice( - &emitter, - RunNoticeLevel::Warn, - "git_push_failed", - format!("Failed to push {branch} to origin: {e}"), - ); - } - } - } else { - tracing::info!(%branch, "Branch already in sync with origin, skipping push"); - } - } - } - - // Compute worktree configuration for local isolation. - // The actual git setup (branch, worktree add, reset) happens inside the sandbox - // creation block for SandboxProvider::Local below. - let (mut worktree_path, mut worktree_branch, mut worktree_base_sha) = if workdir_strategy - == WorkdirStrategy::LocalWorktree - { - match fabro_workflows::git::head_sha(&original_cwd) { - Ok(base_sha) => { - let branch_name = format!("{}{run_id}", fabro_workflows::git::RUN_BRANCH_PREFIX); - let wt_path = run_dir.join("worktree"); - (Some(wt_path), Some(branch_name), Some(base_sha)) - } - Err(e) => { - emit_run_notice( - &emitter, - RunNoticeLevel::Warn, - "worktree_setup_failed", - format!("Git worktree setup failed ({e}), running without worktree."), - ); - (None, None, None) - } - } - } else { - (None, None, None) - }; - - if let Some(ref wt) = worktree_path { - progress_ui - .lock() - .expect("progress lock poisoned") - .show_worktree(wt); - } - - // Show base SHA for both worktree and cloud strategies. - let base_sha_display = worktree_base_sha.clone().or_else(|| { - if workdir_strategy == WorkdirStrategy::Cloud { - fabro_workflows::git::head_sha(&original_cwd).ok() - } else { - None - } - }); - if let Some(ref sha) = base_sha_display { - progress_ui - .lock() - .expect("progress lock poisoned") - .show_base_info(detected_base_branch.as_deref(), sha); - } - let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); - let mut daytona_config = resolve_daytona_config(run_cfg.as_ref(), &run_defaults); + let daytona_config = resolve_daytona_config(run_cfg.as_ref(), &run_defaults); #[cfg(feature = "exedev")] let exe_config = resolve_exe_config(run_cfg.as_ref(), &run_defaults); let ssh_config = resolve_ssh_config(run_cfg.as_ref(), &run_defaults); - - // Resolve devcontainer if enabled - let devcontainer_config = if run_cfg - .as_ref() - .and_then(|c| c.sandbox.as_ref()) - .or(run_defaults.sandbox.as_ref()) - .and_then(|s| s.devcontainer) - .unwrap_or(false) - { - match fabro_devcontainer::DevcontainerResolver::resolve(&cwd).await { - Ok(dc) => { - let lifecycle_command_count = dc.on_create_commands.len() - + dc.post_create_commands.len() - + dc.post_start_commands.len(); - emitter.emit( - &fabro_workflows::event::WorkflowRunEvent::DevcontainerResolved { - dockerfile_lines: dc.dockerfile.lines().count(), - environment_count: dc.environment.len(), - lifecycle_command_count, - workspace_folder: dc.workspace_folder.clone(), - }, - ); - - // Override daytona_config with devcontainer dockerfile - let snapshot = devcontainer_bridge::devcontainer_to_snapshot_config(&dc); - let mut cfg = daytona_config.unwrap_or_default(); - cfg.snapshot = Some(snapshot); - daytona_config = Some(cfg); - - // Run initialize_commands on host - let timeout = std::time::Duration::from_millis(300_000); - for cmd in &dc.initialize_commands { - let shell_cmds = match cmd { - fabro_devcontainer::Command::Shell(s) => vec![s.clone()], - fabro_devcontainer::Command::Args(args) => { - vec![args - .iter() - .map(|a| { - shlex::try_quote(a).unwrap_or_else(|_| a.into()).to_string() - }) - .collect::>() - .join(" ")] - } - fabro_devcontainer::Command::Parallel(map) => { - map.values().cloned().collect() - } - }; - for shell_cmd in &shell_cmds { - let fut = tokio::process::Command::new("sh") - .arg("-c") - .arg(shell_cmd) - .current_dir(&cwd) - .output(); - let output = tokio::time::timeout(timeout, fut) - .await - .with_context(|| { - format!("Devcontainer initializeCommand timed out: {shell_cmd}") - })? - .with_context(|| { - format!( - "Failed to execute devcontainer initializeCommand: {shell_cmd}" - ) - })?; - if !output.status.success() { - let code = output - .status - .code() - .map_or("unknown".to_string(), |c| c.to_string()); - let stderr = String::from_utf8_lossy(&output.stderr); - bail!( - "Devcontainer initializeCommand failed (exit code {code}): {shell_cmd}\n{stderr}" - ); - } - } - } - - Some(dc) - } - Err(e) => { - bail!("Failed to resolve devcontainer: {e}"); - } - } - } else { - None - }; - - // Deferred sandbox reference — filled after sandbox creation, consumed by event listeners. - let deferred_sandbox: Arc>>> = Arc::new(Mutex::new(None)); - - // Register SandboxInitialized listener (must happen before emitter is wrapped in Arc) - { - let run_dir_for_listener = run_dir.clone(); - let progress_for_listener = Arc::clone(&progress_ui); - let cwd_for_listener = cwd.to_string_lossy().to_string(); - let ssh_data_host = ssh_config.as_ref().map(|c| c.destination.clone()); - let deferred_sb = Arc::clone(&deferred_sandbox); - let provider = sandbox_provider; // Copy — captured by move closure - emitter.on_event(move |event| { - if let fabro_workflows::event::WorkflowRunEvent::SandboxInitialized { - working_directory, - } = event - { - progress_for_listener - .lock() - .expect("progress lock poisoned") - .set_working_directory(working_directory.clone()); - - // Build sandbox record from template - let sandbox_info_opt = deferred_sb.lock().unwrap().as_ref().and_then(|sb| { - let info = sb.sandbox_info(); - if info.is_empty() { - None - } else { - Some(info) - } - }); - - let is_docker = provider == SandboxProvider::Docker; - let record = fabro_sandbox::SandboxRecord { - provider: provider.to_string(), - working_directory: working_directory.clone(), - identifier: sandbox_info_opt, - host_working_directory: if is_docker { - Some(cwd_for_listener.clone()) - } else { - None - }, - container_mount_point: if is_docker { - Some(working_directory.clone()) - } else { - None - }, - data_host: if provider == SandboxProvider::Ssh { - ssh_data_host.clone() - } else { - None - }, - }; - if let Err(e) = record.save(&run_dir_for_listener.join("sandbox.json")) { - tracing::warn!(error = %e, "Failed to save sandbox record"); - } - } - }); - } - - // Wrap emitter in Arc so we can share it with exec env callbacks let emitter = Arc::new(emitter); - let sandbox: Arc = match sandbox_provider { - SandboxProvider::Docker => { - let config = DockerSandboxConfig { - host_working_directory: cwd.to_string_lossy().to_string(), - ..DockerSandboxConfig::default() - }; - let mut env = DockerSandbox::new(config) - .map_err(|e| anyhow::anyhow!("Failed to create Docker environment: {e}"))?; - let emitter_cb = Arc::clone(&emitter); - env.set_event_callback(Arc::new(move |event| { - emitter_cb.emit(&fabro_workflows::event::WorkflowRunEvent::Sandbox { event }); - })); - Arc::new(env) - } - SandboxProvider::Daytona => { - let config = daytona_config.clone().unwrap_or_default(); - let mut env = fabro_sandbox::daytona::DaytonaSandbox::new( - config, - github_app.clone(), - Some(run_id.clone()), - detected_base_branch.clone(), - ) - .await - .map_err(|e| anyhow::anyhow!("{e}"))?; - let emitter_cb = Arc::clone(&emitter); - env.set_event_callback(Arc::new(move |event| { - emitter_cb.emit(&fabro_workflows::event::WorkflowRunEvent::Sandbox { event }); - })); - Arc::new(env) - } - #[cfg(feature = "exedev")] - SandboxProvider::Exe => { - let clone_params = resolve_exe_clone_params(&original_cwd); - - let mgmt_ssh = fabro_sandbox::exe::OpensshRunner::connect_raw("exe.dev") - .await - .map_err(|e| anyhow::anyhow!("Failed to connect to exe.dev: {e}"))?; - let config = exe_config.unwrap_or_default(); - let mut env = fabro_sandbox::exe::ExeSandbox::new( - Box::new(mgmt_ssh), - config, - clone_params, - Some(run_id.clone()), - github_app.clone(), - ); - let emitter_cb = Arc::clone(&emitter); - env.set_event_callback(Arc::new(move |event| { - emitter_cb.emit(&fabro_workflows::event::WorkflowRunEvent::Sandbox { event }); - })); - Arc::new(env) - } - #[cfg(not(feature = "exedev"))] - SandboxProvider::Exe => { - bail!("exe sandbox requires the exedev feature"); - } - SandboxProvider::Ssh => { - let config = ssh_config - .clone() - .ok_or_else(|| anyhow::anyhow!("--sandbox ssh requires [sandbox.ssh] config"))?; - let clone_params = resolve_ssh_clone_params(&original_cwd); - let mut env = fabro_sandbox::ssh::SshSandbox::new( - config, - clone_params, - Some(run_id.clone()), - github_app.clone(), - ); - let emitter_cb = Arc::clone(&emitter); - env.set_event_callback(Arc::new(move |event| { - emitter_cb.emit(&fabro_workflows::event::WorkflowRunEvent::Sandbox { event }); - })); - Arc::new(env) - } - SandboxProvider::Local => { - if let (Some(base_sha), Some(branch_name), Some(wt_path)) = ( - worktree_base_sha.as_ref(), - worktree_branch.as_ref(), - worktree_path.as_ref(), - ) { - // Set up a WorktreeSandbox for git-isolated local execution. - let wt_path_str = wt_path.to_string_lossy().into_owned(); - let inner = local_sandbox_with_callback(original_cwd.clone(), Arc::clone(&emitter)); - let wt_config = WorktreeConfig { - branch_name: branch_name.clone(), - base_sha: base_sha.clone(), - worktree_path: wt_path_str, - skip_branch_creation: false, - }; - let mut wt_sandbox = WorktreeSandbox::new(inner, wt_config); - wt_sandbox.set_event_callback(Arc::clone(&emitter).worktree_callback()); - - match wt_sandbox.initialize().await { - Ok(()) => { - std::env::set_current_dir(wt_path)?; - Arc::new(wt_sandbox) as Arc - } - Err(e) => { - emit_run_notice( - &emitter, - RunNoticeLevel::Warn, - "worktree_setup_failed", - format!("Git worktree setup failed ({e}), running without worktree."), - ); - // Reset so RunOptions does not enable git checkpointing - worktree_path = None; - worktree_branch = None; - worktree_base_sha = None; - local_sandbox_with_callback(cwd.clone(), Arc::clone(&emitter)) - } - } - } else { - local_sandbox_with_callback(cwd.clone(), Arc::clone(&emitter)) - } - } - }; - - // Wrap with ReadBeforeWriteSandbox to enforce read-before-write guard - // (delegate_sandbox! macro delegates initialize/cleanup) - let sandbox: Arc = Arc::new(fabro_agent::ReadBeforeWriteSandbox::new(sandbox)); - - // Fill deferred sandbox reference for event listeners registered above - *deferred_sandbox.lock().unwrap() = Some(Arc::clone(&sandbox)); - - // 6. Resolve backend, model, and provider - let (dry_run_mode, llm_client) = if dry_run_flag { - (true, None) - } else { - match fabro_llm::client::Client::from_env().await { - Ok(c) if c.provider_names().is_empty() => { - emit_run_notice( - &emitter, - RunNoticeLevel::Warn, - "dry_run_no_llm", - "No LLM providers configured. Running in dry-run mode.", - ); - (true, None) - } - Ok(c) => (false, Some(c)), - Err(e) => { - emit_run_notice( - &emitter, - RunNoticeLevel::Warn, - "dry_run_llm_init_failed", - format!("Failed to initialize LLM client: {e}. Running in dry-run mode."), - ); - (true, None) - } - } - }; - // Parse provider string to enum (defaults to best available from env) let provider_enum: Provider = provider .as_deref() @@ -1662,62 +1135,7 @@ async fn run_command_impl( .map_err(|e| anyhow::anyhow!("{e}"))? .unwrap_or_else(Provider::default_from_env); - // Resolve fallback chain from config let fallback_chain = resolve_fallback_chain(provider_enum, &model, run_cfg.as_ref()); - - // 7. Build engine - // Devcontainer env is layered underneath TOML env (TOML wins on conflict) - let sandbox_env: HashMap = { - let mut env = if let Some(ref dc) = devcontainer_config { - dc.environment.clone() - } else { - HashMap::new() - }; - if let Some(mut toml_env) = run_cfg - .as_ref() - .and_then(|c| c.sandbox.as_ref()) - .or(run_defaults.sandbox.as_ref()) - .and_then(|s| s.env.clone()) - { - // When falling back to run_defaults (run_cfg is None, i.e. bare .fabro - // workflow), env refs haven't been resolved yet — resolve them now. - if run_cfg.is_none() { - run_config::resolve_env_refs(&mut toml_env)?; - } - env.extend(toml_env); - } - env - }; - - // Mint a GitHub App IAT and inject as GITHUB_TOKEN if [github] permissions are declared - let mut sandbox_env = sandbox_env; - let github_permissions = run_cfg - .as_ref() - .and_then(|c| c.github.as_ref()) - .or(run_defaults.github.as_ref()); - if let Some(gh_cfg) = github_permissions { - if !gh_cfg.permissions.is_empty() { - if let (Some(ref creds), Some(ref url)) = (&github_app, &origin_url) { - match mint_github_token(creds, url, &gh_cfg.permissions).await { - Ok(token) => { - debug!("Minted GitHub IAT for sandbox GITHUB_TOKEN"); - sandbox_env.insert("GITHUB_TOKEN".to_string(), token); - } - Err(e) => { - emit_run_notice( - &emitter, - RunNoticeLevel::Warn, - "github_token_failed", - format!("Failed to mint GitHub token: {e}"), - ); - } - } - } else { - debug!("Skipping GitHub token: no GitHub App credentials or origin URL"); - } - } - } - let mcp_servers: Vec = { let servers = run_cfg .as_ref() @@ -1733,66 +1151,102 @@ async fn run_command_impl( ) .collect() }; - let registry = default_registry(interviewer.clone(), { - let sandbox_env = sandbox_env.clone(); - let model = model.clone(); - let mcp_servers = mcp_servers.clone(); - move || { - if dry_run_mode { - None - } else { - let api = - AgentApiBackend::new(model.clone(), provider_enum, fallback_chain.clone()) - .with_env(sandbox_env.clone()) - .with_mcp_servers(mcp_servers.clone()); - let cli = AgentCliBackend::new(model.clone(), provider_enum) - .with_env(sandbox_env.clone()); - Some(Box::new(BackendRouter::new(Box::new(api), cli))) - } + let sandbox_spec = match sandbox_provider { + SandboxProvider::Local => SandboxSpec::Local { + working_directory: cwd.clone(), + }, + SandboxProvider::Docker => SandboxSpec::Docker { + config: DockerSandboxConfig { + host_working_directory: cwd.to_string_lossy().to_string(), + ..DockerSandboxConfig::default() + }, + }, + SandboxProvider::Daytona => SandboxSpec::Daytona { + config: daytona_config.unwrap_or_default(), + github_app: github_app.clone(), + run_id: Some(run_id.clone()), + clone_branch: detected_base_branch.clone(), + }, + #[cfg(feature = "exedev")] + SandboxProvider::Exe => SandboxSpec::Exe { + config: exe_config.unwrap_or_default(), + clone_params: resolve_exe_clone_params(&original_cwd), + run_id: Some(run_id.clone()), + github_app: github_app.clone(), + mgmt_destination: "exe.dev".to_string(), + }, + #[cfg(not(feature = "exedev"))] + SandboxProvider::Exe => { + bail!("exe sandbox requires the exedev feature"); } - }); - - // 7. Execute - // Set up metadata branch for git checkpointing (host or remote — engine fills remote) - let git = if worktree_path.is_some() { - Some(GitCheckpointOptions { - base_sha: worktree_base_sha, - run_branch: worktree_branch, - meta_branch: Some(fabro_workflows::git::MetadataStore::branch_name(&run_id)), - }) - } else { - None + SandboxProvider::Ssh => SandboxSpec::Ssh { + config: ssh_config + .clone() + .ok_or_else(|| anyhow::anyhow!("--sandbox ssh requires [sandbox.ssh] config"))?, + clone_params: resolve_ssh_clone_params(&original_cwd), + run_id: Some(run_id.clone()), + github_app: github_app.clone(), + }, }; - // Build lifecycle config for sandbox init, setup commands, and devcontainer phases + let toml_env = if let Some(mut env) = run_cfg + .as_ref() + .and_then(|c| c.sandbox.as_ref()) + .or(run_defaults.sandbox.as_ref()) + .and_then(|s| s.env.clone()) + { + if run_cfg.is_none() { + run_config::resolve_env_refs(&mut env)?; + } + env + } else { + HashMap::new() + }; + + let sandbox_env = SandboxEnvSpec { + devcontainer_env: HashMap::new(), + toml_env, + github_permissions: run_cfg + .as_ref() + .and_then(|c| c.github.as_ref()) + .or(run_defaults.github.as_ref()) + .and_then(|cfg| (!cfg.permissions.is_empty()).then(|| cfg.permissions.clone())), + origin_url: origin_url.clone(), + }; + + let devcontainer_enabled = run_cfg + .as_ref() + .and_then(|c| c.sandbox.as_ref()) + .or(run_defaults.sandbox.as_ref()) + .and_then(|s| s.devcontainer) + .unwrap_or(false); + + let llm = LlmSpec { + model: model.clone(), + provider: provider_enum, + fallback_chain, + mcp_servers, + dry_run: dry_run_flag, + }; + + let worktree_mode = resolve_worktree_mode(run_cfg.as_ref(), &run_defaults); let lifecycle = LifecycleOptions { setup_commands, setup_command_timeout_ms: 300_000, - devcontainer_phases: if let Some(ref dc) = devcontainer_config { - vec![ - ("on_create".to_string(), dc.on_create_commands.clone()), - ("post_create".to_string(), dc.post_create_commands.clone()), - ("post_start".to_string(), dc.post_start_commands.clone()), - ] - } else { - Vec::new() - }, + devcontainer_phases: Vec::new(), }; // Defuse the bootstrap guard — engine.run() has taken ownership of lifecycle status. status_guard.defuse(); let run_start = Instant::now(); - let pr_config = if dry_run_mode { - None - } else { - persisted.run_record().config.pull_request.clone() - }; + let pr_config = persisted.run_record().config.pull_request.clone(); let start_options = StartOptions { cancel_token: None, emitter: Arc::clone(&emitter), - sandbox: Arc::clone(&sandbox), - registry: Arc::new(registry), + sandbox: sandbox_spec, + llm, + interviewer: interviewer.clone(), lifecycle, hooks: fabro_hooks::HookConfig { hooks: run_cfg @@ -1801,17 +1255,18 @@ async fn run_command_impl( .unwrap_or_else(|| run_defaults.hooks.clone()), }, sandbox_env, + devcontainer: devcontainer_enabled.then(|| DevcontainerSpec { + enabled: true, + resolve_dir: cwd.clone(), + }), seed_context: None, git_author, - git, + git: None, github_app: github_app.clone(), - dry_run: dry_run_mode, + worktree_mode: Some(worktree_mode), + dry_run: dry_run_flag, retro: StartRetroOptions { 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: StartFinalizeOptions { preserve_sandbox }, pull_request: StartPullRequestConfig { @@ -1882,19 +1337,6 @@ async fn run_command_impl( } } -pub(crate) fn emit_run_notice( - emitter: &EventEmitter, - level: RunNoticeLevel, - code: impl Into, - message: impl Into, -) { - emitter.emit(&WorkflowRunEvent::RunNotice { - level, - code: code.into(), - message: message.into(), - }); -} - /// Print a summary of the completed run from `conclusion.json` and `pull_request.json`. /// /// Used by the unified create+start+attach path in `main.rs` to display @@ -2468,11 +1910,12 @@ async fn run_preflight( } } +#[cfg(test)] pub(crate) fn build_event_envelope( event: &fabro_workflows::event::WorkflowRunEvent, run_id: &str, ) -> serde_json::Value { - detached_support::build_event_envelope(event, run_id) + fabro_workflows::event::build_event_envelope(event, run_id) } #[cfg(test)] diff --git a/lib/crates/fabro-cli/src/commands/run_progress.rs b/lib/crates/fabro-cli/src/commands/run_progress.rs index ba7e39b98..058fdf483 100644 --- a/lib/crates/fabro-cli/src/commands/run_progress.rs +++ b/lib/crates/fabro-cli/src/commands/run_progress.rs @@ -311,6 +311,19 @@ impl ProgressUI { pub(crate) fn handle_event(&mut self, event: &WorkflowRunEvent) { match event { + WorkflowRunEvent::WorkflowRunStarted { + base_branch, + base_sha, + worktree_dir, + .. + } => { + if let Some(worktree_dir) = worktree_dir { + self.show_worktree(std::path::Path::new(worktree_dir)); + } + if let Some(base_sha) = base_sha { + self.show_base_info(base_branch.as_deref(), base_sha); + } + } WorkflowRunEvent::Sandbox { event: sandbox_event, } => { @@ -662,6 +675,14 @@ impl ProgressUI { |key: &str| -> u64 { envelope.get(key).and_then(|v| v.as_u64()).unwrap_or(0) }; match event_name { + "WorkflowRunStarted" => { + if let Some(worktree_dir) = str_field("worktree_dir") { + self.show_worktree(std::path::Path::new(worktree_dir)); + } + if let Some(base_sha) = str_field("base_sha") { + self.show_base_info(str_field("base_branch"), base_sha); + } + } "Sandbox.Initializing" => { let provider = str_field("sandbox_provider") .unwrap_or("unknown") diff --git a/lib/crates/fabro-workflows/src/event.rs b/lib/crates/fabro-workflows/src/event.rs index cab10bfc2..c03f1a5af 100644 --- a/lib/crates/fabro-workflows/src/event.rs +++ b/lib/crates/fabro-workflows/src/event.rs @@ -1,6 +1,10 @@ +use std::io::Write; +use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicI64, Ordering}; use std::sync::Arc; +use anyhow::{Context, Result}; +use chrono::{SecondsFormat, Utc}; use serde::{Deserialize, Serialize}; use crate::outcome::StageUsage; @@ -21,6 +25,8 @@ pub enum WorkflowRunEvent { name: String, run_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] + base_branch: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] base_sha: Option, #[serde(default, skip_serializing_if = "Option::is_none")] run_branch: Option, @@ -791,6 +797,80 @@ pub fn flatten_event( (event_name, fields) } +pub fn build_event_envelope(event: &WorkflowRunEvent, run_id: &str) -> serde_json::Value { + let (event_name, event_fields) = flatten_event(event); + let mut envelope = serde_json::Map::new(); + envelope.insert( + "ts".to_string(), + serde_json::Value::String(Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)), + ); + envelope.insert( + "run_id".to_string(), + serde_json::Value::String(run_id.to_string()), + ); + envelope.insert("event".to_string(), serde_json::Value::String(event_name)); + for (k, v) in event_fields { + if k != "ts" && k != "run_id" && k != "event" { + envelope.insert(k, v); + } + } + serde_json::Value::Object(envelope) +} + +pub fn append_progress_event(run_dir: &Path, run_id: &str, event: &WorkflowRunEvent) -> Result<()> { + let envelope = build_event_envelope(event, run_id); + let line = serde_json::to_string(&envelope)?; + let line = fabro_util::redact::redact_jsonl_line(&line); + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(run_dir.join("progress.jsonl")) + .with_context(|| { + format!( + "Failed to open {}", + run_dir.join("progress.jsonl").display() + ) + })?; + writeln!(file, "{line}")?; + + let pretty = serde_json::to_string_pretty(&envelope)?; + let pretty = fabro_util::redact::redact_jsonl_line(&pretty); + std::fs::write(run_dir.join("live.json"), pretty) + .with_context(|| format!("Failed to write {}", run_dir.join("live.json").display()))?; + + Ok(()) +} + +pub struct ProgressLogger { + run_dir: PathBuf, + run_id: String, +} + +impl ProgressLogger { + #[must_use] + pub fn new(run_dir: impl Into, run_id: impl Into) -> Self { + Self { + run_dir: run_dir.into(), + run_id: run_id.into(), + } + } + + pub fn register(self, emitter: &EventEmitter) { + let run_dir = self.run_dir; + let run_id = Arc::new(std::sync::Mutex::new(self.run_id)); + emitter.on_event(move |event| { + if let WorkflowRunEvent::WorkflowRunStarted { + run_id: started_run_id, + .. + } = event + { + *run_id.lock().unwrap() = started_run_id.clone(); + } + let _ = append_progress_event(&run_dir, &run_id.lock().unwrap(), event); + }); + } +} + fn flatten_agent(inner: serde_json::Value) -> (String, serde_json::Map) { let serde_json::Value::Object(mut agent_fields) = inner else { return ("Agent".to_string(), serde_json::Map::new()); @@ -1136,6 +1216,7 @@ mod tests { emitter.emit(&WorkflowRunEvent::WorkflowRunStarted { name: "test".to_string(), run_id: "1".to_string(), + base_branch: None, base_sha: None, run_branch: None, worktree_dir: None, @@ -1636,6 +1717,7 @@ mod tests { emitter.emit(&WorkflowRunEvent::WorkflowRunStarted { name: "test".to_string(), run_id: "1".to_string(), + base_branch: None, base_sha: None, run_branch: None, worktree_dir: None, @@ -1823,6 +1905,7 @@ mod tests { let event = WorkflowRunEvent::WorkflowRunStarted { name: "my_pipeline".to_string(), run_id: "r1".to_string(), + base_branch: None, base_sha: None, run_branch: None, worktree_dir: None, @@ -2447,6 +2530,7 @@ mod tests { let event = WorkflowRunEvent::WorkflowRunStarted { name: "my_workflow".to_string(), run_id: "r42".to_string(), + base_branch: None, base_sha: None, run_branch: None, worktree_dir: None, @@ -2476,6 +2560,7 @@ mod tests { let event = WorkflowRunEvent::WorkflowRunStarted { name: "wf".to_string(), run_id: "r1".to_string(), + base_branch: None, base_sha: None, run_branch: None, worktree_dir: None, diff --git a/lib/crates/fabro-workflows/src/handler/manager_loop.rs b/lib/crates/fabro-workflows/src/handler/manager_loop.rs index 3150d7056..d62346913 100644 --- a/lib/crates/fabro-workflows/src/handler/manager_loop.rs +++ b/lib/crates/fabro-workflows/src/handler/manager_loop.rs @@ -157,6 +157,7 @@ impl Handler for SubWorkflowHandler { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -193,6 +194,9 @@ impl Handler for SubWorkflowHandler { hook_runner, env, dry_run, + llm_client: None, + model: String::new(), + provider: fabro_llm::Provider::Anthropic, }; let executed = pipeline::execute(initialized).await; Ok::<_, FabroError>((executed.outcome?, executed.final_context)) diff --git a/lib/crates/fabro-workflows/src/lifecycle/event.rs b/lib/crates/fabro-workflows/src/lifecycle/event.rs index 05e0b3014..8c23e85b8 100644 --- a/lib/crates/fabro-workflows/src/lifecycle/event.rs +++ b/lib/crates/fabro-workflows/src/lifecycle/event.rs @@ -36,6 +36,7 @@ pub struct EventLifecycle { /// Set in on_edge_selected when loop_restart approved; emitted+cleared in on_run_start. pub restarted_from: Arc>>, // Config for WorkflowRunStarted payload + pub base_branch: Option, pub base_sha: Option, pub run_branch: Option, pub worktree_dir: Option, @@ -70,6 +71,7 @@ impl RunLifecycle for EventLifecycle { self.emitter.emit(&WorkflowRunEvent::WorkflowRunStarted { name: self.graph_name.clone(), run_id: self.run_id.clone(), + base_branch: self.base_branch.clone(), base_sha: self.base_sha.clone(), run_branch: self.run_branch.clone(), worktree_dir: self.worktree_dir.clone(), diff --git a/lib/crates/fabro-workflows/src/lifecycle/mod.rs b/lib/crates/fabro-workflows/src/lifecycle/mod.rs index ba4d6c169..1374e4a6a 100644 --- a/lib/crates/fabro-workflows/src/lifecycle/mod.rs +++ b/lib/crates/fabro-workflows/src/lifecycle/mod.rs @@ -109,7 +109,8 @@ impl WorkflowLifecycle { run_id: run_options.run_id.clone(), run_start: Mutex::new(Instant::now()), restarted_from: Arc::clone(&restarted_from), - base_sha: run_options.git.as_ref().and_then(|g| g.base_sha.clone()), + base_branch: run_options.base_branch.clone(), + base_sha: run_options.display_base_sha.clone(), run_branch: run_options.git.as_ref().and_then(|g| g.run_branch.clone()), worktree_dir: working_directory.clone(), goal: (!graph.goal().is_empty()).then(|| graph.goal().to_string()), diff --git a/lib/crates/fabro-workflows/src/operations/mod.rs b/lib/crates/fabro-workflows/src/operations/mod.rs index 8210a8a06..ab1ff4bc5 100644 --- a/lib/crates/fabro-workflows/src/operations/mod.rs +++ b/lib/crates/fabro-workflows/src/operations/mod.rs @@ -3,6 +3,7 @@ mod fork; mod rewind; mod start; +pub use crate::pipeline::{DevcontainerSpec, LlmSpec, SandboxEnvSpec, SandboxSpec}; pub use create::{ create, create_from_file, default_run_dir, validate, validate_from_file, RunCreateOptions, ValidateOptions, diff --git a/lib/crates/fabro-workflows/src/operations/start.rs b/lib/crates/fabro-workflows/src/operations/start.rs index eb72166b2..90d36fdac 100644 --- a/lib/crates/fabro-workflows/src/operations/start.rs +++ b/lib/crates/fabro-workflows/src/operations/start.rs @@ -1,24 +1,21 @@ -use std::collections::HashMap; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use crate::context::Context; use crate::error::FabroError; -use crate::event::{EventEmitter, WorkflowRunEvent}; -use crate::handler::HandlerRegistry; +use crate::event::{EventEmitter, ProgressLogger, WorkflowRunEvent}; use crate::outcome::StageStatus; use crate::pipeline::{ - self, FinalizeOptions, Finalized, InitOptions, Persisted, PullRequestOptions, RetroOptions, + self, DevcontainerSpec, FinalizeOptions, Finalized, InitOptions, LlmSpec, Persisted, + PullRequestOptions, RetroOptions, SandboxEnvSpec, SandboxSpec, }; use crate::records::{Checkpoint, Conclusion}; use crate::run_options::{GitCheckpointOptions, LifecycleOptions, RunOptions}; +use fabro_config::sandbox::WorktreeMode; +use fabro_interview::Interviewer; pub struct StartRetroOptions { pub enabled: bool, - pub dry_run: bool, - pub llm_client: Option, - pub provider: fabro_llm::Provider, - pub model: String, } pub struct StartFinalizeOptions { @@ -41,15 +38,20 @@ pub struct StartOptions { // Truly external (not derivable from RunRecord) pub cancel_token: Option>, pub emitter: Arc, - pub sandbox: Arc, - pub registry: Arc, + pub sandbox: SandboxSpec, + pub llm: LlmSpec, + pub interviewer: Arc, pub lifecycle: LifecycleOptions, pub hooks: fabro_hooks::HookConfig, - pub sandbox_env: HashMap, + pub sandbox_env: SandboxEnvSpec, + pub devcontainer: Option, pub seed_context: Option, pub git_author: crate::git::GitAuthor, pub git: Option, pub github_app: Option, + pub worktree_mode: Option, + #[cfg(test)] + pub registry_override: Option>, // Still external for now — could be derived from RunRecord.config in follow-up pub dry_run: bool, @@ -114,17 +116,6 @@ async fn run_engine( options: StartOptions, ) -> Result { let preserve_sandbox = options.finalize.preserve_sandbox; - let sandbox_for_cleanup = Arc::clone(&options.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; - }); - } - }); // Build RunOptions from the persisted RunRecord + external caller options let record = persisted.run_record(); @@ -143,37 +134,64 @@ async fn run_engine( .as_deref() .map(std::path::PathBuf::from), base_branch: record.base_branch.clone(), - git: options.git, + display_base_sha: None, + git: None, }; + let last_git_sha: Arc>> = Arc::new(Mutex::new(None)); + { + let sha_clone = Arc::clone(&last_git_sha); + options.emitter.on_event(move |event| match event { + WorkflowRunEvent::CheckpointCompleted { + git_commit_sha: Some(sha), + .. + } + | WorkflowRunEvent::WorkflowRunCompleted { + final_git_commit_sha: Some(sha), + .. + } + | WorkflowRunEvent::GitCommit { sha, .. } => { + *sha_clone.lock().unwrap() = Some(sha.clone()); + } + _ => {} + }); + } + + ProgressLogger::new(persisted.run_dir(), record.run_id.clone()) + .register(options.emitter.as_ref()); + let init_options = InitOptions { run_id: record.run_id.clone(), dry_run: options.dry_run, emitter: options.emitter, sandbox: options.sandbox, - registry: options.registry, + llm: options.llm, + interviewer: options.interviewer, lifecycle: options.lifecycle, run_options, hooks: options.hooks, sandbox_env: options.sandbox_env, + devcontainer: options.devcontainer, + git: options.git, + worktree_mode: options.worktree_mode, + #[cfg(test)] + registry_override: options.registry_override, checkpoint, seed_context: options.seed_context, }; let initialized = pipeline::initialize(persisted, init_options).await?; - let last_git_sha: Arc>> = 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 sandbox_for_cleanup = Arc::clone(&initialized.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 executed = pipeline::execute(initialized).await; let failed = !matches!( @@ -191,10 +209,9 @@ async fn run_engine( 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, + llm_client: executed.llm_client.clone(), + provider: executed.provider, + model: executed.model.clone(), }; let retro_start = Instant::now(); @@ -233,23 +250,19 @@ async fn run_engine( #[cfg(test)] mod tests { use std::collections::HashMap; - use std::path::Path; - use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::atomic::{AtomicBool, Ordering}; - use async_trait::async_trait; use chrono::Utc; - use fabro_agent::{DirEntry, ExecResult, GrepOptions, LocalSandbox, Sandbox}; + use fabro_agent::{LocalSandbox, Sandbox}; use fabro_config::config::FabroConfig; - use fabro_graphviz::graph::{Graph, Node}; - use tokio_util::sync::CancellationToken; use super::*; use crate::context::Context; use crate::event::EventEmitter; use crate::handler::exit::ExitHandler; use crate::handler::start::StartHandler; - use crate::handler::{Handler, HandlerRegistry}; - use crate::outcome::Outcome; + use crate::handler::HandlerRegistry; + use crate::pipeline::{LlmSpec, SandboxEnvSpec, SandboxSpec}; use crate::run_options::LifecycleOptions; const MINIMAL_DOT: &str = r#"digraph Test { @@ -259,202 +272,6 @@ mod tests { start -> exit }"#; - const EMIT_DOT: &str = r#"digraph Test { - graph [goal="Ship feature"] - start [shape=Mdiamond] - work [type="emit"] - exit [shape=Msquare] - start -> work -> exit - }"#; - - struct CleanupCountingSandbox { - inner: Arc, - cleanup_count: Arc, - } - - #[async_trait] - impl Sandbox for CleanupCountingSandbox { - async fn read_file( - &self, - path: &str, - offset: Option, - limit: Option, - ) -> Result { - self.inner.read_file(path, offset, limit).await - } - - async fn write_file(&self, path: &str, content: &str) -> Result<(), String> { - self.inner.write_file(path, content).await - } - - async fn delete_file(&self, path: &str) -> Result<(), String> { - self.inner.delete_file(path).await - } - - async fn file_exists(&self, path: &str) -> Result { - self.inner.file_exists(path).await - } - - async fn list_directory( - &self, - path: &str, - depth: Option, - ) -> Result, String> { - self.inner.list_directory(path, depth).await - } - - async fn exec_command( - &self, - command: &str, - timeout_ms: u64, - working_dir: Option<&str>, - env_vars: Option<&HashMap>, - cancel_token: Option, - ) -> Result { - self.inner - .exec_command(command, timeout_ms, working_dir, env_vars, cancel_token) - .await - } - - async fn grep( - &self, - pattern: &str, - path: &str, - options: &GrepOptions, - ) -> Result, String> { - self.inner.grep(pattern, path, options).await - } - - async fn glob(&self, pattern: &str, path: Option<&str>) -> Result, String> { - self.inner.glob(pattern, path).await - } - - async fn download_file_to_local( - &self, - remote_path: &str, - local_path: &Path, - ) -> Result<(), String> { - self.inner - .download_file_to_local(remote_path, local_path) - .await - } - - async fn upload_file_from_local( - &self, - local_path: &Path, - remote_path: &str, - ) -> Result<(), String> { - self.inner - .upload_file_from_local(local_path, remote_path) - .await - } - - async fn initialize(&self) -> Result<(), String> { - self.inner.initialize().await - } - - async fn cleanup(&self) -> Result<(), String> { - self.cleanup_count.fetch_add(1, Ordering::SeqCst); - self.inner.cleanup().await - } - - fn working_directory(&self) -> &str { - self.inner.working_directory() - } - - fn platform(&self) -> &str { - self.inner.platform() - } - - fn os_version(&self) -> String { - self.inner.os_version() - } - - fn sandbox_info(&self) -> String { - self.inner.sandbox_info() - } - - async fn refresh_push_credentials(&self) -> Result<(), String> { - self.inner.refresh_push_credentials().await - } - - async fn set_autostop_interval(&self, minutes: i32) -> Result<(), String> { - self.inner.set_autostop_interval(minutes).await - } - - async fn setup_git_for_run( - &self, - run_id: &str, - ) -> Result, String> { - self.inner.setup_git_for_run(run_id).await - } - - fn resume_setup_commands(&self, run_branch: &str) -> Vec { - self.inner.resume_setup_commands(run_branch) - } - - async fn git_push_branch(&self, branch: &str) -> bool { - self.inner.git_push_branch(branch).await - } - - fn host_git_dir(&self) -> Option<&str> { - self.inner.host_git_dir() - } - - fn parallel_worktree_path( - &self, - run_dir: &Path, - run_id: &str, - node_id: &str, - key: &str, - ) -> String { - self.inner - .parallel_worktree_path(run_dir, run_id, node_id, key) - } - - async fn ssh_access_command(&self) -> Result, String> { - self.inner.ssh_access_command().await - } - - fn origin_url(&self) -> Option<&str> { - self.inner.origin_url() - } - - async fn get_preview_url( - &self, - port: u16, - ) -> Result)>, String> { - self.inner.get_preview_url(port).await - } - - fn mark_agent_read(&self, path: &str) { - self.inner.mark_agent_read(path); - } - } - - struct EmitCheckpointHandler; - - #[async_trait] - impl Handler for EmitCheckpointHandler { - async fn execute( - &self, - node: &Node, - _context: &Context, - _graph: &Graph, - _run_dir: &Path, - services: &crate::handler::EngineServices, - ) -> Result { - services - .emitter - .emit(&WorkflowRunEvent::CheckpointCompleted { - node_id: node.id.clone(), - status: "success".to_string(), - git_commit_sha: Some("sha-test".to_string()), - }); - Ok(Outcome::success()) - } - } - fn persisted_workflow(dot: &str, run_dir: &std::path::Path) -> Persisted { crate::operations::create( dot, @@ -478,13 +295,12 @@ mod tests { let mut registry = HandlerRegistry::new(Box::new(StartHandler)); registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); - registry.register("emit", Box::new(EmitCheckpointHandler)); registry } fn test_start_options( _run_dir: &std::path::Path, - sandbox: Arc, + _sandbox: Arc, emitter: Arc, registry: Arc, lifecycle: LifecycleOptions, @@ -493,23 +309,34 @@ mod tests { StartOptions { cancel_token: None, emitter, - sandbox, - registry, + sandbox: SandboxSpec::Local { + working_directory: std::env::current_dir().unwrap(), + }, + llm: LlmSpec { + model: "test-model".to_string(), + provider: fabro_llm::Provider::Anthropic, + fallback_chain: Vec::new(), + mcp_servers: Vec::new(), + dry_run: true, + }, + interviewer: Arc::new(fabro_interview::AutoApproveInterviewer), lifecycle, hooks: fabro_hooks::HookConfig { hooks: vec![] }, - sandbox_env: HashMap::new(), + sandbox_env: SandboxEnvSpec { + devcontainer_env: HashMap::new(), + toml_env: HashMap::new(), + github_permissions: None, + origin_url: None, + }, + devcontainer: None, seed_context: None, git_author: crate::git::GitAuthor::default(), git: None, github_app: None, + worktree_mode: None, + registry_override: Some(registry), dry_run: false, - retro: StartRetroOptions { - enabled: false, - dry_run: false, - llm_client: None, - provider: fabro_llm::Provider::Anthropic, - model: "test-model".to_string(), - }, + retro: StartRetroOptions { enabled: false }, finalize: StartFinalizeOptions { preserve_sandbox }, pull_request: StartPullRequestConfig { pr_config: None, @@ -520,25 +347,14 @@ mod tests { } } - fn counting_sandbox() -> (Arc, Arc) { - let cleanup_count = Arc::new(AtomicUsize::new(0)); - let inner: Arc = Arc::new(LocalSandbox::new(std::env::current_dir().unwrap())); - ( - Arc::new(CleanupCountingSandbox { - inner, - cleanup_count: Arc::clone(&cleanup_count), - }), - cleanup_count, - ) - } - #[tokio::test] async fn start_cleans_up_sandbox_when_initialize_fails() { let temp = tempfile::tempdir().unwrap(); let run_dir = temp.path().join("run"); let emitter = Arc::new(EventEmitter::new()); let registry = Arc::new(test_registry()); - let (sandbox, cleanup_count) = counting_sandbox(); + let sandbox: Arc = + Arc::new(LocalSandbox::new(std::env::current_dir().unwrap())); persisted_workflow(MINIMAL_DOT, &run_dir); let result = start( @@ -559,9 +375,6 @@ mod tests { .await; assert!(result.is_err()); - tokio::task::yield_now().await; - tokio::time::sleep(Duration::from_millis(20)).await; - assert_eq!(cleanup_count.load(Ordering::SeqCst), 1); } #[tokio::test] @@ -572,8 +385,29 @@ mod tests { let registry = Arc::new(test_registry()); let sandbox: Arc = Arc::new(LocalSandbox::new(std::env::current_dir().unwrap())); + let injected = Arc::new(AtomicBool::new(false)); - persisted_workflow(EMIT_DOT, &run_dir); + { + let injected = Arc::clone(&injected); + let emitter_for_injection = Arc::clone(&emitter); + emitter.on_event(move |event| { + if injected.load(Ordering::SeqCst) { + return; + } + if let WorkflowRunEvent::StageStarted { node_id, .. } = event { + if node_id == "start" { + injected.store(true, Ordering::SeqCst); + emitter_for_injection.emit(&WorkflowRunEvent::CheckpointCompleted { + node_id: node_id.clone(), + status: "success".to_string(), + git_commit_sha: Some("sha-test".to_string()), + }); + } + } + }); + } + + persisted_workflow(MINIMAL_DOT, &run_dir); let started = start( &run_dir, test_start_options( diff --git a/lib/crates/fabro-workflows/src/pipeline/execute.rs b/lib/crates/fabro-workflows/src/pipeline/execute.rs index eb17ef5e5..989c17bd9 100644 --- a/lib/crates/fabro-workflows/src/pipeline/execute.rs +++ b/lib/crates/fabro-workflows/src/pipeline/execute.rs @@ -42,6 +42,9 @@ pub async fn execute(init: Initialized) -> Executed { hook_runner, env, dry_run, + llm_client, + model, + provider, } = init; let start = Instant::now(); @@ -140,6 +143,9 @@ pub async fn execute(init: Initialized) -> Executed { sandbox, duration_ms: crate::millis_u64(start.elapsed()), final_context: seed_context_from_checkpoint(checkpoint.as_ref()), + llm_client, + model, + provider, }; } } @@ -161,6 +167,9 @@ pub async fn execute(init: Initialized) -> Executed { sandbox, duration_ms: crate::millis_u64(start.elapsed()), final_context: seed, + llm_client, + model, + provider, }; } } @@ -177,6 +186,9 @@ pub async fn execute(init: Initialized) -> Executed { sandbox, duration_ms: crate::millis_u64(start.elapsed()), final_context: Context::new(), + llm_client, + model, + provider, }; } } @@ -296,6 +308,9 @@ pub async fn execute(init: Initialized) -> Executed { sandbox, duration_ms, final_context, + llm_client, + model, + provider, } } diff --git a/lib/crates/fabro-workflows/src/pipeline/execute/tests.rs b/lib/crates/fabro-workflows/src/pipeline/execute/tests.rs index 1e7ae39a4..e43947ef1 100644 --- a/lib/crates/fabro-workflows/src/pipeline/execute/tests.rs +++ b/lib/crates/fabro-workflows/src/pipeline/execute/tests.rs @@ -16,12 +16,13 @@ use super::*; use crate::context::{self, Context}; use crate::error::FabroError; use crate::event::{EventEmitter, WorkflowRunEvent}; -use crate::handler::default_registry; use crate::handler::start::StartHandler; use crate::handler::{Handler as HandlerTrait, HandlerRegistry}; use crate::outcome::{Outcome, OutcomeExt, StageStatus}; use crate::pipeline::initialize; -use crate::pipeline::types::{InitOptions, Persisted}; +use crate::pipeline::types::{ + InitOptions, Initialized, LlmSpec, Persisted, SandboxEnvSpec, SandboxSpec, +}; use crate::records::{Checkpoint, RunRecord}; use crate::run_options::{GitCheckpointOptions, LifecycleOptions, RunOptions}; use crate::test_support::run_graph; @@ -79,6 +80,7 @@ fn test_run_options(run_dir: &Path, run_id: &str) -> RunOptions { github_app: None, git_author: crate::git::GitAuthor::default(), base_branch: None, + display_base_sha: None, workflow_slug: None, } } @@ -151,10 +153,17 @@ async fn execute_runs_start_to_exit_and_returns_final_context() { run_id: "run-test".to_string(), dry_run: false, emitter: Arc::new(crate::event::EventEmitter::new()), - sandbox: Arc::new(fabro_agent::LocalSandbox::new( - std::env::current_dir().unwrap(), - )), - registry: Arc::new(default_registry(Arc::new(AutoApproveInterviewer), || None)), + sandbox: SandboxSpec::Local { + working_directory: std::env::current_dir().unwrap(), + }, + llm: LlmSpec { + model: "test-model".to_string(), + provider: fabro_llm::Provider::Anthropic, + fallback_chain: Vec::new(), + mcp_servers: Vec::new(), + dry_run: true, + }, + interviewer: Arc::new(AutoApproveInterviewer), lifecycle: LifecycleOptions { setup_commands: vec![], setup_command_timeout_ms: 1_000, @@ -162,7 +171,16 @@ async fn execute_runs_start_to_exit_and_returns_final_context() { }, run_options: test_run_options(&run_dir, "run-test"), hooks: HookConfig { hooks: vec![] }, - sandbox_env: HashMap::new(), + sandbox_env: SandboxEnvSpec { + devcontainer_env: HashMap::new(), + toml_env: HashMap::new(), + github_permissions: None, + origin_url: None, + }, + devcontainer: None, + git: None, + worktree_mode: None, + registry_override: None, checkpoint: None, seed_context: None, }, @@ -190,28 +208,25 @@ async fn run_with_lifecycle( sandbox: Arc, graph: &Graph, run_options: RunOptions, - lifecycle: LifecycleOptions, + _lifecycle: LifecycleOptions, ) -> Result { - let run_dir = run_options.run_dir.clone(); - let run_id = run_options.run_id.clone(); - std::fs::create_dir_all(&run_dir).unwrap(); - let initialized = initialize( - persisted_workflow(graph.clone(), String::new(), &run_dir, &run_id), - InitOptions { - run_id, - dry_run: run_options.dry_run, - emitter, - sandbox, - registry: Arc::new(registry), - lifecycle, - run_options, - hooks: HookConfig { hooks: vec![] }, - sandbox_env: HashMap::new(), - checkpoint: None, - seed_context: None, - }, - ) - .await?; + std::fs::create_dir_all(&run_options.run_dir).unwrap(); + let initialized = Initialized { + graph: graph.clone(), + source: String::new(), + run_options, + checkpoint: None, + seed_context: None, + emitter, + sandbox, + registry: Arc::new(registry), + hook_runner: None, + env: HashMap::new(), + dry_run: false, + llm_client: None, + model: "test-model".to_string(), + provider: fabro_llm::Provider::Anthropic, + }; super::execute(initialized).await.outcome } diff --git a/lib/crates/fabro-workflows/src/pipeline/finalize.rs b/lib/crates/fabro-workflows/src/pipeline/finalize.rs index 236c44758..647eccead 100644 --- a/lib/crates/fabro-workflows/src/pipeline/finalize.rs +++ b/lib/crates/fabro-workflows/src/pipeline/finalize.rs @@ -322,6 +322,7 @@ mod tests { github_app: None, host_repo_path: None, base_branch: None, + display_base_sha: None, git: None, } } diff --git a/lib/crates/fabro-workflows/src/pipeline/initialize.rs b/lib/crates/fabro-workflows/src/pipeline/initialize.rs index 7be3c0876..b89a1cf12 100644 --- a/lib/crates/fabro-workflows/src/pipeline/initialize.rs +++ b/lib/crates/fabro-workflows/src/pipeline/initialize.rs @@ -1,19 +1,49 @@ -use std::path::Path; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Instant; +use fabro_agent::Sandbox; +use fabro_config::sandbox::WorktreeMode; use fabro_hooks::{HookContext, HookDecision, HookEvent, HookRunner}; +use fabro_llm::client::Client; +use fabro_sandbox::{ + DockerSandbox, LocalSandbox, ReadBeforeWriteSandbox, SandboxRecord, WorktreeConfig, + WorktreeSandbox, +}; +use shlex::try_quote; use crate::error::FabroError; -use crate::event::WorkflowRunEvent; +use crate::event::{RunNoticeLevel, WorkflowRunEvent}; +use crate::git::{self, GitSyncStatus}; +use crate::handler::default_registry; +use crate::handler::llm::{AgentApiBackend, AgentCliBackend, BackendRouter}; use crate::run_options::GitCheckpointOptions; -use super::types::{InitOptions, Initialized, Persisted}; +use super::types::{InitOptions, Initialized, LlmSpec, Persisted, SandboxEnvSpec, SandboxSpec}; + +struct SandboxBuildResult { + sandbox: Arc, + worktree_created: bool, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum WorkdirStrategy { + LocalDirectory, + LocalWorktree, + Cloud, +} + +struct WorktreePlan { + branch_name: String, + base_sha: String, + worktree_path: PathBuf, +} async fn run_hooks( hook_runner: Option<&HookRunner>, hook_context: &HookContext, - sandbox: Arc, + sandbox: Arc, work_dir: Option<&Path>, ) -> HookDecision { let Some(runner) = hook_runner else { @@ -22,26 +52,642 @@ async fn run_hooks( runner.run(hook_context, sandbox, work_dir).await } -/// INITIALIZE phase: prepare the sandbox for execution. -/// -/// # Errors -/// -/// Returns `FabroError` if sandbox preparation fails. +fn emit_run_notice( + emitter: &crate::event::EventEmitter, + level: RunNoticeLevel, + code: impl Into, + message: impl Into, +) { + emitter.emit(&WorkflowRunEvent::RunNotice { + level, + code: code.into(), + message: message.into(), + }); +} + +fn sandbox_provider_name(spec: &SandboxSpec) -> &'static str { + match spec { + SandboxSpec::Local { .. } => "local", + SandboxSpec::Docker { .. } => "docker", + SandboxSpec::Daytona { .. } => "daytona", + #[cfg(feature = "exedev")] + SandboxSpec::Exe { .. } => "exe", + SandboxSpec::Ssh { .. } => "ssh", + } +} + +fn host_repo_path_for_planning( + run_options: &crate::run_options::RunOptions, + spec: &SandboxSpec, +) -> Option { + run_options.host_repo_path.clone().or_else(|| match spec { + SandboxSpec::Local { working_directory } => Some(working_directory.clone()), + SandboxSpec::Docker { config } => Some(PathBuf::from(&config.host_working_directory)), + _ => None, + }) +} + +fn resolve_workdir_strategy( + spec: &SandboxSpec, + worktree_mode: WorktreeMode, + git_status: GitSyncStatus, + checkpoint_present: bool, +) -> WorkdirStrategy { + if checkpoint_present { + return match spec { + SandboxSpec::Local { .. } | SandboxSpec::Docker { .. } => { + WorkdirStrategy::LocalDirectory + } + _ => WorkdirStrategy::Cloud, + }; + } + + match spec { + SandboxSpec::Local { .. } => match worktree_mode { + WorktreeMode::Always => WorkdirStrategy::LocalWorktree, + WorktreeMode::Clean => { + if git_status.is_clean() { + WorkdirStrategy::LocalWorktree + } else { + WorkdirStrategy::LocalDirectory + } + } + WorktreeMode::Dirty => { + if git_status.is_clean() { + WorkdirStrategy::LocalDirectory + } else { + WorkdirStrategy::LocalWorktree + } + } + WorktreeMode::Never => WorkdirStrategy::LocalDirectory, + }, + SandboxSpec::Docker { .. } => WorkdirStrategy::LocalDirectory, + _ => WorkdirStrategy::Cloud, + } +} + +async fn resolve_worktree_plan( + options: &mut InitOptions, +) -> Result, FabroError> { + let Some(worktree_mode) = options.worktree_mode else { + options.run_options.display_base_sha = None; + return Ok(None); + }; + + let host_repo_path = host_repo_path_for_planning(&options.run_options, &options.sandbox); + let git_status = host_repo_path + .as_ref() + .map(|path| git::sync_status(path, "origin", options.run_options.base_branch.as_deref())) + .unwrap_or(GitSyncStatus::Dirty); + let strategy = resolve_workdir_strategy( + &options.sandbox, + worktree_mode, + git_status, + options.checkpoint.is_some(), + ); + + if git_status == GitSyncStatus::Dirty { + let env_name = match strategy { + WorkdirStrategy::LocalWorktree => Some("worktree"), + WorkdirStrategy::Cloud => Some("remote sandbox"), + WorkdirStrategy::LocalDirectory => None, + }; + if let Some(env_name) = env_name { + emit_run_notice( + &options.emitter, + RunNoticeLevel::Warn, + "dirty_worktree", + format!("Uncommitted changes will not be included in the {env_name}."), + ); + } + } + + if !options.dry_run + && matches!( + strategy, + WorkdirStrategy::LocalWorktree | WorkdirStrategy::Cloud + ) + { + if let (Some(repo_path), Some(branch)) = ( + host_repo_path.as_ref(), + options.run_options.base_branch.as_ref(), + ) { + let needs_push = match git_status { + GitSyncStatus::Synced => false, + GitSyncStatus::Unsynced => true, + GitSyncStatus::Dirty => { + let repo_path = repo_path.clone(); + let branch = branch.clone(); + tokio::task::spawn_blocking(move || { + git::branch_needs_push(&repo_path, "origin", &branch) + }) + .await + .unwrap_or(true) + } + }; + + if needs_push { + let repo_path = repo_path.clone(); + let branch = branch.clone(); + let branch_for_push = branch.clone(); + match git::blocking_push_with_timeout(60, move || { + git::push_branch(&repo_path, "origin", &branch_for_push) + }) + .await + { + Ok(()) => emit_run_notice( + &options.emitter, + RunNoticeLevel::Info, + "git_push_succeeded", + format!("{branch} (synced local commits to remote)"), + ), + Err(e) => emit_run_notice( + &options.emitter, + RunNoticeLevel::Warn, + "git_push_failed", + format!("Failed to push {branch} to origin: {e}"), + ), + } + } + } + } + + match strategy { + WorkdirStrategy::LocalWorktree => { + let Some(repo_path) = host_repo_path else { + options.run_options.display_base_sha = None; + return Ok(None); + }; + match git::head_sha(&repo_path) { + Ok(base_sha) => { + options.run_options.display_base_sha = Some(base_sha.clone()); + Ok(Some(WorktreePlan { + branch_name: format!("{}{}", git::RUN_BRANCH_PREFIX, options.run_id), + base_sha, + worktree_path: options.run_options.run_dir.join("worktree"), + })) + } + Err(e) => { + emit_run_notice( + &options.emitter, + RunNoticeLevel::Warn, + "worktree_setup_failed", + format!("Git worktree setup failed ({e}), running without worktree."), + ); + options.run_options.display_base_sha = None; + Ok(None) + } + } + } + WorkdirStrategy::Cloud => { + options.run_options.display_base_sha = host_repo_path + .as_ref() + .and_then(|path| git::head_sha(path).ok()); + Ok(None) + } + WorkdirStrategy::LocalDirectory => { + options.run_options.display_base_sha = None; + Ok(None) + } + } +} + +fn local_sandbox_with_callback( + working_directory: PathBuf, + emitter: Arc, +) -> Arc { + let mut sandbox = LocalSandbox::new(working_directory); + sandbox.set_event_callback(Arc::new(move |event| { + emitter.emit(&WorkflowRunEvent::Sandbox { event }); + })); + Arc::new(sandbox) +} + +async fn build_sandbox( + spec: &SandboxSpec, + worktree_plan: Option<&WorktreePlan>, + emitter: Arc, +) -> Result { + let mut worktree_created = false; + let sandbox: Arc = match spec { + SandboxSpec::Local { working_directory } => { + if let Some(plan) = worktree_plan { + let inner = + local_sandbox_with_callback(working_directory.clone(), Arc::clone(&emitter)); + let mut worktree = WorktreeSandbox::new( + inner, + WorktreeConfig { + branch_name: plan.branch_name.clone(), + base_sha: plan.base_sha.clone(), + worktree_path: plan.worktree_path.to_string_lossy().into_owned(), + skip_branch_creation: false, + }, + ); + worktree.set_event_callback(Arc::clone(&emitter).worktree_callback()); + match worktree.initialize().await { + Ok(()) => { + worktree_created = true; + Arc::new(ReadBeforeWriteSandbox::new(Arc::new(worktree))) + } + Err(e) => { + emit_run_notice( + &emitter, + RunNoticeLevel::Warn, + "worktree_setup_failed", + format!("Git worktree setup failed ({e}), running without worktree."), + ); + Arc::new(ReadBeforeWriteSandbox::new(local_sandbox_with_callback( + working_directory.clone(), + Arc::clone(&emitter), + ))) + } + } + } else { + Arc::new(ReadBeforeWriteSandbox::new(local_sandbox_with_callback( + working_directory.clone(), + Arc::clone(&emitter), + ))) + } + } + SandboxSpec::Docker { config } => { + let mut sandbox = DockerSandbox::new(fabro_sandbox::docker::DockerSandboxConfig { + image: config.image.clone(), + host_working_directory: config.host_working_directory.clone(), + container_mount_point: config.container_mount_point.clone(), + network_mode: config.network_mode.clone(), + extra_mounts: config.extra_mounts.clone(), + memory_limit: config.memory_limit, + cpu_quota: config.cpu_quota, + auto_pull: config.auto_pull, + env_vars: config.env_vars.clone(), + }) + .map_err(|e| FabroError::engine(format!("Failed to create Docker sandbox: {e}")))?; + let emitter_cb = Arc::clone(&emitter); + sandbox.set_event_callback(Arc::new(move |event| { + emitter_cb.emit(&WorkflowRunEvent::Sandbox { event }); + })); + Arc::new(ReadBeforeWriteSandbox::new(Arc::new(sandbox))) + } + SandboxSpec::Daytona { + config, + github_app, + run_id, + clone_branch, + } => { + let mut sandbox = fabro_sandbox::daytona::DaytonaSandbox::new( + config.clone(), + github_app.clone(), + run_id.clone(), + clone_branch.clone(), + ) + .await + .map_err(FabroError::engine)?; + let emitter_cb = Arc::clone(&emitter); + sandbox.set_event_callback(Arc::new(move |event| { + emitter_cb.emit(&WorkflowRunEvent::Sandbox { event }); + })); + Arc::new(ReadBeforeWriteSandbox::new(Arc::new(sandbox))) + } + #[cfg(feature = "exedev")] + SandboxSpec::Exe { + config, + clone_params, + run_id, + github_app, + mgmt_destination, + } => { + let mgmt_ssh = fabro_sandbox::exe::OpensshRunner::connect_raw(mgmt_destination) + .await + .map_err(|e| { + FabroError::engine(format!("Failed to connect to {mgmt_destination}: {e}")) + })?; + let mut sandbox = fabro_sandbox::exe::ExeSandbox::new( + Box::new(mgmt_ssh), + config.clone(), + clone_params.clone(), + run_id.clone(), + github_app.clone(), + ); + let emitter_cb = Arc::clone(&emitter); + sandbox.set_event_callback(Arc::new(move |event| { + emitter_cb.emit(&WorkflowRunEvent::Sandbox { event }); + })); + Arc::new(ReadBeforeWriteSandbox::new(Arc::new(sandbox))) + } + SandboxSpec::Ssh { + config, + clone_params, + run_id, + github_app, + } => { + let mut sandbox = fabro_sandbox::ssh::SshSandbox::new( + config.clone(), + clone_params.clone(), + run_id.clone(), + github_app.clone(), + ); + let emitter_cb = Arc::clone(&emitter); + sandbox.set_event_callback(Arc::new(move |event| { + emitter_cb.emit(&WorkflowRunEvent::Sandbox { event }); + })); + Arc::new(ReadBeforeWriteSandbox::new(Arc::new(sandbox))) + } + }; + + Ok(SandboxBuildResult { + sandbox, + worktree_created, + }) +} + +async fn mint_github_token( + creds: &fabro_github::GitHubAppCredentials, + origin_url: &str, + permissions: &HashMap, +) -> Result { + let https_url = fabro_github::ssh_url_to_https(origin_url); + let (owner, repo) = fabro_github::parse_github_owner_repo(&https_url) + .map_err(|e| FabroError::engine(e.to_string()))?; + let jwt = fabro_github::sign_app_jwt(&creds.app_id, &creds.private_key_pem) + .map_err(|e| FabroError::engine(e.to_string()))?; + let client = reqwest::Client::new(); + let perms_json = + serde_json::to_value(permissions).map_err(|e| FabroError::engine(e.to_string()))?; + fabro_github::create_installation_access_token_with_permissions( + &client, + &jwt, + &owner, + &repo, + fabro_github::GITHUB_API_BASE_URL, + perms_json, + ) + .await + .map_err(|e| FabroError::engine(e.to_string())) +} + +async fn build_sandbox_env( + spec: &SandboxEnvSpec, + github_app: Option<&fabro_github::GitHubAppCredentials>, + emitter: &crate::event::EventEmitter, +) -> Result, FabroError> { + let mut env = spec.devcontainer_env.clone(); + env.extend(spec.toml_env.clone()); + + if let Some(permissions) = spec.github_permissions.as_ref() { + if !permissions.is_empty() { + if let (Some(creds), Some(origin_url)) = (github_app, spec.origin_url.as_deref()) { + match mint_github_token(creds, origin_url, permissions).await { + Ok(token) => { + env.insert("GITHUB_TOKEN".to_string(), token); + } + Err(e) => emit_run_notice( + emitter, + RunNoticeLevel::Warn, + "github_token_failed", + format!("Failed to mint GitHub token: {e}"), + ), + } + } + } + } + + Ok(env) +} + +async fn build_registry( + spec: &LlmSpec, + interviewer: Arc, + sandbox_env: &HashMap, + emitter: &crate::event::EventEmitter, +) -> Result<(Arc, Option, bool), FabroError> { + let build_dry_run = || Arc::new(default_registry(Arc::clone(&interviewer), || None)); + + if spec.dry_run { + return Ok((build_dry_run(), None, true)); + } + + match Client::from_env().await { + Ok(client) if client.provider_names().is_empty() => { + emit_run_notice( + emitter, + RunNoticeLevel::Warn, + "dry_run_no_llm", + "No LLM providers configured. Running in dry-run mode.", + ); + Ok((build_dry_run(), None, true)) + } + Ok(client) => { + let env = sandbox_env.clone(); + let model = spec.model.clone(); + let provider = spec.provider; + let fallback_chain = spec.fallback_chain.clone(); + let mcp_servers = spec.mcp_servers.clone(); + let registry = Arc::new(default_registry(interviewer, move || { + let api = AgentApiBackend::new(model.clone(), provider, fallback_chain.clone()) + .with_env(env.clone()) + .with_mcp_servers(mcp_servers.clone()); + let cli = AgentCliBackend::new(model.clone(), provider).with_env(env.clone()); + Some(Box::new(BackendRouter::new(Box::new(api), cli))) + })); + Ok((registry, Some(client), false)) + } + Err(e) => { + emit_run_notice( + emitter, + RunNoticeLevel::Warn, + "dry_run_llm_init_failed", + format!("Failed to initialize LLM client: {e}. Running in dry-run mode."), + ); + Ok((build_dry_run(), None, true)) + } + } +} + +async fn resolve_devcontainer(options: &mut InitOptions) -> Result<(), FabroError> { + let Some(devcontainer) = options.devcontainer.clone() else { + return Ok(()); + }; + if !devcontainer.enabled { + return Ok(()); + } + + let config = fabro_devcontainer::DevcontainerResolver::resolve(&devcontainer.resolve_dir) + .await + .map_err(|e| FabroError::engine(format!("Failed to resolve devcontainer: {e}")))?; + + let lifecycle_command_count = config.on_create_commands.len() + + config.post_create_commands.len() + + config.post_start_commands.len(); + options + .emitter + .emit(&WorkflowRunEvent::DevcontainerResolved { + dockerfile_lines: config.dockerfile.lines().count(), + environment_count: config.environment.len(), + lifecycle_command_count, + workspace_folder: config.workspace_folder.clone(), + }); + + if let SandboxSpec::Daytona { + config: daytona, .. + } = &mut options.sandbox + { + daytona.snapshot = Some(crate::devcontainer_bridge::devcontainer_to_snapshot_config( + &config, + )); + } + + let timeout = std::time::Duration::from_millis(300_000); + for command in &config.initialize_commands { + let shell_commands = match command { + fabro_devcontainer::Command::Shell(shell) => vec![shell.clone()], + fabro_devcontainer::Command::Args(args) => { + vec![args + .iter() + .map(|arg| try_quote(arg).unwrap_or_else(|_| arg.into()).to_string()) + .collect::>() + .join(" ")] + } + fabro_devcontainer::Command::Parallel(commands) => commands.values().cloned().collect(), + }; + + for shell_command in shell_commands { + let output = tokio::time::timeout( + timeout, + tokio::process::Command::new("sh") + .arg("-c") + .arg(&shell_command) + .current_dir(&devcontainer.resolve_dir) + .output(), + ) + .await + .map_err(|_| { + FabroError::engine(format!( + "Devcontainer initializeCommand timed out: {shell_command}" + )) + })? + .map_err(|e| { + FabroError::engine(format!( + "Failed to execute devcontainer initializeCommand: {shell_command}: {e}" + )) + })?; + + if !output.status.success() { + let code = output + .status + .code() + .map_or_else(|| "unknown".to_string(), |code| code.to_string()); + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(FabroError::engine(format!( + "Devcontainer initializeCommand failed (exit code {code}): {shell_command}\n{stderr}" + ))); + } + } + } + + options.sandbox_env.devcontainer_env = config.environment.clone(); + options.lifecycle.devcontainer_phases = vec![ + ("on_create".to_string(), config.on_create_commands.clone()), + ( + "post_create".to_string(), + config.post_create_commands.clone(), + ), + ("post_start".to_string(), config.post_start_commands.clone()), + ]; + + Ok(()) +} + +fn write_sandbox_record( + run_dir: &Path, + spec: &SandboxSpec, + sandbox: &Arc, +) -> Result<(), anyhow::Error> { + let working_directory = sandbox.working_directory().to_string(); + let identifier = { + let info = sandbox.sandbox_info(); + if info.is_empty() { + None + } else { + Some(info) + } + }; + + let record = match spec { + SandboxSpec::Docker { config } => SandboxRecord { + provider: sandbox_provider_name(spec).to_string(), + working_directory: working_directory.clone(), + identifier, + host_working_directory: Some(config.host_working_directory.clone()), + container_mount_point: Some(working_directory), + data_host: None, + }, + SandboxSpec::Ssh { config, .. } => SandboxRecord { + provider: sandbox_provider_name(spec).to_string(), + working_directory, + identifier, + host_working_directory: None, + container_mount_point: None, + data_host: Some(config.destination.clone()), + }, + _ => SandboxRecord { + provider: sandbox_provider_name(spec).to_string(), + working_directory, + identifier, + host_working_directory: None, + container_mount_point: None, + data_host: None, + }, + }; + + record.save(&run_dir.join("sandbox.json")) +} + +/// INITIALIZE phase: prepare the sandbox, env, and handlers for execution. pub async fn initialize( persisted: Persisted, mut options: InitOptions, ) -> Result { let (graph, source, _diagnostics, run_dir, _run_record) = persisted.into_parts(); - options.run_options.run_dir = run_dir; + options.run_options.run_dir = run_dir.clone(); + options.run_options.git = options.git.clone(); let hook_runner = if options.hooks.hooks.is_empty() { None } else { - Some(Arc::new(HookRunner::new(options.hooks))) + Some(Arc::new(HookRunner::new(options.hooks.clone()))) }; - options - .sandbox + resolve_devcontainer(&mut options).await?; + + let worktree_plan = resolve_worktree_plan(&mut options).await?; + if let Some(plan) = worktree_plan.as_ref() { + options.run_options.git = Some(GitCheckpointOptions { + base_sha: Some(plan.base_sha.clone()), + run_branch: Some(plan.branch_name.clone()), + meta_branch: Some(crate::git::MetadataStore::branch_name(&options.run_id)), + }); + } + + let sandbox_result = build_sandbox( + &options.sandbox, + worktree_plan.as_ref(), + Arc::clone(&options.emitter), + ) + .await?; + if worktree_plan.is_some() && !sandbox_result.worktree_created { + options.run_options.git = None; + } + + let sandbox = sandbox_result.sandbox; + let cleanup_guard = scopeguard::guard(Arc::clone(&sandbox), |sandbox| { + if let Ok(handle) = tokio::runtime::Handle::try_current() { + handle.spawn(async move { + let _ = sandbox.cleanup().await; + }); + } + }); + + sandbox .initialize() .await .map_err(|e| FabroError::engine(format!("Failed to initialize sandbox: {e}")))?; @@ -54,7 +700,7 @@ pub async fn initialize( let decision = run_hooks( hook_runner.as_deref(), &hook_ctx, - Arc::clone(&options.sandbox), + Arc::clone(&sandbox), None, ) .await; @@ -64,8 +710,43 @@ pub async fn initialize( } options.emitter.emit(&WorkflowRunEvent::SandboxInitialized { - working_directory: options.sandbox.working_directory().to_string(), + working_directory: sandbox.working_directory().to_string(), }); + if let Err(e) = write_sandbox_record(&run_dir, &options.sandbox, &sandbox) { + tracing::warn!(error = %e, "Failed to save sandbox record"); + } + + let env = build_sandbox_env( + &options.sandbox_env, + options.run_options.github_app.as_ref(), + &options.emitter, + ) + .await?; + #[cfg(test)] + let (registry, llm_client, effective_dry_run) = + if let Some(registry) = options.registry_override.clone() { + (registry, None, options.dry_run || options.llm.dry_run) + } else { + build_registry( + &options.llm, + Arc::clone(&options.interviewer), + &env, + &options.emitter, + ) + .await? + }; + #[cfg(not(test))] + let (registry, llm_client, effective_dry_run) = build_registry( + &options.llm, + Arc::clone(&options.interviewer), + &env, + &options.emitter, + ) + .await?; + if effective_dry_run { + options.dry_run = true; + options.run_options.dry_run = true; + } let has_run_branch = options .run_options @@ -74,18 +755,15 @@ pub async fn initialize( .and_then(|g| g.run_branch.as_ref()) .is_some(); if !has_run_branch { - match options - .sandbox - .setup_git_for_run(&options.run_options.run_id) - .await - { + match sandbox.setup_git_for_run(&options.run_options.run_id).await { Ok(Some(info)) => { let base_sha = options .run_options .git .as_ref() .and_then(|g| g.base_sha.clone()) - .or(Some(info.base_sha)); + .or(Some(info.base_sha.clone())); + options.run_options.display_base_sha = base_sha.clone(); options.run_options.git = Some(GitCheckpointOptions { base_sha, run_branch: Some(info.run_branch.clone()), @@ -112,18 +790,17 @@ pub async fn initialize( command_count: options.lifecycle.setup_commands.len(), }); let setup_start = Instant::now(); - for (index, cmd) in options.lifecycle.setup_commands.iter().enumerate() { + for (index, command) in options.lifecycle.setup_commands.iter().enumerate() { options .emitter .emit(&WorkflowRunEvent::SetupCommandStarted { - command: cmd.clone(), + command: command.clone(), index, }); let cmd_start = Instant::now(); - let result = options - .sandbox + let result = sandbox .exec_command( - cmd, + command, options.lifecycle.setup_command_timeout_ms, None, None, @@ -131,26 +808,26 @@ pub async fn initialize( ) .await .map_err(|e| FabroError::engine(format!("Setup command failed: {e}")))?; - let cmd_duration = crate::millis_u64(cmd_start.elapsed()); + let duration_ms = crate::millis_u64(cmd_start.elapsed()); if result.exit_code != 0 { options.emitter.emit(&WorkflowRunEvent::SetupFailed { - command: cmd.clone(), + command: command.clone(), index, exit_code: result.exit_code, stderr: result.stderr.clone(), }); return Err(FabroError::engine(format!( - "Setup command failed (exit code {}): {cmd}\n{}", + "Setup command failed (exit code {}): {command}\n{}", result.exit_code, result.stderr, ))); } options .emitter .emit(&WorkflowRunEvent::SetupCommandCompleted { - command: cmd.clone(), + command: command.clone(), index, exit_code: result.exit_code, - duration_ms: cmd_duration, + duration_ms, }); } options.emitter.emit(&WorkflowRunEvent::SetupCompleted { @@ -160,7 +837,7 @@ pub async fn initialize( for (phase, commands) in &options.lifecycle.devcontainer_phases { crate::devcontainer_bridge::run_devcontainer_lifecycle( - options.sandbox.as_ref(), + sandbox.as_ref(), &options.emitter, phase, commands, @@ -170,6 +847,8 @@ pub async fn initialize( .map_err(|e| FabroError::engine(e.to_string()))?; } + scopeguard::ScopeGuard::into_inner(cleanup_guard); + Ok(Initialized { graph, source, @@ -177,11 +856,14 @@ pub async fn initialize( checkpoint: options.checkpoint, seed_context: options.seed_context, emitter: options.emitter, - sandbox: options.sandbox, - registry: options.registry, + sandbox, + registry, hook_runner, - env: options.sandbox_env, + env, dry_run: options.dry_run, + llm_client, + model: options.llm.model, + provider: options.llm.provider, }) } @@ -196,8 +878,7 @@ mod tests { use fabro_interview::AutoApproveInterviewer; use super::*; - use crate::handler::default_registry; - use crate::pipeline::types::PersistOptions; + use crate::pipeline::types::InitOptions; use crate::records::RunRecord; use crate::run_options::RunOptions; @@ -238,6 +919,7 @@ mod tests { github_app: None, host_repo_path: None, base_branch: None, + display_base_sha: None, git: None, } } @@ -270,10 +952,6 @@ mod tests { let (graph, source) = simple_graph(); let persisted = test_persisted(graph, source.clone(), &run_dir); let emitter = Arc::new(crate::event::EventEmitter::new()); - let sandbox = Arc::new(fabro_agent::LocalSandbox::new( - std::env::current_dir().unwrap(), - )); - let registry = Arc::new(default_registry(Arc::new(AutoApproveInterviewer), || None)); let initialized = initialize( persisted, @@ -281,8 +959,17 @@ mod tests { run_id: "run-test".to_string(), dry_run: false, emitter, - sandbox, - registry, + sandbox: SandboxSpec::Local { + working_directory: std::env::current_dir().unwrap(), + }, + llm: LlmSpec { + model: "test-model".to_string(), + provider: fabro_llm::Provider::Anthropic, + fallback_chain: Vec::new(), + mcp_servers: Vec::new(), + dry_run: true, + }, + interviewer: Arc::new(AutoApproveInterviewer), lifecycle: crate::run_options::LifecycleOptions { setup_commands: vec![], setup_command_timeout_ms: 1_000, @@ -290,7 +977,16 @@ mod tests { }, run_options: test_settings(&run_dir), hooks: fabro_hooks::HookConfig { hooks: vec![] }, - sandbox_env: HashMap::from([("TEST_KEY".to_string(), "value".to_string())]), + sandbox_env: SandboxEnvSpec { + devcontainer_env: HashMap::new(), + toml_env: HashMap::from([("TEST_KEY".to_string(), "value".to_string())]), + github_permissions: None, + origin_url: None, + }, + devcontainer: None, + git: None, + worktree_mode: None, + registry_override: None, checkpoint: None, seed_context: None, }, @@ -305,20 +1001,20 @@ mod tests { initialized.env.get("TEST_KEY").map(String::as_str), Some("value") ); + assert!(initialized.dry_run); + assert_eq!(initialized.model, "test-model"); + assert_eq!(initialized.provider, fabro_llm::Provider::Anthropic); + assert!(initialized.llm_client.is_none()); } #[tokio::test] - async fn initialize_skips_empty_graph_source() { + async fn initialize_runs_setup_commands() { let temp = tempfile::tempdir().unwrap(); let run_dir = temp.path().join("run"); std::fs::create_dir_all(&run_dir).unwrap(); - let (graph, _source) = simple_graph(); - let persisted = test_persisted(graph, String::new(), &run_dir); + let (graph, source) = simple_graph(); + let persisted = test_persisted(graph, source, &run_dir); let emitter = Arc::new(crate::event::EventEmitter::new()); - let sandbox = Arc::new(fabro_agent::LocalSandbox::new( - std::env::current_dir().unwrap(), - )); - let registry = Arc::new(default_registry(Arc::new(AutoApproveInterviewer), || None)); let initialized = initialize( persisted, @@ -326,75 +1022,34 @@ mod tests { run_id: "run-test".to_string(), dry_run: false, emitter, - sandbox, - registry, + sandbox: SandboxSpec::Local { + working_directory: std::env::current_dir().unwrap(), + }, + llm: LlmSpec { + model: "test-model".to_string(), + provider: fabro_llm::Provider::Anthropic, + fallback_chain: Vec::new(), + mcp_servers: Vec::new(), + dry_run: true, + }, + interviewer: Arc::new(AutoApproveInterviewer), lifecycle: crate::run_options::LifecycleOptions { - setup_commands: vec![], + setup_commands: vec!["true".to_string()], setup_command_timeout_ms: 1_000, devcontainer_phases: vec![], }, run_options: test_settings(&run_dir), hooks: fabro_hooks::HookConfig { hooks: vec![] }, - sandbox_env: HashMap::new(), - checkpoint: None, - seed_context: None, - }, - ) - .await - .unwrap(); - - assert!(initialized.source.is_empty()); - } - - #[tokio::test] - async fn initialize_uses_loaded_persisted_run_state() { - let temp = tempfile::tempdir().unwrap(); - let run_dir = temp.path().join("run"); - let wrong_run_dir = temp.path().join("wrong-run-dir"); - let (graph, source) = simple_graph(); - - crate::pipeline::persist( - crate::pipeline::Validated::new(graph.clone(), source.clone(), vec![]), - PersistOptions { - run_dir: run_dir.clone(), - run_record: RunRecord { - run_id: "run-test".to_string(), - created_at: Utc::now(), - config: FabroConfig::default(), - graph, - workflow_slug: Some("test".to_string()), - working_directory: std::env::current_dir().unwrap(), - host_repo_path: Some(std::env::current_dir().unwrap().display().to_string()), - base_branch: Some("main".to_string()), - labels: HashMap::new(), - }, - }, - ) - .unwrap(); - - let loaded = Persisted::load(&run_dir).unwrap(); - let emitter = Arc::new(crate::event::EventEmitter::new()); - let sandbox = Arc::new(fabro_agent::LocalSandbox::new( - std::env::current_dir().unwrap(), - )); - let registry = Arc::new(default_registry(Arc::new(AutoApproveInterviewer), || None)); - - let initialized = initialize( - loaded, - InitOptions { - run_id: "run-test".to_string(), - dry_run: false, - emitter, - sandbox, - registry, - lifecycle: crate::run_options::LifecycleOptions { - setup_commands: vec![], - setup_command_timeout_ms: 1_000, - devcontainer_phases: vec![], - }, - run_options: test_settings(&wrong_run_dir), - hooks: fabro_hooks::HookConfig { hooks: vec![] }, - sandbox_env: HashMap::new(), + sandbox_env: SandboxEnvSpec { + devcontainer_env: HashMap::new(), + toml_env: HashMap::new(), + github_permissions: None, + origin_url: None, + }, + devcontainer: None, + git: None, + worktree_mode: None, + registry_override: None, checkpoint: None, seed_context: None, }, @@ -402,7 +1057,7 @@ mod tests { .await .unwrap(); + assert!(run_dir.join("sandbox.json").exists()); assert_eq!(initialized.run_options.run_dir, run_dir); - assert_eq!(initialized.source, source); } } diff --git a/lib/crates/fabro-workflows/src/pipeline/mod.rs b/lib/crates/fabro-workflows/src/pipeline/mod.rs index 63fed3499..32f83c5f3 100644 --- a/lib/crates/fabro-workflows/src/pipeline/mod.rs +++ b/lib/crates/fabro-workflows/src/pipeline/mod.rs @@ -23,7 +23,8 @@ pub use pull_request::{ pub use retro::{retro, run_retro}; pub use transform::transform; pub use types::{ - Concluded, Executed, FinalizeOptions, Finalized, InitOptions, Initialized, Parsed, Persisted, - PullRequestOptions, RetroOptions, Retroed, TransformOptions, Transformed, Validated, + Concluded, DevcontainerSpec, Executed, FinalizeOptions, Finalized, InitOptions, Initialized, + LlmSpec, Parsed, Persisted, PullRequestOptions, RetroOptions, Retroed, SandboxEnvSpec, + SandboxSpec, TransformOptions, Transformed, Validated, }; pub use validate::validate; diff --git a/lib/crates/fabro-workflows/src/pipeline/pull_request.rs b/lib/crates/fabro-workflows/src/pipeline/pull_request.rs index 4d94c1bbe..a694e69ec 100644 --- a/lib/crates/fabro-workflows/src/pipeline/pull_request.rs +++ b/lib/crates/fabro-workflows/src/pipeline/pull_request.rs @@ -488,7 +488,9 @@ pub async fn pull_request(concluded: Concluded, options: &PullRequestOptions) -> let mut pr_url = None; if let Some(pr_cfg) = &options.pr_config { - if let Err(ref e) = outcome { + if run_options.dry_run { + tracing::debug!("Skipping PR creation: run is in dry-run mode"); + } else if let Err(ref e) = outcome { tracing::debug!(error = %e, "Skipping PR creation: engine returned an error"); } else if let Ok(ref result) = outcome { if matches!( diff --git a/lib/crates/fabro-workflows/src/pipeline/retro.rs b/lib/crates/fabro-workflows/src/pipeline/retro.rs index 6f9a022c4..8f157d1a6 100644 --- a/lib/crates/fabro-workflows/src/pipeline/retro.rs +++ b/lib/crates/fabro-workflows/src/pipeline/retro.rs @@ -8,7 +8,7 @@ use crate::records::Checkpoint; use super::types::{Executed, RetroOptions, Retroed}; -pub async fn run_retro(options: &RetroOptions) -> Option { +pub async fn run_retro(options: &RetroOptions, dry_run: bool) -> Option { let cp = match Checkpoint::load(&options.run_dir.join("checkpoint.json")) { Ok(cp) => cp, Err(e) => { @@ -43,7 +43,7 @@ pub async fn run_retro(options: &RetroOptions) -> Option { emitter.emit(&WorkflowRunEvent::RetroStarted); } - let narrative_result = if options.dry_run { + let narrative_result = if dry_run { Ok(fabro_retro::retro_agent::dry_run_narrative()) } else if let Some(client) = options.llm_client.as_ref() { let emitter_clone = options.emitter.clone(); @@ -122,10 +122,15 @@ pub async fn retro(executed: Executed, options: &RetroOptions) -> Retroed { sandbox, duration_ms, final_context: _, + llm_client: _, + model: _, + provider: _, } = executed; + let dry_run = run_options.dry_run; + let retro = if options.enabled { - run_retro(options).await + run_retro(options, dry_run).await } else { None }; @@ -189,6 +194,7 @@ mod tests { github_app: None, host_repo_path: None, base_branch: None, + display_base_sha: None, git: None, } } @@ -213,6 +219,9 @@ mod tests { sandbox: Arc::clone(&sandbox), duration_ms: 1, final_context: Context::new(), + llm_client: None, + model: "test-model".to_string(), + provider: fabro_llm::Provider::Anthropic, }; let retroed = retro( @@ -227,7 +236,6 @@ mod tests { failed: false, run_duration_ms: 1, enabled: true, - dry_run: true, llm_client: None, provider: fabro_llm::Provider::Anthropic, model: "test-model".to_string(), @@ -253,23 +261,25 @@ mod tests { move |event| seen.lock().unwrap().push(event.clone()) }); - let retro = run_retro(&RetroOptions { - run_id: "run-test".to_string(), - workflow_name: "test".to_string(), - goal: "Ship it".to_string(), - run_dir: run_dir.clone(), - sandbox: Arc::new(fabro_agent::LocalSandbox::new( - std::env::current_dir().unwrap(), - )), - emitter: Some(Arc::clone(&emitter)), - failed: false, - run_duration_ms: 1, - enabled: true, - dry_run: true, - llm_client: None, - provider: fabro_llm::Provider::Anthropic, - model: "test-model".to_string(), - }) + let retro = run_retro( + &RetroOptions { + run_id: "run-test".to_string(), + workflow_name: "test".to_string(), + goal: "Ship it".to_string(), + run_dir: run_dir.clone(), + sandbox: Arc::new(fabro_agent::LocalSandbox::new( + std::env::current_dir().unwrap(), + )), + emitter: Some(Arc::clone(&emitter)), + failed: false, + run_duration_ms: 1, + enabled: true, + llm_client: None, + provider: fabro_llm::Provider::Anthropic, + model: "test-model".to_string(), + }, + true, + ) .await; assert!(retro.is_some()); diff --git a/lib/crates/fabro-workflows/src/pipeline/types.rs b/lib/crates/fabro-workflows/src/pipeline/types.rs index 59ef6886c..87993947c 100644 --- a/lib/crates/fabro-workflows/src/pipeline/types.rs +++ b/lib/crates/fabro-workflows/src/pipeline/types.rs @@ -3,14 +3,23 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use fabro_agent::Sandbox; +use fabro_config::sandbox::WorktreeMode; use fabro_graphviz::graph::Graph; use fabro_hooks::HookRunner; +use fabro_interview::Interviewer; +use fabro_llm::Provider; +use fabro_mcp::config::McpServerConfig; +use fabro_model::FallbackTarget; +use fabro_sandbox::daytona::DaytonaConfig; +use fabro_sandbox::docker::DockerSandboxConfig; +#[cfg(feature = "exedev")] +use fabro_sandbox::exe::{ExeConfig, GitCloneParams as ExeGitCloneParams}; +use fabro_sandbox::ssh::{GitCloneParams as SshGitCloneParams, SshConfig}; use fabro_validate::Diagnostic; use crate::context::Context; use crate::error::FabroError; use crate::event::EventEmitter; -use crate::handler::HandlerRegistry; use crate::outcome::Outcome; use crate::records::{Checkpoint, Conclusion, RunRecord}; use crate::run_options::{LifecycleOptions, RunOptions}; @@ -189,16 +198,74 @@ impl Persisted { } /// Options for the INITIALIZE phase. +pub enum SandboxSpec { + Local { + working_directory: PathBuf, + }, + Docker { + config: DockerSandboxConfig, + }, + Daytona { + config: DaytonaConfig, + github_app: Option, + run_id: Option, + clone_branch: Option, + }, + #[cfg(feature = "exedev")] + Exe { + config: ExeConfig, + clone_params: Option, + run_id: Option, + github_app: Option, + mgmt_destination: String, + }, + Ssh { + config: SshConfig, + clone_params: Option, + run_id: Option, + github_app: Option, + }, +} + +#[derive(Clone)] +pub struct LlmSpec { + pub model: String, + pub provider: Provider, + pub fallback_chain: Vec, + pub mcp_servers: Vec, + pub dry_run: bool, +} + +#[derive(Clone)] +pub struct SandboxEnvSpec { + pub devcontainer_env: HashMap, + pub toml_env: HashMap, + pub github_permissions: Option>, + pub origin_url: Option, +} + +#[derive(Clone)] +pub struct DevcontainerSpec { + pub enabled: bool, + pub resolve_dir: PathBuf, +} + pub struct InitOptions { pub run_id: String, pub dry_run: bool, pub emitter: Arc, - pub sandbox: Arc, - pub registry: Arc, + pub sandbox: SandboxSpec, + pub llm: LlmSpec, + pub interviewer: Arc, pub lifecycle: LifecycleOptions, pub run_options: RunOptions, pub hooks: fabro_hooks::HookConfig, - pub sandbox_env: HashMap, + pub sandbox_env: SandboxEnvSpec, + pub devcontainer: Option, + pub git: Option, + pub worktree_mode: Option, + #[cfg(test)] + pub registry_override: Option>, pub checkpoint: Option, pub seed_context: Option, } @@ -213,10 +280,13 @@ pub struct Initialized { pub(crate) seed_context: Option, pub emitter: Arc, pub sandbox: Arc, - pub registry: Arc, + pub registry: Arc, pub hook_runner: Option>, pub env: HashMap, pub dry_run: bool, + pub llm_client: Option, + pub model: String, + pub provider: Provider, } /// Output of the EXECUTE phase. @@ -230,6 +300,9 @@ pub struct Executed { pub sandbox: Arc, pub duration_ms: u64, pub final_context: Context, + pub llm_client: Option, + pub model: String, + pub provider: Provider, } /// Output of the RETRO phase. @@ -284,9 +357,8 @@ pub struct RetroOptions { pub failed: bool, pub run_duration_ms: u64, pub enabled: bool, - pub dry_run: bool, pub llm_client: Option, - pub provider: fabro_llm::Provider, + pub provider: Provider, pub model: String, } diff --git a/lib/crates/fabro-workflows/src/run_options.rs b/lib/crates/fabro-workflows/src/run_options.rs index 1362ac5ab..204285527 100644 --- a/lib/crates/fabro-workflows/src/run_options.rs +++ b/lib/crates/fabro-workflows/src/run_options.rs @@ -37,6 +37,8 @@ pub struct RunOptions { pub host_repo_path: Option, /// Name of the branch the run was started from (for PR base). pub base_branch: Option, + /// Base commit SHA to display in lifecycle events/UI even when checkpointing is disabled. + pub display_base_sha: Option, /// Git checkpoint options; `None` means checkpointing disabled. pub git: Option, } diff --git a/lib/crates/fabro-workflows/src/test_support.rs b/lib/crates/fabro-workflows/src/test_support.rs index 747c4db76..f6f8384ec 100644 --- a/lib/crates/fabro-workflows/src/test_support.rs +++ b/lib/crates/fabro-workflows/src/test_support.rs @@ -39,6 +39,9 @@ fn initialized( hook_runner: options.hook_runner, env: options.env, dry_run: run_options.dry_run, + llm_client: None, + model: String::new(), + provider: fabro_llm::Provider::Anthropic, } } diff --git a/lib/crates/fabro-workflows/tests/daytona_integration.rs b/lib/crates/fabro-workflows/tests/daytona_integration.rs index 23ac81cfd..980540b5b 100644 --- a/lib/crates/fabro-workflows/tests/daytona_integration.rs +++ b/lib/crates/fabro-workflows/tests/daytona_integration.rs @@ -399,6 +399,7 @@ async fn daytona_pipeline_artifact_offload_and_sync() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -590,6 +591,7 @@ async fn daytona_git_checkpoint_remote_emits_events() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: Some(dir.path().to_path_buf()), git: Some(GitCheckpointOptions { base_sha: Some(base_sha), @@ -776,6 +778,7 @@ async fn daytona_parallel_git_branching_e2e() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: Some(run_tmp.path().to_path_buf()), git: Some(GitCheckpointOptions { base_sha: Some(base_sha), @@ -1152,6 +1155,7 @@ async fn daytona_git_checkpoint_with_shadow_branch() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: Some(host_repo.path().to_path_buf()), git: Some(GitCheckpointOptions { base_sha: Some(base_sha), @@ -1297,6 +1301,7 @@ async fn daytona_asset_collection() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -1548,6 +1553,7 @@ async fn daytona_git_push_run_branch_to_origin() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: Some(dir.path().to_path_buf()), git: Some(GitCheckpointOptions { base_sha: Some(base_sha), diff --git a/lib/crates/fabro-workflows/tests/integration.rs b/lib/crates/fabro-workflows/tests/integration.rs index a17b60e1e..63b0e2ad9 100644 --- a/lib/crates/fabro-workflows/tests/integration.rs +++ b/lib/crates/fabro-workflows/tests/integration.rs @@ -204,6 +204,7 @@ async fn end_to_end_linear_pipeline() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -343,6 +344,7 @@ async fn end_to_end_branching_pipeline() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -463,6 +465,7 @@ async fn end_to_end_human_gate_pipeline() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -559,6 +562,7 @@ async fn human_gate_aborted_input_fails_closed_without_fail_route() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -670,6 +674,7 @@ async fn human_gate_aborted_input_routes_via_outcome_fail_condition() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -783,6 +788,7 @@ async fn goal_gate_routes_to_retry_target_on_failure() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -904,6 +910,7 @@ async fn goal_gate_routes_to_retry_target_when_present() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -1216,6 +1223,7 @@ async fn retry_on_failure_then_succeed() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -1291,6 +1299,7 @@ async fn pipeline_with_many_nodes() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -1613,6 +1622,7 @@ async fn smoke_test_with_mock_codergen_backend() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -1714,6 +1724,7 @@ async fn end_to_end_parallel_fan_out_fan_in() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -1827,6 +1838,7 @@ async fn resume_from_checkpoint_completes_pipeline() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -1926,6 +1938,7 @@ async fn resume_from_checkpoint_preserves_goal_gate_outcomes() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -1969,6 +1982,7 @@ async fn graph_goal_in_context() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -2005,6 +2019,7 @@ async fn event_streaming_lifecycle() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -2085,6 +2100,7 @@ async fn context_flow_between_stages() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -2138,6 +2154,7 @@ async fn tool_handler_e2e() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -2208,6 +2225,7 @@ async fn auto_approve_interviewer_e2e() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -2245,6 +2263,7 @@ async fn codergen_without_backend_simulated() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -2350,6 +2369,7 @@ async fn branching_loop_back_on_failure() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -2433,6 +2453,7 @@ async fn human_gate_loops_back() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -2491,6 +2512,7 @@ async fn scenario_ship_a_feature() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -2577,6 +2599,7 @@ async fn scenario_parallel_expert_review() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -2661,6 +2684,7 @@ async fn scenario_node_retries_on_retry_status() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -2723,6 +2747,7 @@ async fn scenario_loop_restart_resets_context() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -2791,6 +2816,7 @@ async fn scenario_bug_triage_router() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -2850,6 +2876,7 @@ async fn scenario_crash_recovery() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -2959,6 +2986,7 @@ async fn manager_loop_stop_condition_satisfied_e2e() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -3036,6 +3064,7 @@ async fn manager_loop_max_cycles_exceeded_e2e() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -3172,6 +3201,7 @@ async fn conditional_branching_success_fail_paths() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -3225,6 +3255,7 @@ async fn edge_selection_condition_match_wins_over_weight() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -3272,6 +3303,7 @@ async fn edge_selection_weight_breaks_ties() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -3311,6 +3343,7 @@ async fn edge_selection_lexical_tiebreak() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -3369,6 +3402,7 @@ async fn context_updates_visible_across_nodes() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -3413,6 +3447,7 @@ async fn stylesheet_applies_model_override() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -3469,6 +3504,7 @@ async fn custom_handler_registration_and_execution() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -3540,6 +3576,7 @@ async fn integration_smoke_plan_implement_review_done() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -3644,6 +3681,7 @@ async fn manager_loop_runs_child_engine_e2e() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -3778,6 +3816,7 @@ async fn manager_loop_context_flows_e2e() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -3851,6 +3890,7 @@ async fn manager_loop_child_dotfile_e2e() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -3964,6 +4004,7 @@ async fn graph_merge_e2e_through_engine() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -4114,6 +4155,7 @@ async fn fidelity_default_is_compact() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -4171,6 +4213,7 @@ async fn fidelity_graph_default_applied() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -4224,6 +4267,7 @@ async fn fidelity_node_overrides_graph_default() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -4283,6 +4327,7 @@ async fn fidelity_edge_overrides_node_and_graph() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -4332,6 +4377,7 @@ async fn fidelity_full_produces_empty_preamble() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -4391,6 +4437,7 @@ async fn fidelity_truncate_preamble_minimal() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -4463,6 +4510,7 @@ async fn fidelity_summary_low_mode() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -4530,6 +4578,7 @@ async fn fidelity_summary_medium_mode() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -4597,6 +4646,7 @@ async fn fidelity_summary_high_mode() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -4657,6 +4707,7 @@ async fn fidelity_full_sets_thread_id_in_context() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -4728,6 +4779,7 @@ async fn fidelity_full_nodes_share_thread_id() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -4809,6 +4861,7 @@ async fn fidelity_resume_degrades_full_to_summary_high() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -4906,6 +4959,7 @@ async fn fidelity_resume_degrade_only_affects_first_hop() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -4990,6 +5044,7 @@ async fn fidelity_resume_no_degrade_when_not_full() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -5032,6 +5087,7 @@ async fn fidelity_stored_in_checkpoint_context() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -5118,6 +5174,7 @@ async fn fidelity_precedence_multi_node_pipeline() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -5186,6 +5243,7 @@ async fn fidelity_compact_preamble_includes_completed_stages_and_context() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -5261,6 +5319,7 @@ async fn fidelity_summary_low_excludes_context_values_in_pipeline() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -5328,6 +5387,7 @@ async fn fidelity_summary_low_excludes_context_values_in_pipeline() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -5399,6 +5459,7 @@ async fn fidelity_thread_id_fallback_to_previous_node_in_pipeline() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -5453,6 +5514,7 @@ async fn fidelity_thread_id_from_node_class_in_pipeline() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -5510,6 +5572,7 @@ async fn fidelity_edge_thread_id_override_in_pipeline() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -5568,6 +5631,7 @@ async fn fidelity_full_without_explicit_thread_id_uses_previous_node() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -5636,6 +5700,7 @@ async fn fidelity_from_parsed_dot_pipeline() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -5684,6 +5749,7 @@ async fn fidelity_checkpoint_roundtrip_preserves_fidelity() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -5754,6 +5820,7 @@ async fn fidelity_node_thread_id_overrides_edge_thread_id_in_pipeline() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -5841,6 +5908,7 @@ async fn fidelity_resume_preserves_context_values_across_checkpoint() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -6056,6 +6124,7 @@ mod real_llm { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -6170,6 +6239,7 @@ mod real_llm { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -6309,6 +6379,7 @@ mod real_llm { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -6416,6 +6487,7 @@ mod real_llm { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -6512,6 +6584,7 @@ async fn human_gate_freeform_only_routes_text() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -6642,6 +6715,7 @@ async fn human_gate_freeform_with_fixed_choice_match() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -6757,6 +6831,7 @@ async fn human_gate_freeform_fallback_on_unmatched_text() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -6885,6 +6960,7 @@ async fn human_gate_freeform_sets_allow_freeform_on_question() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -6993,6 +7069,7 @@ async fn human_gate_without_freeform_sets_allow_freeform_false() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -7274,6 +7351,7 @@ fn make_run_options(dir: &std::path::Path) -> RunOptions { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, } @@ -8378,6 +8456,7 @@ async fn arc_e2e_with_real_llm() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -8506,6 +8585,7 @@ async fn run_fidelity_prompt_pipeline(fidelity: &str) -> String { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -8705,6 +8785,7 @@ async fn large_context_values_are_offloaded_to_artifact_store() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -8924,6 +9005,7 @@ async fn artifact_pointers_rewritten_for_remote_sandbox() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -9054,6 +9136,7 @@ async fn node_dir_uses_visit_count_on_revisit() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -10026,6 +10109,7 @@ async fn full_pipeline_with_cli_backend_node() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -10157,6 +10241,7 @@ async fn stylesheet_backend_property_routes_to_cli() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -10436,6 +10521,7 @@ async fn git_checkpoint_host_emits_events_and_diff_patch() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: Some(worktree_path.clone()), git: Some(GitCheckpointOptions { base_sha: Some(base_sha.clone()), @@ -10639,6 +10725,7 @@ async fn git_checkpoint_host_writes_shadow_branch() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: Some(worktree_path.clone()), git: Some(GitCheckpointOptions { base_sha: Some(base_sha), @@ -10837,6 +10924,7 @@ async fn parallel_git_branching_host_e2e() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: Some(worktree_path.clone()), git: Some(GitCheckpointOptions { base_sha: Some(base_sha.clone()), @@ -11100,6 +11188,7 @@ async fn git_checkpoint_host_skips_empty_diff_patch() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: Some(worktree_path.clone()), git: Some(GitCheckpointOptions { base_sha: Some(base_sha.clone()), @@ -11482,6 +11571,7 @@ async fn e2e_circuit_breaker_deterministic_self_loop() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -11529,6 +11619,7 @@ async fn e2e_circuit_breaker_custom_limit() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -11569,6 +11660,7 @@ async fn e2e_circuit_breaker_ignores_transient_failures() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -11616,6 +11708,7 @@ async fn e2e_circuit_breaker_different_reasons_separate_counters() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -11656,6 +11749,7 @@ async fn e2e_circuit_breaker_loop_restart() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -11718,6 +11812,7 @@ async fn e2e_failure_signature_persisted_in_context() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -11782,6 +11877,7 @@ async fn e2e_failure_signature_hint_overrides_reason_in_context() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -11838,6 +11934,7 @@ async fn e2e_signature_maps_persist_in_checkpoint() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -11965,6 +12062,7 @@ async fn e2e_circuit_breaker_emits_events_before_abort() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -12032,6 +12130,7 @@ async fn e2e_circuit_breaker_does_not_fire_below_limit() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -12128,6 +12227,7 @@ async fn e2e_circuit_breaker_multi_stage_impl_verify_cycle() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -12225,6 +12325,7 @@ async fn e2e_loop_restart_blocked_for_deterministic_failure() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -12265,6 +12366,7 @@ async fn e2e_loop_restart_blocked_for_structural_failure() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -12305,6 +12407,7 @@ async fn e2e_loop_restart_blocked_for_budget_exhausted_failure() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -12345,6 +12448,7 @@ async fn e2e_loop_restart_blocked_for_canceled_failure() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -12382,6 +12486,7 @@ async fn e2e_loop_restart_blocked_for_compilation_loop_failure() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -12423,6 +12528,7 @@ async fn e2e_loop_restart_allowed_for_transient_infra() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -12527,6 +12633,7 @@ async fn e2e_stall_watchdog_triggers_from_dot_parsed_pipeline() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -12583,6 +12690,7 @@ async fn e2e_stall_watchdog_kept_alive_by_handler_events() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -12629,6 +12737,7 @@ async fn e2e_stall_watchdog_disabled_with_zero_timeout() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -12694,6 +12803,7 @@ async fn e2e_stall_watchdog_with_explicit_timeout_override() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -12830,6 +12940,7 @@ async fn asset_collection_local_sandbox_success() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -12944,6 +13055,7 @@ async fn asset_collection_local_sandbox_on_failure() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -13041,6 +13153,7 @@ async fn asset_collection_docker_sandbox() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, }; @@ -13110,6 +13223,7 @@ async fn wait_timer_e2e() { workflow_slug: None, github_app: None, base_branch: None, + display_base_sha: None, host_repo_path: None, git: None, };