mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-09 22:33:37 +00:00
Thin CLI run commands and move execution into workflows operations
This commit is contained in:
parent
defcf9c746
commit
db3231e987
26 changed files with 2200 additions and 3072 deletions
|
|
@ -21,7 +21,7 @@ use crate::jwt_auth::{AuthMode, AuthenticatedService, AuthenticatedUser};
|
||||||
use fabro_interview::{Answer, Interviewer, QuestionType, WebInterviewer};
|
use fabro_interview::{Answer, Interviewer, QuestionType, WebInterviewer};
|
||||||
use fabro_workflows::context::Context;
|
use fabro_workflows::context::Context;
|
||||||
use fabro_workflows::event::{EventEmitter, WorkflowRunEvent};
|
use fabro_workflows::event::{EventEmitter, WorkflowRunEvent};
|
||||||
use fabro_workflows::operations::{self, RunCreateOptions};
|
use fabro_workflows::operations::{self, CreateRequest, WorkflowInput};
|
||||||
use fabro_workflows::pipeline::{
|
use fabro_workflows::pipeline::{
|
||||||
self, InitOptions, LlmSpec, Persisted, SandboxEnvSpec, SandboxSpec,
|
self, InitOptions, LlmSpec, Persisted, SandboxEnvSpec, SandboxSpec,
|
||||||
};
|
};
|
||||||
|
|
@ -526,25 +526,19 @@ async fn start_run(
|
||||||
}),
|
}),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let run_labels = settings.labels.clone();
|
let created = match operations::create(CreateRequest {
|
||||||
let persisted = match operations::create(
|
workflow: WorkflowInput::DotSource {
|
||||||
&req.dot_source,
|
source: req.dot_source.clone(),
|
||||||
RunCreateOptions {
|
base_dir: None,
|
||||||
|
workflow_slug: None,
|
||||||
|
},
|
||||||
settings,
|
settings,
|
||||||
run_dir: Some(run_dir.clone()),
|
run_dir: Some(run_dir.clone()),
|
||||||
run_id: Some(run_id.clone()),
|
run_id: Some(run_id.clone()),
|
||||||
workflow_slug: None,
|
|
||||||
labels: run_labels,
|
|
||||||
base_branch: None,
|
|
||||||
working_directory: Some(
|
|
||||||
std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")),
|
|
||||||
),
|
|
||||||
host_repo_path: None,
|
host_repo_path: None,
|
||||||
goal_override: None,
|
base_branch: None,
|
||||||
base_dir: None,
|
}) {
|
||||||
},
|
Ok(created) => created,
|
||||||
) {
|
|
||||||
Ok(persisted) => persisted,
|
|
||||||
Err(ref err @ fabro_workflows::error::FabroError::ValidationFailed { ref diagnostics }) => {
|
Err(ref err @ fabro_workflows::error::FabroError::ValidationFailed { ref diagnostics }) => {
|
||||||
let message = if diagnostics.is_empty() {
|
let message = if diagnostics.is_empty() {
|
||||||
err.to_string()
|
err.to_string()
|
||||||
|
|
@ -568,6 +562,7 @@ async fn start_run(
|
||||||
.into_response();
|
.into_response();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let persisted = created.persisted;
|
||||||
let created_at = persisted.run_record().created_at;
|
let created_at = persisted.run_record().created_at;
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -646,12 +646,12 @@ pub(crate) enum RunCommands {
|
||||||
/// Internal: run the engine process (reads run.json from run dir)
|
/// Internal: run the engine process (reads run.json from run dir)
|
||||||
#[command(name = "__detached", hide = true)]
|
#[command(name = "__detached", hide = true)]
|
||||||
Detached {
|
Detached {
|
||||||
/// Base storage directory
|
/// Run directory
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
storage_dir: PathBuf,
|
run_dir: PathBuf,
|
||||||
/// Run ID
|
/// Launcher metadata path
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
run_id: String,
|
launcher_path: PathBuf,
|
||||||
/// Resume from checkpoint instead of fresh start
|
/// Resume from checkpoint instead of fresh start
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
resume: bool,
|
resume: bool,
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ use std::io::Write;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use crate::args::{ConfigCommand, ConfigNamespace, ConfigShowArgs};
|
use crate::args::{ConfigCommand, ConfigNamespace, ConfigShowArgs};
|
||||||
use anyhow::bail;
|
|
||||||
use fabro_config::{FabroConfig, FabroSettings};
|
use fabro_config::{FabroConfig, FabroSettings};
|
||||||
|
|
||||||
pub fn dispatch(ns: ConfigNamespace) -> anyhow::Result<()> {
|
pub fn dispatch(ns: ConfigNamespace) -> anyhow::Result<()> {
|
||||||
|
|
@ -13,25 +12,14 @@ pub fn dispatch(ns: ConfigNamespace) -> anyhow::Result<()> {
|
||||||
|
|
||||||
fn merged_config(workflow: Option<&Path>) -> anyhow::Result<FabroSettings> {
|
fn merged_config(workflow: Option<&Path>) -> anyhow::Result<FabroSettings> {
|
||||||
if let Some(workflow) = workflow {
|
if let Some(workflow) = workflow {
|
||||||
let (resolved_path, _dot_path, run_config) =
|
|
||||||
crate::commands::run::execute::resolve_workflow_source(workflow)?;
|
|
||||||
let missing_workflow = run_config.is_none() && !resolved_path.is_file();
|
|
||||||
let project_config = fabro_config::project::discover_project_config(
|
|
||||||
resolved_path.parent().unwrap_or_else(|| Path::new(".")),
|
|
||||||
)?
|
|
||||||
.map(|(_, config)| config)
|
|
||||||
.unwrap_or_default();
|
|
||||||
let cli_config = fabro_config::cli::load_cli_config(None)?;
|
let cli_config = fabro_config::cli::load_cli_config(None)?;
|
||||||
let config = run_config
|
return fabro_workflows::operations::resolve_settings_for_path(
|
||||||
.unwrap_or_default()
|
workflow,
|
||||||
.combine(project_config)
|
cli_config,
|
||||||
.combine(cli_config);
|
FabroConfig::default(),
|
||||||
|
true,
|
||||||
if missing_workflow {
|
)
|
||||||
bail!("Workflow not found: {}", resolved_path.display());
|
.map_err(Into::into);
|
||||||
}
|
|
||||||
|
|
||||||
return config.try_into();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let cwd = std::env::current_dir()?;
|
let cwd = std::env::current_dir()?;
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use anyhow::bail;
|
use anyhow::bail;
|
||||||
|
use fabro_agent::{DockerSandbox, DockerSandboxConfig, LocalSandbox, Sandbox};
|
||||||
use fabro_config::{FabroConfig, FabroSettings};
|
use fabro_config::{FabroConfig, FabroSettings};
|
||||||
|
use fabro_model::{Catalog, Provider};
|
||||||
|
use fabro_sandbox::SandboxProvider;
|
||||||
use fabro_util::terminal::Styles;
|
use fabro_util::terminal::Styles;
|
||||||
|
use fabro_workflows::git::GitSyncStatus;
|
||||||
|
|
||||||
use super::run::execute::{
|
|
||||||
load_workflow_source_input, print_workflow_report, resolve_sandbox_provider, run_preflight,
|
|
||||||
};
|
|
||||||
use crate::args::PreflightArgs;
|
use crate::args::PreflightArgs;
|
||||||
|
|
||||||
pub async fn execute(mut args: PreflightArgs) -> anyhow::Result<()> {
|
pub async fn execute(mut args: PreflightArgs) -> anyhow::Result<()> {
|
||||||
|
|
@ -17,51 +19,503 @@ pub async fn execute(mut args: PreflightArgs) -> anyhow::Result<()> {
|
||||||
|
|
||||||
let github_app = crate::shared::github::build_github_app_credentials(cli_config.app_id());
|
let github_app = crate::shared::github::build_github_app_credentials(cli_config.app_id());
|
||||||
let cli_args_config = FabroConfig::try_from(&args)?;
|
let cli_args_config = FabroConfig::try_from(&args)?;
|
||||||
|
let settings = fabro_workflows::operations::resolve_settings_for_path(
|
||||||
|
&args.workflow,
|
||||||
|
cli_defaults,
|
||||||
|
cli_args_config,
|
||||||
|
true,
|
||||||
|
)?;
|
||||||
|
let resolved = fabro_workflows::operations::resolve_workflow(
|
||||||
|
fabro_workflows::operations::ResolveWorkflowRequest {
|
||||||
|
workflow: fabro_workflows::operations::WorkflowInput::Path(args.workflow.clone()),
|
||||||
|
settings: settings.clone(),
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
|
||||||
let source_input =
|
|
||||||
load_workflow_source_input(&args.workflow, cli_args_config, cli_defaults, true)?;
|
|
||||||
|
|
||||||
let original_cwd = std::env::current_dir()?;
|
|
||||||
let (origin_url, detected_base_branch) =
|
let (origin_url, detected_base_branch) =
|
||||||
fabro_sandbox::daytona::detect_repo_info(&original_cwd)
|
fabro_sandbox::daytona::detect_repo_info(&resolved.working_directory)
|
||||||
.map(|(url, branch)| (Some(url), branch))
|
.map(|(url, branch)| (Some(url), branch))
|
||||||
.unwrap_or((None, None));
|
.unwrap_or((None, None));
|
||||||
let git_status =
|
let git_status = fabro_workflows::git::sync_status(
|
||||||
fabro_workflows::git::sync_status(&original_cwd, "origin", detected_base_branch.as_deref());
|
&resolved.working_directory,
|
||||||
|
"origin",
|
||||||
|
detected_base_branch.as_deref(),
|
||||||
|
);
|
||||||
|
|
||||||
let sandbox_provider =
|
let sandbox_provider = resolve_sandbox_provider(args.sandbox.map(Into::into), &settings)?;
|
||||||
resolve_sandbox_provider(args.sandbox.map(Into::into), &source_input.settings)?;
|
|
||||||
|
|
||||||
let validated = fabro_workflows::operations::validate(
|
let validated = fabro_workflows::operations::validate(
|
||||||
&source_input.raw_source,
|
&resolved.raw_source,
|
||||||
fabro_workflows::operations::ValidateOptions {
|
fabro_workflows::operations::ValidateOptions {
|
||||||
base_dir: Some(
|
base_dir: resolved.base_dir.clone(),
|
||||||
source_input
|
settings: Some(resolved.settings.clone()),
|
||||||
.dot_path
|
goal_override: resolved.goal_override.clone(),
|
||||||
.parent()
|
|
||||||
.unwrap_or(Path::new("."))
|
|
||||||
.to_path_buf(),
|
|
||||||
),
|
|
||||||
settings: Some(source_input.settings.clone()),
|
|
||||||
goal_override: source_input.goal_override.clone(),
|
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
)?;
|
)?;
|
||||||
print_workflow_report(&validated, &source_input.dot_path, styles);
|
super::run::output::print_workflow_report(&validated, resolved.dot_path.as_deref(), styles);
|
||||||
if validated.has_errors() {
|
if validated.has_errors() {
|
||||||
bail!("Validation failed");
|
bail!("Validation failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
run_preflight(
|
run_preflight(
|
||||||
validated.graph(),
|
validated.graph(),
|
||||||
&source_input.settings,
|
&resolved.settings,
|
||||||
args.model.as_deref(),
|
args.model.as_deref(),
|
||||||
args.provider.as_deref(),
|
args.provider.as_deref(),
|
||||||
git_status,
|
git_status,
|
||||||
sandbox_provider,
|
sandbox_provider,
|
||||||
|
&resolved.working_directory,
|
||||||
styles,
|
styles,
|
||||||
github_app,
|
github_app,
|
||||||
origin_url.as_deref(),
|
origin_url.as_deref(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn resolve_model_provider(
|
||||||
|
cli_model: Option<&str>,
|
||||||
|
cli_provider: Option<&str>,
|
||||||
|
settings: &FabroSettings,
|
||||||
|
graph: &fabro_graphviz::graph::Graph,
|
||||||
|
) -> (String, Option<String>) {
|
||||||
|
let configured_model = settings.llm.as_ref().and_then(|llm| llm.model.as_deref());
|
||||||
|
let configured_provider = settings
|
||||||
|
.llm
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|llm| llm.provider.as_deref());
|
||||||
|
|
||||||
|
let provider = cli_provider
|
||||||
|
.or(configured_provider)
|
||||||
|
.or_else(|| graph.attrs.get("default_provider").and_then(|v| v.as_str()))
|
||||||
|
.map(String::from);
|
||||||
|
|
||||||
|
let model = cli_model
|
||||||
|
.or(configured_model)
|
||||||
|
.or_else(|| graph.attrs.get("default_model").and_then(|v| v.as_str()))
|
||||||
|
.map(String::from)
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
let catalog = Catalog::builtin();
|
||||||
|
let info = provider
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|s| s.parse::<Provider>().ok())
|
||||||
|
.and_then(|p| catalog.default_for_provider(p))
|
||||||
|
.unwrap_or_else(|| catalog.default_from_env());
|
||||||
|
info.id.clone()
|
||||||
|
});
|
||||||
|
|
||||||
|
match Catalog::builtin().get(&model) {
|
||||||
|
Some(info) => (
|
||||||
|
info.id.clone(),
|
||||||
|
provider.or(Some(info.provider.to_string())),
|
||||||
|
),
|
||||||
|
None => (model, provider),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_sandbox_provider(settings: &FabroSettings) -> anyhow::Result<Option<SandboxProvider>> {
|
||||||
|
settings
|
||||||
|
.sandbox_settings()
|
||||||
|
.and_then(|s| s.provider.as_deref())
|
||||||
|
.map(|s| s.parse::<SandboxProvider>())
|
||||||
|
.transpose()
|
||||||
|
.map_err(|e| anyhow::anyhow!("Invalid sandbox provider: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_sandbox_provider(
|
||||||
|
cli: Option<SandboxProvider>,
|
||||||
|
settings: &FabroSettings,
|
||||||
|
) -> anyhow::Result<SandboxProvider> {
|
||||||
|
Ok(cli
|
||||||
|
.or(parse_sandbox_provider(settings)?)
|
||||||
|
.unwrap_or_default())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_daytona_config(
|
||||||
|
settings: &FabroSettings,
|
||||||
|
) -> Option<fabro_sandbox::daytona::DaytonaConfig> {
|
||||||
|
settings
|
||||||
|
.sandbox_settings()
|
||||||
|
.and_then(|sandbox| sandbox.daytona.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "exedev")]
|
||||||
|
fn resolve_exe_config(settings: &FabroSettings) -> Option<fabro_sandbox::exe::ExeConfig> {
|
||||||
|
settings
|
||||||
|
.sandbox_settings()
|
||||||
|
.and_then(|sandbox| sandbox.exe.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "exedev")]
|
||||||
|
fn resolve_exe_clone_params(cwd: &Path) -> Option<fabro_sandbox::exe::GitCloneParams> {
|
||||||
|
let (detected_url, branch) = match fabro_sandbox::daytona::detect_repo_info(cwd) {
|
||||||
|
Ok(info) => info,
|
||||||
|
Err(err) => {
|
||||||
|
tracing::warn!("No git repo detected for exe.dev clone: {err}");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let url = fabro_github::ssh_url_to_https(&detected_url);
|
||||||
|
Some(fabro_sandbox::exe::GitCloneParams { url, branch })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_ssh_config(settings: &FabroSettings) -> Option<fabro_sandbox::ssh::SshConfig> {
|
||||||
|
settings
|
||||||
|
.sandbox_settings()
|
||||||
|
.and_then(|sandbox| sandbox.ssh.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_ssh_clone_params(cwd: &Path) -> Option<fabro_sandbox::ssh::GitCloneParams> {
|
||||||
|
let (detected_url, branch) = match fabro_sandbox::daytona::detect_repo_info(cwd) {
|
||||||
|
Ok(info) => info,
|
||||||
|
Err(err) => {
|
||||||
|
tracing::warn!("No git repo detected for SSH clone: {err}");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let url = fabro_github::ssh_url_to_https(&detected_url);
|
||||||
|
Some(fabro_sandbox::ssh::GitCloneParams { url, branch })
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn mint_github_token(
|
||||||
|
creds: &fabro_github::GitHubAppCredentials,
|
||||||
|
origin_url: &str,
|
||||||
|
permissions: &std::collections::HashMap<String, String>,
|
||||||
|
) -> anyhow::Result<String> {
|
||||||
|
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| anyhow::anyhow!("{e}"))?;
|
||||||
|
let jwt = fabro_github::sign_app_jwt(&creds.app_id, &creds.private_key_pem)
|
||||||
|
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let perms_json = serde_json::to_value(permissions)?;
|
||||||
|
let token = fabro_github::create_installation_access_token_with_permissions(
|
||||||
|
&client,
|
||||||
|
&jwt,
|
||||||
|
&owner,
|
||||||
|
&repo,
|
||||||
|
fabro_github::GITHUB_API_BASE_URL,
|
||||||
|
perms_json,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||||
|
Ok(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
async fn run_preflight(
|
||||||
|
graph: &fabro_graphviz::graph::Graph,
|
||||||
|
settings: &FabroSettings,
|
||||||
|
cli_model: Option<&str>,
|
||||||
|
cli_provider: Option<&str>,
|
||||||
|
git_status: GitSyncStatus,
|
||||||
|
sandbox_provider: SandboxProvider,
|
||||||
|
working_directory: &Path,
|
||||||
|
styles: &'static Styles,
|
||||||
|
github_app: Option<fabro_github::GitHubAppCredentials>,
|
||||||
|
origin_url: Option<&str>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
use fabro_util::check_report::{
|
||||||
|
CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus,
|
||||||
|
};
|
||||||
|
|
||||||
|
let spinner = indicatif::ProgressBar::new_spinner();
|
||||||
|
spinner.set_style(
|
||||||
|
indicatif::ProgressStyle::with_template("{spinner:.cyan} {msg}")
|
||||||
|
.expect("valid template")
|
||||||
|
.tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", ""]),
|
||||||
|
);
|
||||||
|
spinner.set_message("Running preflight checks...");
|
||||||
|
spinner.enable_steady_tick(std::time::Duration::from_millis(80));
|
||||||
|
|
||||||
|
let mut checks: Vec<CheckResult> = Vec::new();
|
||||||
|
|
||||||
|
let setup_command_count = settings.setup_commands().len();
|
||||||
|
let repo_summary = origin_url
|
||||||
|
.map(|url| {
|
||||||
|
let https = fabro_github::ssh_url_to_https(url);
|
||||||
|
fabro_github::parse_github_owner_repo(&https)
|
||||||
|
.map(|(owner, repo)| format!("{owner}/{repo}"))
|
||||||
|
.unwrap_or_else(|_| url.to_string())
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "unknown".into());
|
||||||
|
|
||||||
|
checks.push(CheckResult {
|
||||||
|
name: "Repository".into(),
|
||||||
|
status: CheckStatus::Pass,
|
||||||
|
summary: repo_summary,
|
||||||
|
details: vec![
|
||||||
|
CheckDetail::new(format!("Setup commands: {setup_command_count}")),
|
||||||
|
CheckDetail {
|
||||||
|
text: format!("Git: {git_status}"),
|
||||||
|
warn: git_status != GitSyncStatus::Synced,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
remediation: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
let (model, provider) = resolve_model_provider(cli_model, cli_provider, settings, graph);
|
||||||
|
checks.push(CheckResult {
|
||||||
|
name: "Workflow".into(),
|
||||||
|
status: CheckStatus::Pass,
|
||||||
|
summary: graph.name.clone(),
|
||||||
|
details: vec![
|
||||||
|
CheckDetail::new(format!("Nodes: {}", graph.nodes.len())),
|
||||||
|
CheckDetail::new(format!("Edges: {}", graph.edges.len())),
|
||||||
|
CheckDetail::new(format!("Goal: {}", graph.goal())),
|
||||||
|
],
|
||||||
|
remediation: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
let daytona_config = resolve_daytona_config(settings);
|
||||||
|
#[cfg(feature = "exedev")]
|
||||||
|
let exe_config = resolve_exe_config(settings);
|
||||||
|
let ssh_config = resolve_ssh_config(settings);
|
||||||
|
|
||||||
|
let sandbox_result: Result<Arc<dyn Sandbox>, String> = match sandbox_provider {
|
||||||
|
SandboxProvider::Docker => {
|
||||||
|
let config = DockerSandboxConfig {
|
||||||
|
host_working_directory: working_directory.to_string_lossy().to_string(),
|
||||||
|
..DockerSandboxConfig::default()
|
||||||
|
};
|
||||||
|
DockerSandbox::new(config)
|
||||||
|
.map(|env| Arc::new(env) as Arc<dyn Sandbox>)
|
||||||
|
.map_err(|e| format!("Docker sandbox creation failed: {e}"))
|
||||||
|
}
|
||||||
|
SandboxProvider::Daytona => {
|
||||||
|
let config = daytona_config.unwrap_or_default();
|
||||||
|
match fabro_sandbox::daytona::DaytonaSandbox::new(
|
||||||
|
config,
|
||||||
|
github_app.clone(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(env) => Ok(Arc::new(env) as Arc<dyn Sandbox>),
|
||||||
|
Err(e) => Err(format!("Daytona sandbox creation failed: {e}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(feature = "exedev")]
|
||||||
|
SandboxProvider::Exe => {
|
||||||
|
match fabro_sandbox::exe::OpensshRunner::connect_raw("exe.dev").await {
|
||||||
|
Ok(mgmt_ssh) => {
|
||||||
|
let config = exe_config.unwrap_or_default();
|
||||||
|
let clone_params = resolve_exe_clone_params(working_directory);
|
||||||
|
let env = fabro_sandbox::exe::ExeSandbox::new(
|
||||||
|
Box::new(mgmt_ssh),
|
||||||
|
config,
|
||||||
|
clone_params,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
Ok(Arc::new(env) as Arc<dyn Sandbox>)
|
||||||
|
}
|
||||||
|
Err(e) => Err(format!("exe.dev SSH connection failed: {e}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "exedev"))]
|
||||||
|
SandboxProvider::Exe => Err("exe sandbox requires the exedev feature".to_string()),
|
||||||
|
SandboxProvider::Ssh => match ssh_config {
|
||||||
|
Some(config) => {
|
||||||
|
let clone_params = resolve_ssh_clone_params(working_directory);
|
||||||
|
let env = fabro_sandbox::ssh::SshSandbox::new(config, clone_params, None, None);
|
||||||
|
Ok(Arc::new(env) as Arc<dyn Sandbox>)
|
||||||
|
}
|
||||||
|
None => Err("SSH sandbox requires [sandbox.ssh] config".to_string()),
|
||||||
|
},
|
||||||
|
SandboxProvider::Local => {
|
||||||
|
Ok(Arc::new(LocalSandbox::new(working_directory.to_path_buf())) as Arc<dyn Sandbox>)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let sandbox_ok = match sandbox_result {
|
||||||
|
Ok(sandbox) => match sandbox.initialize().await {
|
||||||
|
Ok(()) => {
|
||||||
|
let _ = sandbox.cleanup().await;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let _ = sandbox.cleanup().await;
|
||||||
|
checks.push(CheckResult {
|
||||||
|
name: "Sandbox".into(),
|
||||||
|
status: CheckStatus::Error,
|
||||||
|
summary: "failed".into(),
|
||||||
|
details: vec![CheckDetail::new(format!("Provider: {sandbox_provider}"))],
|
||||||
|
remediation: Some(format!("Sandbox init failed: {e}")),
|
||||||
|
});
|
||||||
|
false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
checks.push(CheckResult {
|
||||||
|
name: "Sandbox".into(),
|
||||||
|
status: CheckStatus::Error,
|
||||||
|
summary: "failed".into(),
|
||||||
|
details: vec![CheckDetail::new(format!("Provider: {sandbox_provider}"))],
|
||||||
|
remediation: Some(e),
|
||||||
|
});
|
||||||
|
false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if sandbox_ok {
|
||||||
|
checks.push(CheckResult {
|
||||||
|
name: "Sandbox".into(),
|
||||||
|
status: CheckStatus::Pass,
|
||||||
|
summary: sandbox_provider.to_string(),
|
||||||
|
details: vec![CheckDetail::new(format!("Provider: {sandbox_provider}"))],
|
||||||
|
remediation: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let default_provider = provider.as_deref().unwrap_or("anthropic");
|
||||||
|
let llm_ok = match fabro_llm::client::Client::from_env().await {
|
||||||
|
Ok(c) => {
|
||||||
|
let configured: Vec<String> =
|
||||||
|
c.provider_names().iter().map(|s| s.to_string()).collect();
|
||||||
|
|
||||||
|
let mut model_providers = std::collections::BTreeSet::new();
|
||||||
|
for node in graph.nodes.values() {
|
||||||
|
if !fabro_graphviz::graph::is_llm_handler_type(node.handler_type()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let node_model = node.model().unwrap_or(&model);
|
||||||
|
let node_provider = node.provider().unwrap_or(default_provider);
|
||||||
|
|
||||||
|
let (resolved_model, resolved_provider) =
|
||||||
|
if let Some(info) = Catalog::builtin().get(node_model) {
|
||||||
|
(info.id.clone(), info.provider.to_string())
|
||||||
|
} else {
|
||||||
|
(node_model.to_string(), node_provider.to_string())
|
||||||
|
};
|
||||||
|
|
||||||
|
let final_provider = if node.provider().is_some() {
|
||||||
|
node_provider.to_string()
|
||||||
|
} else {
|
||||||
|
resolved_provider
|
||||||
|
};
|
||||||
|
|
||||||
|
model_providers.insert((resolved_model, final_provider));
|
||||||
|
}
|
||||||
|
|
||||||
|
if model_providers.is_empty() {
|
||||||
|
let (resolved_model, resolved_provider) =
|
||||||
|
if let Some(info) = Catalog::builtin().get(&model) {
|
||||||
|
(info.id.clone(), info.provider.to_string())
|
||||||
|
} else {
|
||||||
|
(model.clone(), default_provider.to_string())
|
||||||
|
};
|
||||||
|
model_providers.insert((resolved_model, resolved_provider));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut all_ok = true;
|
||||||
|
for (model_id, provider_name) in &model_providers {
|
||||||
|
match provider_name.parse::<Provider>() {
|
||||||
|
Ok(_) => {
|
||||||
|
let mut status = CheckStatus::Pass;
|
||||||
|
if !configured.iter().any(|n| n == provider_name) {
|
||||||
|
status = CheckStatus::Warning;
|
||||||
|
all_ok = false;
|
||||||
|
}
|
||||||
|
checks.push(CheckResult {
|
||||||
|
name: "LLM".into(),
|
||||||
|
status,
|
||||||
|
summary: model_id.clone(),
|
||||||
|
details: vec![CheckDetail::new(format!("Provider: {provider_name}"))],
|
||||||
|
remediation: if status == CheckStatus::Warning {
|
||||||
|
Some(format!("Provider \"{provider_name}\" is not configured"))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
checks.push(CheckResult {
|
||||||
|
name: "LLM".into(),
|
||||||
|
status: CheckStatus::Error,
|
||||||
|
summary: model_id.clone(),
|
||||||
|
details: vec![CheckDetail::new(format!("Provider: {provider_name}"))],
|
||||||
|
remediation: Some(format!("Invalid provider \"{provider_name}\": {e}")),
|
||||||
|
});
|
||||||
|
all_ok = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
all_ok
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
checks.push(CheckResult {
|
||||||
|
name: "LLM".into(),
|
||||||
|
status: CheckStatus::Error,
|
||||||
|
summary: "initialization failed".into(),
|
||||||
|
details: vec![],
|
||||||
|
remediation: Some(format!("LLM client init failed: {e}")),
|
||||||
|
});
|
||||||
|
false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(github_permissions) = settings.github_permissions() {
|
||||||
|
if !github_permissions.is_empty() {
|
||||||
|
let perm_details: Vec<CheckDetail> = github_permissions
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| CheckDetail::new(format!("{k}: {v}")))
|
||||||
|
.collect();
|
||||||
|
match (&github_app, origin_url) {
|
||||||
|
(Some(creds), Some(url)) => {
|
||||||
|
match mint_github_token(creds, url, github_permissions).await {
|
||||||
|
Ok(_) => {
|
||||||
|
checks.push(CheckResult {
|
||||||
|
name: "GitHub Token".into(),
|
||||||
|
status: CheckStatus::Pass,
|
||||||
|
summary: "minted".into(),
|
||||||
|
details: perm_details,
|
||||||
|
remediation: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
checks.push(CheckResult {
|
||||||
|
name: "GitHub Token".into(),
|
||||||
|
status: CheckStatus::Error,
|
||||||
|
summary: "failed".into(),
|
||||||
|
details: perm_details,
|
||||||
|
remediation: Some(format!("Failed to mint GitHub token: {e}")),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
checks.push(CheckResult {
|
||||||
|
name: "GitHub Token".into(),
|
||||||
|
status: CheckStatus::Warning,
|
||||||
|
summary: "skipped".into(),
|
||||||
|
details: vec![],
|
||||||
|
remediation: Some(
|
||||||
|
"No GitHub App credentials or origin URL available".to_string(),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
spinner.finish_and_clear();
|
||||||
|
|
||||||
|
let report = CheckReport {
|
||||||
|
title: "Run Preflight".into(),
|
||||||
|
sections: vec![CheckSection {
|
||||||
|
title: String::new(),
|
||||||
|
checks,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
let term_width = console::Term::stderr().size().1;
|
||||||
|
print!("{}", report.render(styles, true, None, Some(term_width)));
|
||||||
|
|
||||||
|
if sandbox_ok && llm_ok {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,8 @@ use anyhow::{bail, Result};
|
||||||
|
|
||||||
use fabro_interview::{AnswerValue, ConsoleInterviewer};
|
use fabro_interview::{AnswerValue, ConsoleInterviewer};
|
||||||
use fabro_util::terminal::Styles;
|
use fabro_util::terminal::Styles;
|
||||||
use fabro_workflows::event::RunNoticeLevel;
|
|
||||||
use fabro_workflows::run_status::{RunStatus, RunStatusRecord};
|
use fabro_workflows::run_status::{RunStatus, RunStatusRecord};
|
||||||
|
|
||||||
use super::detached::append_run_notice;
|
|
||||||
use super::run_progress;
|
use super::run_progress;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
@ -36,7 +34,6 @@ pub async fn attach_run(
|
||||||
let status_path = run_dir.join("status.json");
|
let status_path = run_dir.join("status.json");
|
||||||
let interview_request_path = run_dir.join("interview_request.json");
|
let interview_request_path = run_dir.join("interview_request.json");
|
||||||
let interview_response_path = run_dir.join("interview_response.json");
|
let interview_response_path = run_dir.join("interview_response.json");
|
||||||
let pid_path = run_dir.join("run.pid");
|
|
||||||
|
|
||||||
let mut engine_guard = engine_child.map(EngineChildGuard::new);
|
let mut engine_guard = engine_child.map(EngineChildGuard::new);
|
||||||
|
|
||||||
|
|
@ -86,6 +83,16 @@ pub async fn attach_run(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(pid) = read_launcher_pid(run_dir) {
|
||||||
|
if !process_alive(pid) && wait_count > 5 {
|
||||||
|
progress_ui.finish();
|
||||||
|
return Ok(determine_exit_code(
|
||||||
|
&conclusion_path,
|
||||||
|
read_status_record(&status_path),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if wait_count > 100 {
|
if wait_count > 100 {
|
||||||
// Guard's Drop kills+waits on the engine child
|
// Guard's Drop kills+waits on the engine child
|
||||||
drop(engine_guard.take());
|
drop(engine_guard.take());
|
||||||
|
|
@ -114,8 +121,13 @@ pub async fn attach_run(
|
||||||
loop {
|
loop {
|
||||||
if cancelled.load(Ordering::Relaxed) {
|
if cancelled.load(Ordering::Relaxed) {
|
||||||
if kill_on_detach {
|
if kill_on_detach {
|
||||||
// Kill the engine process
|
if let Some(guard) = engine_guard.as_mut() {
|
||||||
kill_engine(&pid_path);
|
if let Some(child) = guard.inner() {
|
||||||
|
let _ = child.kill();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
kill_engine(run_dir);
|
||||||
|
}
|
||||||
// Wait briefly for a terminal status or conclusion
|
// Wait briefly for a terminal status or conclusion
|
||||||
for _ in 0..20 {
|
for _ in 0..20 {
|
||||||
if conclusion_path.exists()
|
if conclusion_path.exists()
|
||||||
|
|
@ -168,12 +180,6 @@ pub async fn attach_run(
|
||||||
progress_ui.show_bars();
|
progress_ui.show_bars();
|
||||||
|
|
||||||
if answer_requires_reattach(&answer) {
|
if answer_requires_reattach(&answer) {
|
||||||
let _ = append_run_notice(
|
|
||||||
run_dir,
|
|
||||||
RunNoticeLevel::Warn,
|
|
||||||
"interview_unanswered",
|
|
||||||
INTERVIEW_UNANSWERED_MESSAGE,
|
|
||||||
);
|
|
||||||
if let Some(guard) = engine_guard.as_mut() {
|
if let Some(guard) = engine_guard.as_mut() {
|
||||||
guard.defuse();
|
guard.defuse();
|
||||||
}
|
}
|
||||||
|
|
@ -213,7 +219,7 @@ pub async fn attach_run(
|
||||||
let engine_alive = match cached_pid {
|
let engine_alive = match cached_pid {
|
||||||
Some(pid) => process_alive(pid),
|
Some(pid) => process_alive(pid),
|
||||||
None => {
|
None => {
|
||||||
if let Some(pid) = read_pid(&pid_path) {
|
if let Some(pid) = read_launcher_pid(run_dir) {
|
||||||
cached_pid = Some(pid);
|
cached_pid = Some(pid);
|
||||||
process_alive(pid)
|
process_alive(pid)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -264,10 +270,14 @@ fn read_status_record(path: &Path) -> Option<RunStatusRecord> {
|
||||||
RunStatusRecord::load(path).ok()
|
RunStatusRecord::load(path).ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_pid(pid_path: &Path) -> Option<u32> {
|
fn read_launcher_pid(run_dir: &Path) -> Option<u32> {
|
||||||
std::fs::read_to_string(pid_path)
|
super::launcher::launcher_record_for_run(run_dir)
|
||||||
|
.map(|record| record.pid)
|
||||||
|
.or_else(|| {
|
||||||
|
std::fs::read_to_string(run_dir.join("run.pid"))
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|pid| pid.trim().parse::<u32>().ok())
|
.and_then(|pid| pid.trim().parse::<u32>().ok())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn progress_file_is_empty(path: &Path) -> bool {
|
fn progress_file_is_empty(path: &Path) -> bool {
|
||||||
|
|
@ -390,9 +400,8 @@ fn determine_exit_code(conclusion_path: &Path, status_record: Option<RunStatusRe
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn kill_engine(pid_path: &Path) {
|
fn kill_engine(run_dir: &Path) {
|
||||||
if let Ok(pid_str) = std::fs::read_to_string(pid_path) {
|
if let Some(pid) = read_launcher_pid(run_dir).map(|pid| pid as i32) {
|
||||||
if let Ok(pid) = pid_str.trim().parse::<i32>() {
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
unsafe {
|
unsafe {
|
||||||
libc::kill(pid, libc::SIGTERM);
|
libc::kill(pid, libc::SIGTERM);
|
||||||
|
|
@ -400,7 +409,6 @@ fn kill_engine(pid_path: &Path) {
|
||||||
let _ = pid;
|
let _ = pid;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fn process_alive(pid: u32) -> bool {
|
fn process_alive(pid: u32) -> bool {
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
|
|
|
||||||
35
lib/crates/fabro-cli/src/commands/run/command.rs
Normal file
35
lib/crates/fabro-cli/src/commands/run/command.rs
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
use crate::args::{GlobalArgs, RunArgs};
|
||||||
|
|
||||||
|
pub async fn execute(mut args: RunArgs, _globals: &GlobalArgs) -> Result<()> {
|
||||||
|
let styles: &'static fabro_util::terminal::Styles =
|
||||||
|
Box::leak(Box::new(fabro_util::terminal::Styles::detect_stderr()));
|
||||||
|
let cli_defaults = fabro_config::cli::load_cli_config(None)?;
|
||||||
|
let cli_config: fabro_config::FabroSettings = cli_defaults.clone().try_into()?;
|
||||||
|
args.verbose = args.verbose || cli_config.verbose_enabled();
|
||||||
|
|
||||||
|
let quiet = args.detach;
|
||||||
|
let prevent_idle_sleep = cli_config.prevent_idle_sleep_enabled();
|
||||||
|
let (run_id, run_dir) = super::create::create_run(&args, cli_defaults, styles, quiet).await?;
|
||||||
|
|
||||||
|
#[cfg(feature = "sleep_inhibitor")]
|
||||||
|
let _sleep_guard = crate::sleep_inhibitor::guard(prevent_idle_sleep);
|
||||||
|
|
||||||
|
#[cfg(not(feature = "sleep_inhibitor"))]
|
||||||
|
let _ = prevent_idle_sleep;
|
||||||
|
|
||||||
|
let child = super::start::start_run(&run_dir, false)?;
|
||||||
|
|
||||||
|
if args.detach {
|
||||||
|
println!("{run_id}");
|
||||||
|
} else {
|
||||||
|
let exit_code = super::attach::attach_run(&run_dir, true, styles, Some(child)).await?;
|
||||||
|
super::output::print_run_summary(&run_dir, &run_id, styles);
|
||||||
|
if exit_code != std::process::ExitCode::SUCCESS {
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
@ -1,15 +1,12 @@
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use crate::args::RunArgs;
|
use crate::args::RunArgs;
|
||||||
use fabro_config::FabroConfig;
|
use fabro_config::{FabroConfig, FabroSettings};
|
||||||
|
|
||||||
use super::execute::{
|
|
||||||
cached_graph_path, default_run_dir, load_workflow_source_input, make_run_dir, parse_labels,
|
|
||||||
print_diagnostics_from_error, print_workflow_report_from_persisted, resolve_sandbox_provider,
|
|
||||||
write_run_config_snapshot,
|
|
||||||
};
|
|
||||||
use fabro_util::terminal::Styles;
|
use fabro_util::terminal::Styles;
|
||||||
|
|
||||||
|
use super::output::{print_diagnostics_from_error, print_workflow_report_from_persisted};
|
||||||
|
|
||||||
/// Create a workflow run: allocate run directory, persist RunRecord, return (run_id, run_dir).
|
/// Create a workflow run: allocate run directory, persist RunRecord, return (run_id, run_dir).
|
||||||
///
|
///
|
||||||
/// This does NOT execute the workflow — it only prepares the run directory.
|
/// This does NOT execute the workflow — it only prepares the run directory.
|
||||||
|
|
@ -24,56 +21,27 @@ pub async fn create_run(
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| anyhow::anyhow!("--workflow is required"))?;
|
.ok_or_else(|| anyhow::anyhow!("--workflow is required"))?;
|
||||||
let cli_args_config = FabroConfig::try_from(args)?;
|
let cli_args_config = FabroConfig::try_from(args)?;
|
||||||
let source_input =
|
let settings: FabroSettings = fabro_workflows::operations::resolve_settings_for_path(
|
||||||
load_workflow_source_input(workflow_path, cli_args_config, cli_defaults, true)?;
|
workflow_path,
|
||||||
let run_id = args
|
cli_defaults,
|
||||||
.run_id
|
cli_args_config,
|
||||||
.clone()
|
true,
|
||||||
.unwrap_or_else(|| ulid::Ulid::new().to_string());
|
)?;
|
||||||
let run_dir = match args
|
|
||||||
.storage_dir
|
|
||||||
.clone()
|
|
||||||
.or_else(|| source_input.settings.storage_dir.clone())
|
|
||||||
{
|
|
||||||
Some(sd) => make_run_dir(&sd.join("runs"), &run_id, args.dry_run),
|
|
||||||
None => default_run_dir(&run_id, args.dry_run),
|
|
||||||
};
|
|
||||||
let working_directory = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
let working_directory = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
||||||
let base_branch = fabro_sandbox::daytona::detect_repo_info(&working_directory)
|
let base_branch = fabro_sandbox::daytona::detect_repo_info(&working_directory)
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|(_, branch)| branch);
|
.and_then(|(_, branch)| branch);
|
||||||
if !args.dry_run {
|
|
||||||
let _ = resolve_sandbox_provider(args.sandbox.map(Into::into), &source_input.settings)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let settings = source_input.settings.clone();
|
let created =
|
||||||
|
match fabro_workflows::operations::create(fabro_workflows::operations::CreateRequest {
|
||||||
let persisted = match fabro_workflows::operations::create(
|
workflow: fabro_workflows::operations::WorkflowInput::Path(workflow_path.clone()),
|
||||||
&source_input.raw_source,
|
|
||||||
fabro_workflows::operations::RunCreateOptions {
|
|
||||||
settings,
|
settings,
|
||||||
run_dir: Some(run_dir.clone()),
|
run_dir: None,
|
||||||
run_id: Some(run_id.clone()),
|
run_id: args.run_id.clone(),
|
||||||
workflow_slug: source_input.workflow_slug.clone(),
|
|
||||||
labels: {
|
|
||||||
let mut labels = source_input.settings.labels.clone();
|
|
||||||
labels.extend(parse_labels(&args.label));
|
|
||||||
labels
|
|
||||||
},
|
|
||||||
base_branch,
|
base_branch,
|
||||||
working_directory: Some(working_directory.clone()),
|
|
||||||
host_repo_path: Some(working_directory.to_string_lossy().to_string()),
|
host_repo_path: Some(working_directory.to_string_lossy().to_string()),
|
||||||
goal_override: source_input.goal_override.clone(),
|
}) {
|
||||||
base_dir: Some(
|
Ok(created) => created,
|
||||||
source_input
|
|
||||||
.dot_path
|
|
||||||
.parent()
|
|
||||||
.unwrap_or(std::path::Path::new("."))
|
|
||||||
.to_path_buf(),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Ok(persisted) => persisted,
|
|
||||||
Err(fabro_workflows::error::FabroError::ValidationFailed { diagnostics }) => {
|
Err(fabro_workflows::error::FabroError::ValidationFailed { diagnostics }) => {
|
||||||
if !quiet {
|
if !quiet {
|
||||||
print_diagnostics_from_error(&diagnostics, styles);
|
print_diagnostics_from_error(&diagnostics, styles);
|
||||||
|
|
@ -84,19 +52,12 @@ pub async fn create_run(
|
||||||
};
|
};
|
||||||
|
|
||||||
if !quiet {
|
if !quiet {
|
||||||
print_workflow_report_from_persisted(&persisted, &source_input.dot_path, styles);
|
print_workflow_report_from_persisted(
|
||||||
}
|
&created.persisted,
|
||||||
|
created.dot_path.as_deref(),
|
||||||
// Write CLI-owned debug and status artifacts after the run has been persisted.
|
styles,
|
||||||
tokio::fs::write(cached_graph_path(&run_dir), &source_input.raw_source).await?;
|
|
||||||
tokio::fs::write(run_dir.join("id.txt"), &run_id).await?;
|
|
||||||
std::fs::File::create(run_dir.join("progress.jsonl"))?;
|
|
||||||
fabro_workflows::run_status::write_run_status(
|
|
||||||
&run_dir,
|
|
||||||
fabro_workflows::run_status::RunStatus::Submitted,
|
|
||||||
None,
|
|
||||||
);
|
);
|
||||||
write_run_config_snapshot(&run_dir, source_input.workflow_toml_path.as_deref()).await?;
|
}
|
||||||
|
|
||||||
Ok((run_id, run_dir))
|
Ok((created.run_id, created.run_dir))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,14 @@
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::Result;
|
||||||
use chrono::Utc;
|
use fabro_interview::FileInterviewer;
|
||||||
use fabro_workflows::event::{RunNoticeLevel, WorkflowRunEvent};
|
use fabro_workflows::event::EventEmitter;
|
||||||
use fabro_workflows::outcome::StageStatus;
|
|
||||||
use fabro_workflows::records::Conclusion;
|
|
||||||
use fabro_workflows::run_status::{self, RunStatus, StatusReason};
|
|
||||||
use serde::Serialize;
|
|
||||||
|
|
||||||
use crate::cli_config;
|
use crate::cli_config;
|
||||||
use crate::shared;
|
use crate::shared;
|
||||||
|
|
||||||
pub async fn execute(storage_dir: PathBuf, run_id: String, resume: bool) -> Result<()> {
|
pub async fn execute(run_dir: PathBuf, launcher_path: PathBuf, resume: bool) -> Result<()> {
|
||||||
let runs_base = fabro_workflows::run_lookup::runs_base(&storage_dir);
|
|
||||||
let run_dir = fabro_workflows::run_lookup::find_run_by_prefix(&runs_base, &run_id)?;
|
|
||||||
let styles: &'static fabro_util::terminal::Styles =
|
|
||||||
Box::leak(Box::new(fabro_util::terminal::Styles::detect_stderr()));
|
|
||||||
let cli_config = cli_config::load_cli_settings(None)?;
|
let cli_config = cli_config::load_cli_settings(None)?;
|
||||||
let github_app = shared::github::build_github_app_credentials(cli_config.app_id());
|
let github_app = shared::github::build_github_app_credentials(cli_config.app_id());
|
||||||
let git_author = fabro_workflows::git::GitAuthor::from_options(
|
let git_author = fabro_workflows::git::GitAuthor::from_options(
|
||||||
|
|
@ -23,370 +16,24 @@ pub async fn execute(storage_dir: PathBuf, run_id: String, resume: bool) -> Resu
|
||||||
cli_config.git_author().and_then(|a| a.email.clone()),
|
cli_config.git_author().and_then(|a| a.email.clone()),
|
||||||
);
|
);
|
||||||
|
|
||||||
let persisted = match fabro_workflows::pipeline::Persisted::load(&run_dir) {
|
let _launcher_guard = scopeguard::guard(launcher_path.clone(), |path| {
|
||||||
Ok(persisted) => persisted,
|
super::launcher::remove_launcher_record(&path);
|
||||||
Err(err) => {
|
});
|
||||||
let anyhow_err: anyhow::Error = anyhow::anyhow!("Failed to load persisted run: {err}");
|
|
||||||
let _ = persist_detached_failure(
|
|
||||||
&run_dir,
|
|
||||||
"bootstrap",
|
|
||||||
StatusReason::BootstrapFailed,
|
|
||||||
&anyhow_err,
|
|
||||||
);
|
|
||||||
return Err(anyhow_err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Err(err) =
|
let services = fabro_workflows::operations::StartServices {
|
||||||
std::env::set_current_dir(&persisted.run_record().working_directory).map_err(|e| {
|
cancel_token: None,
|
||||||
anyhow::anyhow!(
|
emitter: Arc::new(EventEmitter::new()),
|
||||||
"Failed to set working directory to {}: {e}",
|
interviewer: Arc::new(FileInterviewer::new(run_dir.clone())),
|
||||||
persisted.run_record().working_directory.display()
|
|
||||||
)
|
|
||||||
})
|
|
||||||
{
|
|
||||||
let _ =
|
|
||||||
persist_detached_failure(&run_dir, "bootstrap", StatusReason::BootstrapFailed, &err);
|
|
||||||
return Err(err);
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = if resume {
|
|
||||||
super::execute::resume_from_record(
|
|
||||||
persisted,
|
|
||||||
run_dir.clone(),
|
|
||||||
styles,
|
|
||||||
github_app,
|
|
||||||
git_author,
|
git_author,
|
||||||
)
|
github_app,
|
||||||
.await
|
registry_override: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
if resume {
|
||||||
|
let _ = fabro_workflows::operations::resume(&run_dir, services).await?;
|
||||||
} else {
|
} else {
|
||||||
super::execute::run_from_record(persisted, run_dir.clone(), styles, github_app, git_author)
|
let _ = fabro_workflows::operations::start(&run_dir, services).await?;
|
||||||
.await
|
|
||||||
};
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(()) => Ok(()),
|
|
||||||
Err(err) => {
|
|
||||||
let _ = persist_detached_failure(
|
|
||||||
&run_dir,
|
|
||||||
"bootstrap",
|
|
||||||
StatusReason::SandboxInitFailed,
|
|
||||||
&err,
|
|
||||||
);
|
|
||||||
Err(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const POSTRUN_ABORTED_MESSAGE: &str = "Run aborted before post-run finalization completed.";
|
|
||||||
|
|
||||||
pub(crate) struct DetachedRunBootstrapGuard {
|
|
||||||
run_dir: PathBuf,
|
|
||||||
active: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DetachedRunBootstrapGuard {
|
|
||||||
pub(crate) fn arm(run_dir: &Path) -> Result<Self> {
|
|
||||||
std::fs::write(run_dir.join("run.pid"), std::process::id().to_string())
|
|
||||||
.with_context(|| format!("Failed to write {}", run_dir.join("run.pid").display()))?;
|
|
||||||
run_status::write_run_status(
|
|
||||||
run_dir,
|
|
||||||
RunStatus::Starting,
|
|
||||||
Some(StatusReason::SandboxInitializing),
|
|
||||||
);
|
|
||||||
Ok(Self {
|
|
||||||
run_dir: run_dir.to_path_buf(),
|
|
||||||
active: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn defuse(&mut self) {
|
|
||||||
self.active = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for DetachedRunBootstrapGuard {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
if self.active {
|
|
||||||
run_status::write_run_status(
|
|
||||||
&self.run_dir,
|
|
||||||
RunStatus::Failed,
|
|
||||||
Some(StatusReason::SandboxInitFailed),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) struct DetachedRunCompletionGuard {
|
|
||||||
run_dir: PathBuf,
|
|
||||||
active: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DetachedRunCompletionGuard {
|
|
||||||
pub(crate) fn arm(run_dir: &Path) -> Self {
|
|
||||||
Self {
|
|
||||||
run_dir: run_dir.to_path_buf(),
|
|
||||||
active: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn defuse(&mut self) {
|
|
||||||
self.active = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for DetachedRunCompletionGuard {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
if !self.active {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
run_status::write_run_status(
|
|
||||||
&self.run_dir,
|
|
||||||
RunStatus::Failed,
|
|
||||||
Some(StatusReason::WorkflowError),
|
|
||||||
);
|
|
||||||
if !self.run_dir.join("conclusion.json").exists() {
|
|
||||||
let _ = write_failure_conclusion(
|
|
||||||
&self.run_dir,
|
|
||||||
POSTRUN_ABORTED_MESSAGE,
|
|
||||||
Some(StatusReason::WorkflowError),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if let Some(run_id) = load_run_id(&self.run_dir) {
|
|
||||||
let _ = append_progress_event(
|
|
||||||
&self.run_dir,
|
|
||||||
&run_id,
|
|
||||||
&WorkflowRunEvent::RunNotice {
|
|
||||||
level: RunNoticeLevel::Error,
|
|
||||||
code: "postrun_aborted".to_string(),
|
|
||||||
message: POSTRUN_ABORTED_MESSAGE.to_string(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn load_run_id(run_dir: &Path) -> Option<String> {
|
|
||||||
fabro_workflows::records::RunRecord::load(run_dir)
|
|
||||||
.ok()
|
|
||||||
.map(|record| record.run_id)
|
|
||||||
.filter(|run_id| !run_id.trim().is_empty())
|
|
||||||
.or_else(|| {
|
|
||||||
std::fs::read_to_string(run_dir.join("id.txt"))
|
|
||||||
.ok()
|
|
||||||
.map(|run_id| run_id.trim().to_string())
|
|
||||||
.filter(|run_id| !run_id.is_empty())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn append_progress_event(
|
|
||||||
run_dir: &Path,
|
|
||||||
run_id: &str,
|
|
||||||
event: &WorkflowRunEvent,
|
|
||||||
) -> Result<()> {
|
|
||||||
fabro_workflows::event::append_progress_event(run_dir, run_id, event)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn append_run_notice(
|
|
||||||
run_dir: &Path,
|
|
||||||
level: RunNoticeLevel,
|
|
||||||
code: &'static str,
|
|
||||||
message: impl Into<String>,
|
|
||||||
) -> Result<()> {
|
|
||||||
let Some(run_id) = load_run_id(run_dir) else {
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
append_progress_event(
|
|
||||||
run_dir,
|
|
||||||
&run_id,
|
|
||||||
&WorkflowRunEvent::RunNotice {
|
|
||||||
level,
|
|
||||||
code: code.to_string(),
|
|
||||||
message: message.into(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn persist_detached_failure(
|
|
||||||
run_dir: &Path,
|
|
||||||
phase: &'static str,
|
|
||||||
reason: StatusReason,
|
|
||||||
error: &anyhow::Error,
|
|
||||||
) -> Result<()> {
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct DetachedFailureRecord<'a> {
|
|
||||||
timestamp: chrono::DateTime<Utc>,
|
|
||||||
phase: &'a str,
|
|
||||||
reason: StatusReason,
|
|
||||||
error: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
let message = error.to_string();
|
|
||||||
let record = DetachedFailureRecord {
|
|
||||||
timestamp: Utc::now(),
|
|
||||||
phase,
|
|
||||||
reason,
|
|
||||||
error: message.clone(),
|
|
||||||
};
|
|
||||||
std::fs::write(
|
|
||||||
run_dir.join("detached_failure.json"),
|
|
||||||
serde_json::to_string_pretty(&record)?,
|
|
||||||
)
|
|
||||||
.with_context(|| {
|
|
||||||
format!(
|
|
||||||
"Failed to write {}",
|
|
||||||
run_dir.join("detached_failure.json").display()
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
write_failure_conclusion(run_dir, &message, Some(reason))?;
|
|
||||||
run_status::write_run_status(run_dir, RunStatus::Failed, Some(reason));
|
|
||||||
|
|
||||||
if let Some(run_id) = load_run_id(run_dir) {
|
|
||||||
append_progress_event(
|
|
||||||
run_dir,
|
|
||||||
&run_id,
|
|
||||||
&WorkflowRunEvent::RunNotice {
|
|
||||||
level: RunNoticeLevel::Error,
|
|
||||||
code: format!("{phase}_failed"),
|
|
||||||
message,
|
|
||||||
},
|
|
||||||
)?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn write_failure_conclusion(
|
|
||||||
run_dir: &Path,
|
|
||||||
message: &str,
|
|
||||||
_reason: Option<StatusReason>,
|
|
||||||
) -> Result<()> {
|
|
||||||
if run_dir.join("conclusion.json").exists() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let conclusion = Conclusion {
|
|
||||||
timestamp: Utc::now(),
|
|
||||||
status: StageStatus::Fail,
|
|
||||||
duration_ms: 0,
|
|
||||||
failure_reason: Some(message.to_string()),
|
|
||||||
final_git_commit_sha: None,
|
|
||||||
stages: vec![],
|
|
||||||
total_cost: None,
|
|
||||||
total_retries: 0,
|
|
||||||
total_input_tokens: 0,
|
|
||||||
total_output_tokens: 0,
|
|
||||||
total_cache_read_tokens: 0,
|
|
||||||
total_cache_write_tokens: 0,
|
|
||||||
total_reasoning_tokens: 0,
|
|
||||||
has_pricing: false,
|
|
||||||
};
|
|
||||||
conclusion.save(&run_dir.join("conclusion.json"))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use fabro_workflows::run_status::{RunStatusRecord, StatusReason};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn bootstrap_guard_marks_failed_on_drop() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
|
|
||||||
{
|
|
||||||
let _guard = DetachedRunBootstrapGuard::arm(dir.path()).unwrap();
|
|
||||||
let record = RunStatusRecord::load(&dir.path().join("status.json")).unwrap();
|
|
||||||
assert_eq!(record.status, RunStatus::Starting);
|
|
||||||
assert_eq!(record.reason, Some(StatusReason::SandboxInitializing));
|
|
||||||
}
|
|
||||||
|
|
||||||
let record = RunStatusRecord::load(&dir.path().join("status.json")).unwrap();
|
|
||||||
assert_eq!(record.status, RunStatus::Failed);
|
|
||||||
assert_eq!(record.reason, Some(StatusReason::SandboxInitFailed));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn bootstrap_guard_defuse_leaves_starting_intact() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
|
|
||||||
{
|
|
||||||
let mut guard = DetachedRunBootstrapGuard::arm(dir.path()).unwrap();
|
|
||||||
guard.defuse();
|
|
||||||
}
|
|
||||||
|
|
||||||
let record = RunStatusRecord::load(&dir.path().join("status.json")).unwrap();
|
|
||||||
assert_eq!(record.status, RunStatus::Starting);
|
|
||||||
assert_eq!(record.reason, Some(StatusReason::SandboxInitializing));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn completion_guard_marks_failed_on_drop() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
std::fs::write(dir.path().join("id.txt"), "run-123").unwrap();
|
|
||||||
|
|
||||||
{
|
|
||||||
let _guard = DetachedRunCompletionGuard::arm(dir.path());
|
|
||||||
}
|
|
||||||
|
|
||||||
let record = RunStatusRecord::load(&dir.path().join("status.json")).unwrap();
|
|
||||||
assert_eq!(record.status, RunStatus::Failed);
|
|
||||||
assert_eq!(record.reason, Some(StatusReason::WorkflowError));
|
|
||||||
assert!(dir.path().join("conclusion.json").exists());
|
|
||||||
let progress = std::fs::read_to_string(dir.path().join("progress.jsonl")).unwrap();
|
|
||||||
assert!(progress.contains("postrun_aborted"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn load_run_id_falls_back_to_id_txt() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
std::fs::write(dir.path().join("id.txt"), "run-xyz").unwrap();
|
|
||||||
|
|
||||||
assert_eq!(load_run_id(dir.path()).as_deref(), Some("run-xyz"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn persist_detached_failure_writes_status_conclusion_and_progress() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
std::fs::write(dir.path().join("id.txt"), "run-err").unwrap();
|
|
||||||
|
|
||||||
let err = anyhow::anyhow!("bootstrap exploded");
|
|
||||||
persist_detached_failure(dir.path(), "bootstrap", StatusReason::BootstrapFailed, &err)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let record = RunStatusRecord::load(&dir.path().join("status.json")).unwrap();
|
|
||||||
assert_eq!(record.status, RunStatus::Failed);
|
|
||||||
assert_eq!(record.reason, Some(StatusReason::BootstrapFailed));
|
|
||||||
let conclusion =
|
|
||||||
fabro_workflows::records::Conclusion::load(&dir.path().join("conclusion.json"))
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(conclusion.status, StageStatus::Fail);
|
|
||||||
assert_eq!(
|
|
||||||
conclusion.failure_reason.as_deref(),
|
|
||||||
Some("bootstrap exploded")
|
|
||||||
);
|
|
||||||
let progress = std::fs::read_to_string(dir.path().join("progress.jsonl")).unwrap();
|
|
||||||
assert!(progress.contains("bootstrap_failed"));
|
|
||||||
assert!(dir.path().join("detached_failure.json").exists());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn append_run_notice_writes_progress() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
std::fs::write(dir.path().join("id.txt"), "run-notice").unwrap();
|
|
||||||
|
|
||||||
append_run_notice(
|
|
||||||
dir.path(),
|
|
||||||
RunNoticeLevel::Warn,
|
|
||||||
"interview_unanswered",
|
|
||||||
"The run is still waiting for input.",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let progress = std::fs::read_to_string(dir.path().join("progress.jsonl")).unwrap();
|
|
||||||
assert!(progress.contains("\"event\":\"RunNotice\""));
|
|
||||||
assert!(progress.contains("\"code\":\"interview_unanswered\""));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
50
lib/crates/fabro-cli/src/commands/run/launcher.rs
Normal file
50
lib/crates/fabro-cli/src/commands/run/launcher.rs
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub(crate) struct LauncherRecord {
|
||||||
|
pub run_id: String,
|
||||||
|
pub run_dir: PathBuf,
|
||||||
|
pub pid: u32,
|
||||||
|
pub resume: bool,
|
||||||
|
pub log_path: PathBuf,
|
||||||
|
pub started_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn launcher_dir(storage_dir: &Path) -> PathBuf {
|
||||||
|
storage_dir.join("launchers")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn launcher_record_path(storage_dir: &Path, run_id: &str) -> PathBuf {
|
||||||
|
launcher_dir(storage_dir).join(format!("{run_id}.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn launcher_log_path(storage_dir: &Path, run_id: &str) -> PathBuf {
|
||||||
|
launcher_dir(storage_dir).join(format!("{run_id}.log"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn write_launcher_record(path: &Path, record: &LauncherRecord) -> Result<()> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
std::fs::write(path, serde_json::to_string_pretty(record)?)
|
||||||
|
.with_context(|| format!("Failed to write launcher metadata to {}", path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn read_launcher_record(path: &Path) -> Option<LauncherRecord> {
|
||||||
|
let content = std::fs::read_to_string(path).ok()?;
|
||||||
|
serde_json::from_str(&content).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn remove_launcher_record(path: &Path) {
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn launcher_record_for_run(run_dir: &Path) -> Option<LauncherRecord> {
|
||||||
|
let record = fabro_workflows::records::RunRecord::load(run_dir).ok()?;
|
||||||
|
let path = launcher_record_path(&record.settings.storage_dir(), &record.run_id);
|
||||||
|
read_launcher_record(&path)
|
||||||
|
}
|
||||||
|
|
@ -3,13 +3,16 @@ use anyhow::Result;
|
||||||
use crate::args::{GlobalArgs, RunCommands};
|
use crate::args::{GlobalArgs, RunCommands};
|
||||||
|
|
||||||
pub(crate) mod attach;
|
pub(crate) mod attach;
|
||||||
|
pub(crate) mod command;
|
||||||
pub(crate) mod cp;
|
pub(crate) mod cp;
|
||||||
pub(crate) mod create;
|
pub(crate) mod create;
|
||||||
pub(crate) mod detached;
|
pub(crate) mod detached;
|
||||||
pub(crate) mod diff;
|
pub(crate) mod diff;
|
||||||
pub(crate) mod execute;
|
|
||||||
pub(crate) mod fork;
|
pub(crate) mod fork;
|
||||||
|
pub(crate) mod launcher;
|
||||||
pub(crate) mod logs;
|
pub(crate) mod logs;
|
||||||
|
pub(crate) mod output;
|
||||||
|
pub(crate) mod overrides;
|
||||||
pub(crate) mod preview;
|
pub(crate) mod preview;
|
||||||
pub(crate) mod resume;
|
pub(crate) mod resume;
|
||||||
pub(crate) mod rewind;
|
pub(crate) mod rewind;
|
||||||
|
|
@ -20,7 +23,7 @@ pub(crate) mod wait;
|
||||||
|
|
||||||
pub async fn dispatch(cmd: RunCommands, globals: &GlobalArgs) -> Result<()> {
|
pub async fn dispatch(cmd: RunCommands, globals: &GlobalArgs) -> Result<()> {
|
||||||
match cmd {
|
match cmd {
|
||||||
RunCommands::Run(args) => execute::execute(args, globals).await,
|
RunCommands::Run(args) => command::execute(args, globals).await,
|
||||||
RunCommands::Create(args) => {
|
RunCommands::Create(args) => {
|
||||||
let styles: &'static fabro_util::terminal::Styles =
|
let styles: &'static fabro_util::terminal::Styles =
|
||||||
Box::leak(Box::new(fabro_util::terminal::Styles::detect_stderr()));
|
Box::leak(Box::new(fabro_util::terminal::Styles::detect_stderr()));
|
||||||
|
|
@ -50,10 +53,10 @@ pub async fn dispatch(cmd: RunCommands, globals: &GlobalArgs) -> Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
RunCommands::Detached {
|
RunCommands::Detached {
|
||||||
storage_dir,
|
run_dir,
|
||||||
run_id,
|
launcher_path,
|
||||||
resume,
|
resume,
|
||||||
} => detached::execute(storage_dir, run_id, resume).await,
|
} => detached::execute(run_dir, launcher_path, resume).await,
|
||||||
RunCommands::Cp(args) => cp::cp_command(args).await,
|
RunCommands::Cp(args) => cp::cp_command(args).await,
|
||||||
RunCommands::Preview(args) => preview::run(args).await,
|
RunCommands::Preview(args) => preview::run(args).await,
|
||||||
RunCommands::Ssh(args) => ssh::run(args).await,
|
RunCommands::Ssh(args) => ssh::run(args).await,
|
||||||
|
|
|
||||||
222
lib/crates/fabro-cli/src/commands/run/output.rs
Normal file
222
lib/crates/fabro-cli/src/commands/run/output.rs
Normal file
|
|
@ -0,0 +1,222 @@
|
||||||
|
use std::path::Path;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use fabro_util::terminal::Styles;
|
||||||
|
use fabro_workflows::outcome::{format_cost, StageStatus};
|
||||||
|
use fabro_workflows::pipeline::{Persisted, Validated};
|
||||||
|
use fabro_workflows::records::Checkpoint;
|
||||||
|
use indicatif::HumanDuration;
|
||||||
|
|
||||||
|
use crate::shared::{format_tokens_human, print_diagnostics, relative_path, tilde_path};
|
||||||
|
|
||||||
|
fn print_workflow_header(
|
||||||
|
graph: &fabro_graphviz::graph::Graph,
|
||||||
|
diagnostics: &[fabro_validate::Diagnostic],
|
||||||
|
dot_path: Option<&Path>,
|
||||||
|
styles: &Styles,
|
||||||
|
) {
|
||||||
|
eprintln!(
|
||||||
|
"{} {} {}",
|
||||||
|
styles.bold.apply_to("Workflow:"),
|
||||||
|
graph.name,
|
||||||
|
styles.dim.apply_to(format!(
|
||||||
|
"({} nodes, {} edges)",
|
||||||
|
graph.nodes.len(),
|
||||||
|
graph.edges.len()
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
let graph_path = dot_path
|
||||||
|
.map(relative_path)
|
||||||
|
.unwrap_or_else(|| "<inline>".to_string());
|
||||||
|
eprintln!(
|
||||||
|
"{} {}",
|
||||||
|
styles.dim.apply_to("Graph:"),
|
||||||
|
styles.dim.apply_to(graph_path),
|
||||||
|
);
|
||||||
|
|
||||||
|
let goal = graph.goal();
|
||||||
|
if !goal.is_empty() {
|
||||||
|
let stripped = fabro_util::text::strip_goal_decoration(goal);
|
||||||
|
eprintln!("{} {stripped}\n", styles.bold.apply_to("Goal:"));
|
||||||
|
}
|
||||||
|
|
||||||
|
print_diagnostics(diagnostics, styles);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn print_workflow_report(
|
||||||
|
validated: &Validated,
|
||||||
|
dot_path: Option<&Path>,
|
||||||
|
styles: &Styles,
|
||||||
|
) {
|
||||||
|
print_workflow_header(validated.graph(), validated.diagnostics(), dot_path, styles);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn print_workflow_report_from_persisted(
|
||||||
|
persisted: &Persisted,
|
||||||
|
dot_path: Option<&Path>,
|
||||||
|
styles: &Styles,
|
||||||
|
) {
|
||||||
|
print_workflow_header(persisted.graph(), persisted.diagnostics(), dot_path, styles);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn print_diagnostics_from_error(
|
||||||
|
diagnostics: &[fabro_validate::Diagnostic],
|
||||||
|
styles: &Styles,
|
||||||
|
) {
|
||||||
|
print_diagnostics(diagnostics, styles);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn print_run_summary(run_dir: &Path, run_id: &str, styles: &Styles) {
|
||||||
|
let conclusion_path = run_dir.join("conclusion.json");
|
||||||
|
let Ok(conclusion) = fabro_workflows::records::Conclusion::load(&conclusion_path) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let pr_url = std::fs::read_to_string(run_dir.join("pull_request.json"))
|
||||||
|
.ok()
|
||||||
|
.and_then(|content| {
|
||||||
|
serde_json::from_str::<fabro_workflows::pull_request::PullRequestRecord>(&content)
|
||||||
|
.ok()
|
||||||
|
.map(|record| record.html_url)
|
||||||
|
});
|
||||||
|
|
||||||
|
print_run_conclusion(
|
||||||
|
&conclusion,
|
||||||
|
run_id,
|
||||||
|
run_dir,
|
||||||
|
None,
|
||||||
|
pr_url.as_deref(),
|
||||||
|
styles,
|
||||||
|
);
|
||||||
|
print_final_output(run_dir, styles);
|
||||||
|
print_assets(run_dir, styles);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn print_run_conclusion(
|
||||||
|
conclusion: &fabro_workflows::records::Conclusion,
|
||||||
|
run_id: &str,
|
||||||
|
run_dir: &Path,
|
||||||
|
pushed_branch: Option<&str>,
|
||||||
|
pr_url: Option<&str>,
|
||||||
|
styles: &Styles,
|
||||||
|
) {
|
||||||
|
eprintln!("\n{}", styles.bold.apply_to("=== Run Result ==="));
|
||||||
|
eprintln!("{}", styles.dim.apply_to(format!("Run: {run_id}")));
|
||||||
|
|
||||||
|
let status_str = conclusion.status.to_string().to_uppercase();
|
||||||
|
let status_color = match conclusion.status {
|
||||||
|
StageStatus::Success | StageStatus::PartialSuccess => &styles.bold_green,
|
||||||
|
_ => &styles.bold_red,
|
||||||
|
};
|
||||||
|
eprintln!("Status: {}", status_color.apply_to(&status_str));
|
||||||
|
eprintln!(
|
||||||
|
"Duration: {}",
|
||||||
|
HumanDuration(Duration::from_millis(conclusion.duration_ms))
|
||||||
|
);
|
||||||
|
|
||||||
|
let total_tokens = conclusion.total_input_tokens + conclusion.total_output_tokens;
|
||||||
|
if total_tokens > 0 {
|
||||||
|
if conclusion.has_pricing {
|
||||||
|
if let Some(cost) = conclusion.total_cost {
|
||||||
|
if cost > 0.0 {
|
||||||
|
eprintln!(
|
||||||
|
"{}",
|
||||||
|
styles.dim.apply_to(format!(
|
||||||
|
"Cost: {} ({} toks)",
|
||||||
|
format_cost(cost),
|
||||||
|
format_tokens_human(total_tokens)
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
eprintln!(
|
||||||
|
"{}",
|
||||||
|
styles
|
||||||
|
.dim
|
||||||
|
.apply_to(format!("Toks: {}", format_tokens_human(total_tokens)))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if conclusion.total_cache_read_tokens > 0 {
|
||||||
|
eprintln!(
|
||||||
|
"{}",
|
||||||
|
styles.dim.apply_to(format!(
|
||||||
|
"Cache: {} read, {} write",
|
||||||
|
format_tokens_human(conclusion.total_cache_read_tokens),
|
||||||
|
format_tokens_human(conclusion.total_cache_write_tokens),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if conclusion.total_reasoning_tokens > 0 {
|
||||||
|
eprintln!(
|
||||||
|
"{}",
|
||||||
|
styles.dim.apply_to(format!(
|
||||||
|
"Reasoning: {} tokens",
|
||||||
|
format_tokens_human(conclusion.total_reasoning_tokens),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
eprintln!(
|
||||||
|
"{}",
|
||||||
|
styles
|
||||||
|
.dim
|
||||||
|
.apply_to(format!("Run: {}", tilde_path(run_dir)))
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Some(ref failure) = conclusion.failure_reason {
|
||||||
|
eprintln!("Failure: {}", styles.red.apply_to(failure));
|
||||||
|
}
|
||||||
|
|
||||||
|
if pushed_branch.is_some() || pr_url.is_some() {
|
||||||
|
eprintln!();
|
||||||
|
if let Some(branch) = pushed_branch {
|
||||||
|
eprintln!("{} {branch}", styles.bold.apply_to("Pushed branch:"));
|
||||||
|
}
|
||||||
|
if let Some(url) = pr_url {
|
||||||
|
eprintln!("{} {url}", styles.bold.apply_to("Pull request:"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn print_final_output(run_dir: &Path, styles: &Styles) {
|
||||||
|
let Ok(checkpoint) = Checkpoint::load(&run_dir.join("checkpoint.json")) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
for node_id in checkpoint.completed_nodes.iter().rev() {
|
||||||
|
let key = format!("response.{node_id}");
|
||||||
|
if let Some(serde_json::Value::String(response)) = checkpoint.context_values.get(&key) {
|
||||||
|
let text = response.trim();
|
||||||
|
if !text.is_empty() {
|
||||||
|
eprintln!("\n{}", styles.bold.apply_to("=== Output ==="));
|
||||||
|
eprintln!("{}", styles.render_markdown(text));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn print_assets(run_dir: &Path, styles: &Styles) {
|
||||||
|
let paths = fabro_workflows::asset_snapshot::collect_asset_paths(run_dir);
|
||||||
|
if paths.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let home = dirs::home_dir();
|
||||||
|
eprintln!("\n{}", styles.bold.apply_to("=== Assets ==="));
|
||||||
|
for path in &paths {
|
||||||
|
let display = match &home {
|
||||||
|
Some(home_dir) => {
|
||||||
|
let home_str = home_dir.to_string_lossy();
|
||||||
|
if let Some(rest) = path.strip_prefix(home_str.as_ref()) {
|
||||||
|
format!("~{rest}")
|
||||||
|
} else {
|
||||||
|
path.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => path.clone(),
|
||||||
|
};
|
||||||
|
eprintln!("{display}");
|
||||||
|
}
|
||||||
|
}
|
||||||
90
lib/crates/fabro-cli/src/commands/run/overrides.rs
Normal file
90
lib/crates/fabro-cli/src/commands/run/overrides.rs
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use fabro_config::{sandbox as sandbox_config, FabroConfig};
|
||||||
|
use fabro_sandbox::SandboxProvider;
|
||||||
|
|
||||||
|
use crate::args::{PreflightArgs, RunArgs};
|
||||||
|
|
||||||
|
fn sparse_flag(value: bool) -> Option<bool> {
|
||||||
|
value.then_some(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn parse_labels(labels: &[String]) -> HashMap<String, String> {
|
||||||
|
labels
|
||||||
|
.iter()
|
||||||
|
.filter_map(|label| label.split_once('='))
|
||||||
|
.map(|(key, value)| (key.to_string(), value.to_string()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<&RunArgs> for FabroConfig {
|
||||||
|
type Error = anyhow::Error;
|
||||||
|
|
||||||
|
fn try_from(args: &RunArgs) -> Result<Self, Self::Error> {
|
||||||
|
let llm = if args.model.is_some() || args.provider.is_some() {
|
||||||
|
Some(fabro_config::run::LlmConfig {
|
||||||
|
model: args.model.clone(),
|
||||||
|
provider: args.provider.clone(),
|
||||||
|
fallbacks: None,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let sandbox = if args.sandbox.is_some() || args.preserve_sandbox {
|
||||||
|
Some(sandbox_config::SandboxConfig {
|
||||||
|
provider: args
|
||||||
|
.sandbox
|
||||||
|
.map(Into::into)
|
||||||
|
.map(|provider: SandboxProvider| provider.to_string()),
|
||||||
|
preserve: sparse_flag(args.preserve_sandbox),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
goal: args.goal.clone(),
|
||||||
|
goal_file: args.goal_file.clone(),
|
||||||
|
llm,
|
||||||
|
sandbox,
|
||||||
|
verbose: sparse_flag(args.verbose),
|
||||||
|
dry_run: sparse_flag(args.dry_run),
|
||||||
|
auto_approve: sparse_flag(args.auto_approve),
|
||||||
|
no_retro: sparse_flag(args.no_retro),
|
||||||
|
storage_dir: args.storage_dir.clone(),
|
||||||
|
labels: parse_labels(&args.label),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<&PreflightArgs> for FabroConfig {
|
||||||
|
type Error = anyhow::Error;
|
||||||
|
|
||||||
|
fn try_from(args: &PreflightArgs) -> Result<Self, Self::Error> {
|
||||||
|
let llm = if args.model.is_some() || args.provider.is_some() {
|
||||||
|
Some(fabro_config::run::LlmConfig {
|
||||||
|
model: args.model.clone(),
|
||||||
|
provider: args.provider.clone(),
|
||||||
|
fallbacks: None,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let sandbox = args.sandbox.map(|sandbox| sandbox_config::SandboxConfig {
|
||||||
|
provider: Some(SandboxProvider::from(sandbox).to_string()),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
goal: args.goal.clone(),
|
||||||
|
goal_file: args.goal_file.clone(),
|
||||||
|
llm,
|
||||||
|
sandbox,
|
||||||
|
verbose: sparse_flag(args.verbose),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
use anyhow::bail;
|
use anyhow::bail;
|
||||||
use fabro_util::terminal::Styles;
|
use fabro_util::terminal::Styles;
|
||||||
use fabro_workflows::records::{Checkpoint, RunRecord};
|
use fabro_workflows::records::RunRecord;
|
||||||
use fabro_workflows::run_status::{RunStatus, RunStatusRecord};
|
|
||||||
|
|
||||||
use crate::args::ResumeArgs;
|
use crate::args::ResumeArgs;
|
||||||
|
|
||||||
|
|
@ -21,60 +20,17 @@ pub async fn resume_command(args: ResumeArgs, styles: &'static Styles) -> anyhow
|
||||||
}
|
}
|
||||||
let run_id = RunRecord::load(&run_dir)?.run_id;
|
let run_id = RunRecord::load(&run_dir)?.run_id;
|
||||||
|
|
||||||
// Guard against resuming a live run — must happen before checkpoint
|
if launcher_pid_alive(&run_dir) {
|
||||||
// validation because the engine writes checkpoint.json with a plain
|
|
||||||
// fs::write, so a mid-write read would see a truncated file and
|
|
||||||
// report "corrupt" for a run that is simply still alive.
|
|
||||||
if is_pid_alive(&run_dir.join("run.pid")) {
|
|
||||||
bail!("an engine process is still running for this run — cannot resume");
|
bail!("an engine process is still running for this run — cannot resume");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reject runs that completed successfully — only failed/interrupted
|
|
||||||
// runs should be resumed.
|
|
||||||
if let Ok(record) = RunStatusRecord::load(&run_dir.join("status.json")) {
|
|
||||||
if record.status == RunStatus::Succeeded {
|
|
||||||
bail!("run already succeeded — nothing to resume");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate checkpoint is parseable before touching any state.
|
|
||||||
// A crash during the original run can leave a truncated file;
|
|
||||||
// we must not destroy the old conclusion/failure evidence and
|
|
||||||
// only then discover the checkpoint is corrupt.
|
|
||||||
let cp_path = run_dir.join("checkpoint.json");
|
|
||||||
Checkpoint::load(&cp_path).map_err(|e| {
|
|
||||||
if cp_path.exists() {
|
|
||||||
anyhow::anyhow!("checkpoint.json is corrupt — cannot resume: {e}")
|
|
||||||
} else {
|
|
||||||
anyhow::anyhow!("no checkpoint found — nothing to resume")
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// Clean stale artifacts from previous execution
|
|
||||||
for name in &[
|
|
||||||
"conclusion.json",
|
|
||||||
"pull_request.json",
|
|
||||||
"detached_failure.json",
|
|
||||||
"interview_request.json",
|
|
||||||
"interview_response.json",
|
|
||||||
"interview_request.claim",
|
|
||||||
"detach.log",
|
|
||||||
"run.pid",
|
|
||||||
"progress.jsonl",
|
|
||||||
] {
|
|
||||||
let _ = std::fs::remove_file(run_dir.join(name));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset status for re-execution
|
|
||||||
fabro_workflows::run_status::write_run_status(&run_dir, RunStatus::Submitted, None);
|
|
||||||
|
|
||||||
let child = super::start::start_run(&run_dir, true)?;
|
let child = super::start::start_run(&run_dir, true)?;
|
||||||
|
|
||||||
if args.detach {
|
if args.detach {
|
||||||
println!("{run_id}");
|
println!("{run_id}");
|
||||||
} else {
|
} else {
|
||||||
let exit_code = super::attach::attach_run(&run_dir, true, styles, Some(child)).await?;
|
let exit_code = super::attach::attach_run(&run_dir, true, styles, Some(child)).await?;
|
||||||
super::execute::print_run_summary(&run_dir, &run_id, styles);
|
super::output::print_run_summary(&run_dir, &run_id, styles);
|
||||||
if exit_code != std::process::ExitCode::SUCCESS {
|
if exit_code != std::process::ExitCode::SUCCESS {
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
|
|
@ -82,16 +38,16 @@ pub async fn resume_command(args: ResumeArgs, styles: &'static Styles) -> anyhow
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check whether a PID file contains a live process.
|
fn launcher_pid_alive(run_dir: &std::path::Path) -> bool {
|
||||||
fn is_pid_alive(pid_path: &std::path::Path) -> bool {
|
super::launcher::launcher_record_for_run(run_dir)
|
||||||
let Ok(content) = std::fs::read_to_string(pid_path) else {
|
.map(|record| process_alive(record.pid))
|
||||||
return false;
|
.or_else(|| {
|
||||||
};
|
std::fs::read_to_string(run_dir.join("run.pid"))
|
||||||
let Ok(pid) = content.trim().parse::<i32>() else {
|
.ok()
|
||||||
return false;
|
.and_then(|pid| pid.trim().parse::<u32>().ok())
|
||||||
};
|
.map(process_alive)
|
||||||
// kill(pid, 0) checks liveness without sending a signal
|
})
|
||||||
unsafe { libc::kill(pid, 0) == 0 }
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
@ -99,23 +55,20 @@ mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn is_pid_alive_returns_false_for_missing_file() {
|
fn launcher_pid_alive_returns_false_for_missing_record() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
assert!(!is_pid_alive(&dir.path().join("run.pid")));
|
assert!(!launcher_pid_alive(dir.path()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
fn process_alive(pid: u32) -> bool {
|
||||||
fn is_pid_alive_returns_false_for_invalid_pid() {
|
#[cfg(unix)]
|
||||||
let dir = tempfile::tempdir().unwrap();
|
{
|
||||||
std::fs::write(dir.path().join("run.pid"), "not-a-pid").unwrap();
|
unsafe { libc::kill(pid as i32, 0) == 0 }
|
||||||
assert!(!is_pid_alive(&dir.path().join("run.pid")));
|
|
||||||
}
|
}
|
||||||
|
#[cfg(not(unix))]
|
||||||
#[test]
|
{
|
||||||
fn is_pid_alive_returns_true_for_current_process() {
|
let _ = pid;
|
||||||
let dir = tempfile::tempdir().unwrap();
|
true
|
||||||
let pid = std::process::id();
|
|
||||||
std::fs::write(dir.path().join("run.pid"), pid.to_string()).unwrap();
|
|
||||||
assert!(is_pid_alive(&dir.path().join("run.pid")));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,7 @@ pub struct ProgressUI {
|
||||||
working_directory: Option<String>,
|
working_directory: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
impl ProgressUI {
|
impl ProgressUI {
|
||||||
pub fn new(is_tty: bool, verbose: bool) -> Self {
|
pub fn new(is_tty: bool, verbose: bool) -> Self {
|
||||||
let renderer = if is_tty {
|
let renderer = if is_tty {
|
||||||
|
|
@ -1722,11 +1723,13 @@ impl ProgressUI {
|
||||||
|
|
||||||
/// Wraps a `ConsoleInterviewer` so that progress bars are hidden during
|
/// Wraps a `ConsoleInterviewer` so that progress bars are hidden during
|
||||||
/// interactive prompts (avoids garbled output from concurrent writes).
|
/// interactive prompts (avoids garbled output from concurrent writes).
|
||||||
|
#[allow(dead_code)]
|
||||||
pub struct ProgressAwareInterviewer {
|
pub struct ProgressAwareInterviewer {
|
||||||
inner: ConsoleInterviewer,
|
inner: ConsoleInterviewer,
|
||||||
progress: Arc<Mutex<ProgressUI>>,
|
progress: Arc<Mutex<ProgressUI>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
impl ProgressAwareInterviewer {
|
impl ProgressAwareInterviewer {
|
||||||
pub fn new(inner: ConsoleInterviewer, progress: Arc<Mutex<ProgressUI>>) -> Self {
|
pub fn new(inner: ConsoleInterviewer, progress: Arc<Mutex<ProgressUI>>) -> Self {
|
||||||
Self { inner, progress }
|
Self { inner, progress }
|
||||||
|
|
|
||||||
|
|
@ -1,76 +1,37 @@
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use anyhow::{bail, Result};
|
use anyhow::{anyhow, Result};
|
||||||
use fabro_workflows::run_status::{RunStatus, StatusReason};
|
use chrono::Utc;
|
||||||
|
|
||||||
use super::detached::persist_detached_failure;
|
use super::launcher::{
|
||||||
|
launcher_log_path, launcher_record_path, write_launcher_record, LauncherRecord,
|
||||||
|
};
|
||||||
|
|
||||||
/// Spawn a detached engine process for the given run directory.
|
/// Spawn a detached engine process for the given run directory.
|
||||||
///
|
///
|
||||||
/// The engine process reads `run.json` from the run directory and executes the
|
/// The engine process reads `run.json` from the run directory and executes the
|
||||||
/// workflow. Returns the child process handle (use `.id()` for the PID).
|
/// workflow. Returns the child process handle (use `.id()` for the PID).
|
||||||
///
|
|
||||||
/// `storage_dir` is the base storage directory (e.g. `~/.fabro`). If `None`,
|
|
||||||
/// it is derived from the `run_dir` by stripping the `runs/<dir_name>` suffix.
|
|
||||||
pub fn start_run(run_dir: &Path, resume: bool) -> Result<std::process::Child> {
|
pub fn start_run(run_dir: &Path, resume: bool) -> Result<std::process::Child> {
|
||||||
// Validate status is Submitted
|
let record = fabro_workflows::records::RunRecord::load(run_dir)
|
||||||
let status_path = run_dir.join("status.json");
|
.map_err(|e| anyhow!("Cannot start run: failed to load run.json: {e}"))?;
|
||||||
match fabro_workflows::run_status::RunStatusRecord::load(&status_path) {
|
|
||||||
Ok(record) if record.status != RunStatus::Submitted => {
|
let storage_dir = record.settings.storage_dir();
|
||||||
bail!(
|
let launcher_path = launcher_record_path(&storage_dir, &record.run_id);
|
||||||
"Cannot start run: status is {:?}, expected Submitted",
|
let log_path = launcher_log_path(&storage_dir, &record.run_id);
|
||||||
record.status
|
|
||||||
);
|
if let Some(parent) = log_path.parent() {
|
||||||
}
|
std::fs::create_dir_all(parent)?;
|
||||||
_ => {} // No status file or Submitted — proceed
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate run.json is loadable
|
let log_file = std::fs::File::create(&log_path)?;
|
||||||
fabro_workflows::records::RunRecord::load(run_dir)
|
let stdout_log = log_file.try_clone()?;
|
||||||
.map_err(|e| anyhow::anyhow!("Cannot start run: failed to load run.json: {e}"))?;
|
let exe = std::env::current_exe()?;
|
||||||
|
|
||||||
// Write Starting status before spawning to prevent duplicate engines
|
|
||||||
fabro_workflows::run_status::write_run_status(run_dir, RunStatus::Starting, None);
|
|
||||||
|
|
||||||
let log_file = match std::fs::File::create(run_dir.join("detach.log")) {
|
|
||||||
Ok(file) => file,
|
|
||||||
Err(err) => {
|
|
||||||
let err = err.into();
|
|
||||||
let _ = persist_detached_failure(run_dir, "launch", StatusReason::LaunchFailed, &err);
|
|
||||||
return Err(err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let exe = match std::env::current_exe() {
|
|
||||||
Ok(exe) => exe,
|
|
||||||
Err(err) => {
|
|
||||||
let err = err.into();
|
|
||||||
let _ = persist_detached_failure(run_dir, "launch", StatusReason::LaunchFailed, &err);
|
|
||||||
return Err(err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut cmd = std::process::Command::new(&exe);
|
let mut cmd = std::process::Command::new(&exe);
|
||||||
let stdout_log = match log_file.try_clone() {
|
cmd.args(["__detached", "--run-dir"])
|
||||||
Ok(file) => file,
|
.arg(run_dir)
|
||||||
Err(err) => {
|
.args(["--launcher-path"])
|
||||||
let err = err.into();
|
.arg(&launcher_path);
|
||||||
let _ = persist_detached_failure(run_dir, "launch", StatusReason::LaunchFailed, &err);
|
|
||||||
return Err(err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// Derive storage_dir (grandparent of run_dir, e.g. ~/.fabro) and run_id
|
|
||||||
let storage_dir = run_dir
|
|
||||||
.parent() // runs/
|
|
||||||
.and_then(|p| p.parent()) // ~/.fabro/
|
|
||||||
.unwrap_or(run_dir);
|
|
||||||
let run_id = fabro_workflows::records::RunRecord::load(run_dir)
|
|
||||||
.map(|r| r.run_id)
|
|
||||||
.or_else(|_| std::fs::read_to_string(run_dir.join("id.txt")).map(|s| s.trim().to_string()))
|
|
||||||
.unwrap_or_default();
|
|
||||||
cmd.args(["__detached", "--storage-dir"])
|
|
||||||
.arg(storage_dir)
|
|
||||||
.args(["--run-id", &run_id]);
|
|
||||||
if resume {
|
if resume {
|
||||||
cmd.arg("--resume");
|
cmd.arg("--resume");
|
||||||
}
|
}
|
||||||
|
|
@ -78,7 +39,6 @@ pub fn start_run(run_dir: &Path, resume: bool) -> Result<std::process::Child> {
|
||||||
.stderr(log_file)
|
.stderr(log_file)
|
||||||
.stdin(std::process::Stdio::null());
|
.stdin(std::process::Stdio::null());
|
||||||
|
|
||||||
// Detach from the controlling terminal on unix
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
use std::os::unix::process::CommandExt;
|
use std::os::unix::process::CommandExt;
|
||||||
|
|
@ -90,20 +50,20 @@ pub fn start_run(run_dir: &Path, resume: bool) -> Result<std::process::Child> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut child = match cmd.spawn() {
|
let mut child = cmd.spawn()?;
|
||||||
Ok(child) => child,
|
|
||||||
Err(err) => {
|
|
||||||
let err = err.into();
|
|
||||||
let _ = persist_detached_failure(run_dir, "launch", StatusReason::LaunchFailed, &err);
|
|
||||||
return Err(err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Write PID file
|
if let Err(err) = write_launcher_record(
|
||||||
if let Err(err) = std::fs::write(run_dir.join("run.pid"), child.id().to_string()) {
|
&launcher_path,
|
||||||
|
&LauncherRecord {
|
||||||
|
run_id: record.run_id,
|
||||||
|
run_dir: run_dir.to_path_buf(),
|
||||||
|
pid: child.id(),
|
||||||
|
resume,
|
||||||
|
log_path,
|
||||||
|
started_at: Utc::now(),
|
||||||
|
},
|
||||||
|
) {
|
||||||
kill_child_best_effort(&mut child);
|
kill_child_best_effort(&mut child);
|
||||||
let err = err.into();
|
|
||||||
let _ = persist_detached_failure(run_dir, "launch", StatusReason::LaunchFailed, &err);
|
|
||||||
return Err(err);
|
return Err(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -114,53 +74,3 @@ fn kill_child_best_effort(child: &mut std::process::Child) {
|
||||||
let _ = child.kill();
|
let _ = child.kill();
|
||||||
let _ = child.wait();
|
let _ = child.wait();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use chrono::Utc;
|
|
||||||
use fabro_config::FabroSettings;
|
|
||||||
use fabro_graphviz::graph::Graph;
|
|
||||||
use fabro_workflows::records::RunRecord;
|
|
||||||
use fabro_workflows::run_status::{write_run_status, RunStatus, RunStatusRecord, StatusReason};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
fn sample_record() -> RunRecord {
|
|
||||||
RunRecord {
|
|
||||||
run_id: "run-test123".to_string(),
|
|
||||||
created_at: Utc::now(),
|
|
||||||
settings: FabroSettings::default(),
|
|
||||||
graph: Graph {
|
|
||||||
name: "test".to_string(),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
workflow_slug: None,
|
|
||||||
working_directory: PathBuf::from("/tmp"),
|
|
||||||
host_repo_path: None,
|
|
||||||
base_branch: None,
|
|
||||||
labels: HashMap::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn start_run_marks_failed_when_spawn_cannot_start_engine() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
write_run_status(dir.path(), RunStatus::Submitted, None);
|
|
||||||
sample_record().save(dir.path()).unwrap();
|
|
||||||
std::fs::create_dir(dir.path().join("detach.log")).unwrap();
|
|
||||||
|
|
||||||
let _ = start_run(dir.path(), false);
|
|
||||||
|
|
||||||
let record = RunStatusRecord::load(&dir.path().join("status.json")).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
record.status,
|
|
||||||
RunStatus::Failed,
|
|
||||||
"start_run should persist a terminal failure on launch errors"
|
|
||||||
);
|
|
||||||
assert_eq!(record.reason, Some(StatusReason::LaunchFailed));
|
|
||||||
assert!(dir.path().join("conclusion.json").exists());
|
|
||||||
let progress = std::fs::read_to_string(dir.path().join("progress.jsonl")).unwrap();
|
|
||||||
assert!(progress.contains("launch_failed"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -338,20 +338,23 @@ mod tests {
|
||||||
let cli = Cli::try_parse_from([
|
let cli = Cli::try_parse_from([
|
||||||
"fabro",
|
"fabro",
|
||||||
"__detached",
|
"__detached",
|
||||||
"--storage-dir",
|
"--run-dir",
|
||||||
"/tmp/fabro",
|
"/tmp/fabro/runs/01ABC",
|
||||||
"--run-id",
|
"--launcher-path",
|
||||||
"01ABC",
|
"/tmp/fabro/launchers/01ABC.json",
|
||||||
])
|
])
|
||||||
.expect("should parse");
|
.expect("should parse");
|
||||||
match *cli.command {
|
match *cli.command {
|
||||||
Commands::RunCmd(RunCommands::Detached {
|
Commands::RunCmd(RunCommands::Detached {
|
||||||
storage_dir,
|
run_dir,
|
||||||
run_id,
|
launcher_path,
|
||||||
resume,
|
resume,
|
||||||
}) => {
|
}) => {
|
||||||
assert_eq!(storage_dir, std::path::PathBuf::from("/tmp/fabro"));
|
assert_eq!(run_dir, std::path::PathBuf::from("/tmp/fabro/runs/01ABC"));
|
||||||
assert_eq!(run_id, "01ABC");
|
assert_eq!(
|
||||||
|
launcher_path,
|
||||||
|
std::path::PathBuf::from("/tmp/fabro/launchers/01ABC.json")
|
||||||
|
);
|
||||||
assert!(!resume);
|
assert!(!resume);
|
||||||
}
|
}
|
||||||
_ => panic!("unexpected command variant"),
|
_ => panic!("unexpected command variant"),
|
||||||
|
|
@ -363,21 +366,24 @@ mod tests {
|
||||||
let cli = Cli::try_parse_from([
|
let cli = Cli::try_parse_from([
|
||||||
"fabro",
|
"fabro",
|
||||||
"__detached",
|
"__detached",
|
||||||
"--storage-dir",
|
"--run-dir",
|
||||||
"/tmp/fabro",
|
"/tmp/fabro/runs/01ABC",
|
||||||
"--run-id",
|
"--launcher-path",
|
||||||
"01ABC",
|
"/tmp/fabro/launchers/01ABC.json",
|
||||||
"--resume",
|
"--resume",
|
||||||
])
|
])
|
||||||
.expect("should parse");
|
.expect("should parse");
|
||||||
match *cli.command {
|
match *cli.command {
|
||||||
Commands::RunCmd(RunCommands::Detached {
|
Commands::RunCmd(RunCommands::Detached {
|
||||||
storage_dir,
|
run_dir,
|
||||||
run_id,
|
launcher_path,
|
||||||
resume,
|
resume,
|
||||||
}) => {
|
}) => {
|
||||||
assert_eq!(storage_dir, std::path::PathBuf::from("/tmp/fabro"));
|
assert_eq!(run_dir, std::path::PathBuf::from("/tmp/fabro/runs/01ABC"));
|
||||||
assert_eq!(run_id, "01ABC");
|
assert_eq!(
|
||||||
|
launcher_path,
|
||||||
|
std::path::PathBuf::from("/tmp/fabro/launchers/01ABC.json")
|
||||||
|
);
|
||||||
assert!(resume);
|
assert!(resume);
|
||||||
}
|
}
|
||||||
_ => panic!("unexpected command variant"),
|
_ => panic!("unexpected command variant"),
|
||||||
|
|
|
||||||
|
|
@ -713,7 +713,8 @@ fn detach_creates_run_dir_with_detach_log() {
|
||||||
let ulid = ulid.trim();
|
let ulid = ulid.trim();
|
||||||
assert!(!ulid.is_empty(), "should print a ULID");
|
assert!(!ulid.is_empty(), "should print a ULID");
|
||||||
|
|
||||||
// Run dir should have been created under storage_dir/runs/ with detach.log
|
// Run dir should have been created under storage_dir/runs/ and the launcher
|
||||||
|
// log should live under storage_dir/launchers/.
|
||||||
let runs_base = storage_dir.join("runs");
|
let runs_base = storage_dir.join("runs");
|
||||||
assert!(runs_base.exists(), "runs/ directory should exist");
|
assert!(runs_base.exists(), "runs/ directory should exist");
|
||||||
let entries: Vec<_> = std::fs::read_dir(&runs_base)
|
let entries: Vec<_> = std::fs::read_dir(&runs_base)
|
||||||
|
|
@ -723,9 +724,13 @@ fn detach_creates_run_dir_with_detach_log() {
|
||||||
assert_eq!(entries.len(), 1, "should have exactly one run directory");
|
assert_eq!(entries.len(), 1, "should have exactly one run directory");
|
||||||
let run_dir = entries[0].path();
|
let run_dir = entries[0].path();
|
||||||
assert!(
|
assert!(
|
||||||
run_dir.join("detach.log").exists(),
|
storage_dir
|
||||||
"detach.log should exist in run dir"
|
.join("launchers")
|
||||||
|
.join(format!("{ulid}.log"))
|
||||||
|
.exists(),
|
||||||
|
"launcher log should exist under storage_dir/launchers"
|
||||||
);
|
);
|
||||||
|
assert!(!run_dir.join("detach.log").exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
// == Resume ===================================================================
|
// == Resume ===================================================================
|
||||||
|
|
@ -790,7 +795,7 @@ fn setup_run_dir(
|
||||||
let run_record = serde_json::json!({
|
let run_record = serde_json::json!({
|
||||||
"run_id": run_id,
|
"run_id": run_id,
|
||||||
"created_at": "2026-01-01T00:00:00Z",
|
"created_at": "2026-01-01T00:00:00Z",
|
||||||
"config": {
|
"settings": {
|
||||||
"goal": overrides.get("goal").and_then(|v| v.as_str()),
|
"goal": overrides.get("goal").and_then(|v| v.as_str()),
|
||||||
"llm": {
|
"llm": {
|
||||||
"model": get_str("model", "test-model"),
|
"model": get_str("model", "test-model"),
|
||||||
|
|
@ -1146,10 +1151,14 @@ digraph G {
|
||||||
let output = arc()
|
let output = arc()
|
||||||
.args([
|
.args([
|
||||||
"__detached",
|
"__detached",
|
||||||
"--storage-dir",
|
"--run-dir",
|
||||||
storage_dir.to_str().unwrap(),
|
run_dir.to_str().unwrap(),
|
||||||
"--run-id",
|
"--launcher-path",
|
||||||
"test-bug2",
|
storage_dir
|
||||||
|
.join("launchers")
|
||||||
|
.join("test-bug2.json")
|
||||||
|
.to_str()
|
||||||
|
.unwrap(),
|
||||||
])
|
])
|
||||||
.env("NO_COLOR", "1")
|
.env("NO_COLOR", "1")
|
||||||
.timeout(std::time::Duration::from_secs(15))
|
.timeout(std::time::Duration::from_secs(15))
|
||||||
|
|
@ -1213,7 +1222,7 @@ digraph Test {
|
||||||
.success();
|
.success();
|
||||||
let before: serde_json::Value =
|
let before: serde_json::Value =
|
||||||
serde_json::from_slice(&inspect_before.get_output().stdout).unwrap();
|
serde_json::from_slice(&inspect_before.get_output().stdout).unwrap();
|
||||||
let _run_dir = before[0]["run_dir"].as_str().unwrap().to_string();
|
let run_dir = before[0]["run_dir"].as_str().unwrap().to_string();
|
||||||
let start_time_before = before[0]["start_record"]["start_time"]
|
let start_time_before = before[0]["start_record"]["start_time"]
|
||||||
.as_str()
|
.as_str()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
|
@ -1228,10 +1237,14 @@ digraph Test {
|
||||||
.env("HOME", home.path())
|
.env("HOME", home.path())
|
||||||
.args([
|
.args([
|
||||||
"__detached",
|
"__detached",
|
||||||
"--storage-dir",
|
"--run-dir",
|
||||||
storage_dir.to_str().unwrap(),
|
&run_dir,
|
||||||
"--run-id",
|
"--launcher-path",
|
||||||
&run_id,
|
storage_dir
|
||||||
|
.join("launchers")
|
||||||
|
.join(format!("{run_id}.json"))
|
||||||
|
.to_str()
|
||||||
|
.unwrap(),
|
||||||
"--resume",
|
"--resume",
|
||||||
])
|
])
|
||||||
.timeout(std::time::Duration::from_secs(10))
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
|
|
@ -1384,13 +1397,6 @@ fn attach_closed_stdin_keeps_interview_pending() {
|
||||||
!run_dir.join("interview_request.claim").exists(),
|
!run_dir.join("interview_request.claim").exists(),
|
||||||
"attach with closed stdin must release the claim so a later attach can answer"
|
"attach with closed stdin must release the claim so a later attach can answer"
|
||||||
);
|
);
|
||||||
|
|
||||||
let progress = std::fs::read_to_string(run_dir.join("progress.jsonl")).unwrap();
|
|
||||||
assert!(
|
|
||||||
progress.contains("\"event\":\"RunNotice\"")
|
|
||||||
&& progress.contains("\"code\":\"interview_unanswered\""),
|
|
||||||
"attach should emit a structured warning when the interview ends without an answer.\nprogress: {progress}"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bug 4: attach should respect the verbose flag from run.json.
|
// Bug 4: attach should respect the verbose flag from run.json.
|
||||||
|
|
|
||||||
|
|
@ -614,7 +614,7 @@ mod tests {
|
||||||
init_repo(dir.path());
|
init_repo(dir.path());
|
||||||
|
|
||||||
let store = MetadataStore::new(dir.path(), &GitAuthor::default());
|
let store = MetadataStore::new(dir.path(), &GitAuthor::default());
|
||||||
let run_record = br#"{"run_id":"RUN1","created_at":"2025-01-01T00:00:00Z","config":{},"graph":{"name":"test","nodes":{},"edges":[],"attrs":{}},"working_directory":"/tmp"}"#;
|
let run_record = br#"{"run_id":"RUN1","created_at":"2025-01-01T00:00:00Z","settings":{},"graph":{"name":"test","nodes":{},"edges":[],"attrs":{}},"working_directory":"/tmp"}"#;
|
||||||
store.init_run("RUN1", &[("run.json", run_record)]).unwrap();
|
store.init_run("RUN1", &[("run.json", run_record)]).unwrap();
|
||||||
|
|
||||||
let read_record = MetadataStore::read_run_record(dir.path(), "RUN1")
|
let read_record = MetadataStore::read_run_record(dir.path(), "RUN1")
|
||||||
|
|
@ -794,7 +794,7 @@ mod tests {
|
||||||
init_repo(dir.path());
|
init_repo(dir.path());
|
||||||
|
|
||||||
let store = MetadataStore::new(dir.path(), &GitAuthor::default());
|
let store = MetadataStore::new(dir.path(), &GitAuthor::default());
|
||||||
let run_record = br#"{"run_id":"RUN5","created_at":"2025-01-01T00:00:00Z","config":{},"graph":{"name":"test","nodes":{},"edges":[],"attrs":{}},"working_directory":"/tmp"}"#;
|
let run_record = br#"{"run_id":"RUN5","created_at":"2025-01-01T00:00:00Z","settings":{},"graph":{"name":"test","nodes":{},"edges":[],"attrs":{}},"working_directory":"/tmp"}"#;
|
||||||
store.init_run("RUN5", &[("run.json", run_record)]).unwrap();
|
store.init_run("RUN5", &[("run.json", run_record)]).unwrap();
|
||||||
|
|
||||||
store
|
store
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ use chrono::{Local, Utc};
|
||||||
use fabro_config::FabroSettings;
|
use fabro_config::FabroSettings;
|
||||||
use fabro_graphviz::graph::{AttrValue, Graph};
|
use fabro_graphviz::graph::{AttrValue, Graph};
|
||||||
use fabro_model::{Catalog, Provider};
|
use fabro_model::{Catalog, Provider};
|
||||||
|
use fabro_sandbox::SandboxProvider;
|
||||||
|
|
||||||
use crate::error::FabroError;
|
use crate::error::FabroError;
|
||||||
use crate::pipeline::types::PersistOptions;
|
use crate::pipeline::types::PersistOptions;
|
||||||
|
|
@ -12,6 +13,10 @@ use crate::pipeline::{self, Persisted, TransformOptions, Validated};
|
||||||
use crate::records::RunRecord;
|
use crate::records::RunRecord;
|
||||||
use crate::transforms::{expand_vars, Transform};
|
use crate::transforms::{expand_vars, Transform};
|
||||||
|
|
||||||
|
use super::source::{resolve_workflow, ResolveWorkflowRequest, WorkflowInput};
|
||||||
|
|
||||||
|
const RUN_CONFIG_FILE: &str = "workflow.toml";
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct ValidateOptions {
|
pub struct ValidateOptions {
|
||||||
pub base_dir: Option<PathBuf>,
|
pub base_dir: Option<PathBuf>,
|
||||||
|
|
@ -20,17 +25,52 @@ pub struct ValidateOptions {
|
||||||
pub goal_override: Option<String>,
|
pub goal_override: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct RunCreateOptions {
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct CreateRequest {
|
||||||
|
pub workflow: WorkflowInput,
|
||||||
pub settings: FabroSettings,
|
pub settings: FabroSettings,
|
||||||
pub run_dir: Option<PathBuf>,
|
pub run_dir: Option<PathBuf>,
|
||||||
pub run_id: Option<String>,
|
pub run_id: Option<String>,
|
||||||
pub workflow_slug: Option<String>,
|
|
||||||
pub labels: HashMap<String, String>,
|
|
||||||
pub base_branch: Option<String>,
|
|
||||||
pub working_directory: Option<PathBuf>,
|
|
||||||
pub host_repo_path: Option<String>,
|
pub host_repo_path: Option<String>,
|
||||||
pub goal_override: Option<String>,
|
pub base_branch: Option<String>,
|
||||||
pub base_dir: Option<PathBuf>,
|
}
|
||||||
|
|
||||||
|
impl Default for CreateRequest {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
workflow: WorkflowInput::DotSource {
|
||||||
|
source: String::new(),
|
||||||
|
base_dir: None,
|
||||||
|
workflow_slug: None,
|
||||||
|
},
|
||||||
|
settings: FabroSettings::default(),
|
||||||
|
run_dir: None,
|
||||||
|
run_id: None,
|
||||||
|
host_repo_path: None,
|
||||||
|
base_branch: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct CreatedRun {
|
||||||
|
pub persisted: Persisted,
|
||||||
|
pub run_id: String,
|
||||||
|
pub run_dir: PathBuf,
|
||||||
|
pub dot_path: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PersistCreateOptions {
|
||||||
|
settings: FabroSettings,
|
||||||
|
run_dir: Option<PathBuf>,
|
||||||
|
run_id: Option<String>,
|
||||||
|
workflow_slug: Option<String>,
|
||||||
|
labels: HashMap<String, String>,
|
||||||
|
base_branch: Option<String>,
|
||||||
|
working_directory: Option<PathBuf>,
|
||||||
|
host_repo_path: Option<String>,
|
||||||
|
goal_override: Option<String>,
|
||||||
|
base_dir: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse, transform, and validate a DOT source string.
|
/// Parse, transform, and validate a DOT source string.
|
||||||
|
|
@ -61,8 +101,104 @@ pub fn validate_from_file(path: &Path) -> Result<Validated, FabroError> {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse, transform, validate, resolve settings, and persist a run.
|
/// Resolve workflow inputs, normalize settings, and persist a run directory.
|
||||||
pub fn create(dot_source: &str, options: RunCreateOptions) -> Result<Persisted, FabroError> {
|
pub fn create(request: CreateRequest) -> Result<CreatedRun, FabroError> {
|
||||||
|
let resolved = resolve_workflow(ResolveWorkflowRequest {
|
||||||
|
workflow: request.workflow,
|
||||||
|
settings: request.settings,
|
||||||
|
})
|
||||||
|
.map_err(|err| FabroError::Parse(err.to_string()))?;
|
||||||
|
|
||||||
|
if !resolved.settings.dry_run_enabled() {
|
||||||
|
validate_sandbox_provider(&resolved.settings)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let CreateRequest {
|
||||||
|
workflow: _,
|
||||||
|
settings: _,
|
||||||
|
run_dir,
|
||||||
|
run_id,
|
||||||
|
host_repo_path,
|
||||||
|
base_branch,
|
||||||
|
} = request;
|
||||||
|
|
||||||
|
let settings = resolved.settings.clone();
|
||||||
|
let run_id = run_id.unwrap_or_else(|| ulid::Ulid::new().to_string());
|
||||||
|
let storage_dir = settings.storage_dir();
|
||||||
|
let run_dir = run_dir.unwrap_or_else(|| {
|
||||||
|
make_run_dir(
|
||||||
|
&storage_dir.join("runs"),
|
||||||
|
&run_id,
|
||||||
|
settings.dry_run_enabled(),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let working_directory = resolved.working_directory.clone();
|
||||||
|
let host_repo_path =
|
||||||
|
host_repo_path.or_else(|| Some(working_directory.to_string_lossy().to_string()));
|
||||||
|
let base_branch = base_branch.or_else(|| {
|
||||||
|
fabro_sandbox::daytona::detect_repo_info(&working_directory)
|
||||||
|
.ok()
|
||||||
|
.and_then(|(_, branch)| branch)
|
||||||
|
});
|
||||||
|
|
||||||
|
let persisted = create_from_source(
|
||||||
|
&resolved.raw_source,
|
||||||
|
PersistCreateOptions {
|
||||||
|
settings,
|
||||||
|
run_dir: Some(run_dir.clone()),
|
||||||
|
run_id: Some(run_id.clone()),
|
||||||
|
workflow_slug: resolved.workflow_slug.clone(),
|
||||||
|
labels: resolved.settings.labels.clone(),
|
||||||
|
base_branch,
|
||||||
|
working_directory: Some(working_directory),
|
||||||
|
host_repo_path,
|
||||||
|
goal_override: resolved.goal_override.clone(),
|
||||||
|
base_dir: resolved.base_dir.clone(),
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
|
||||||
|
write_run_config_snapshot(&run_dir, resolved.workflow_toml_path.as_deref())?;
|
||||||
|
crate::run_status::write_run_status(&run_dir, crate::run_status::RunStatus::Submitted, None);
|
||||||
|
|
||||||
|
Ok(CreatedRun {
|
||||||
|
persisted,
|
||||||
|
run_id,
|
||||||
|
run_dir,
|
||||||
|
dot_path: resolved.dot_path,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_sandbox_provider(settings: &FabroSettings) -> Result<(), FabroError> {
|
||||||
|
if let Some(provider) = settings
|
||||||
|
.sandbox_settings()
|
||||||
|
.and_then(|sandbox| sandbox.provider.as_deref())
|
||||||
|
{
|
||||||
|
provider
|
||||||
|
.parse::<SandboxProvider>()
|
||||||
|
.map_err(|err| FabroError::Precondition(format!("Invalid sandbox provider: {err}")))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_run_config_snapshot(
|
||||||
|
run_dir: &Path,
|
||||||
|
workflow_toml_path: Option<&Path>,
|
||||||
|
) -> Result<(), FabroError> {
|
||||||
|
if let Some(toml_path) = workflow_toml_path {
|
||||||
|
if toml_path.is_file() {
|
||||||
|
std::fs::copy(toml_path, run_dir.join(RUN_CONFIG_FILE))
|
||||||
|
.map_err(|err| FabroError::Io(err.to_string()))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_from_source(
|
||||||
|
dot_source: &str,
|
||||||
|
options: PersistCreateOptions,
|
||||||
|
) -> Result<Persisted, FabroError> {
|
||||||
let validated = preprocess_and_validate(
|
let validated = preprocess_and_validate(
|
||||||
dot_source,
|
dot_source,
|
||||||
options.base_dir.clone(),
|
options.base_dir.clone(),
|
||||||
|
|
@ -80,18 +216,6 @@ pub fn create(dot_source: &str, options: RunCreateOptions) -> Result<Persisted,
|
||||||
persist_validated(validated, options)
|
persist_validated(validated, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read a DOT file, apply file inlining from its parent directory, then create.
|
|
||||||
pub fn create_from_file(
|
|
||||||
path: &Path,
|
|
||||||
mut options: RunCreateOptions,
|
|
||||||
) -> Result<Persisted, FabroError> {
|
|
||||||
let source = std::fs::read_to_string(path)
|
|
||||||
.map_err(|e| FabroError::Parse(format!("Failed to read {}: {e}", path.display())))?;
|
|
||||||
let base_dir = path.parent().unwrap_or(Path::new("."));
|
|
||||||
options.base_dir = Some(base_dir.to_path_buf());
|
|
||||||
create(&source, options)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn preprocess_and_validate(
|
fn preprocess_and_validate(
|
||||||
dot_source: &str,
|
dot_source: &str,
|
||||||
base_dir: Option<PathBuf>,
|
base_dir: Option<PathBuf>,
|
||||||
|
|
@ -102,7 +226,6 @@ fn preprocess_and_validate(
|
||||||
let source = match settings.and_then(|resolved| resolved.vars.as_ref()) {
|
let source = match settings.and_then(|resolved| resolved.vars.as_ref()) {
|
||||||
Some(vars) => {
|
Some(vars) => {
|
||||||
let mut vars = vars.clone();
|
let mut vars = vars.clone();
|
||||||
// `$goal` is resolved later from the graph goal after any goal override.
|
|
||||||
vars.insert("goal".to_string(), "$goal".to_string());
|
vars.insert("goal".to_string(), "$goal".to_string());
|
||||||
expand_vars(dot_source, &vars)
|
expand_vars(dot_source, &vars)
|
||||||
.map_err(|e| FabroError::Parse(format!("var expansion failed: {e}")))?
|
.map_err(|e| FabroError::Parse(format!("var expansion failed: {e}")))?
|
||||||
|
|
@ -134,9 +257,9 @@ fn apply_goal_override(graph: &mut Graph, goal_override: Option<&str>) {
|
||||||
|
|
||||||
fn persist_validated(
|
fn persist_validated(
|
||||||
validated: Validated,
|
validated: Validated,
|
||||||
options: RunCreateOptions,
|
options: PersistCreateOptions,
|
||||||
) -> Result<Persisted, FabroError> {
|
) -> Result<Persisted, FabroError> {
|
||||||
let RunCreateOptions {
|
let PersistCreateOptions {
|
||||||
settings,
|
settings,
|
||||||
run_dir,
|
run_dir,
|
||||||
run_id,
|
run_id,
|
||||||
|
|
@ -406,21 +529,15 @@ mod tests {
|
||||||
graph [goal="Test"]
|
graph [goal="Test"]
|
||||||
work [label="Work"]
|
work [label="Work"]
|
||||||
}"#;
|
}"#;
|
||||||
let err = create(
|
let err = create(CreateRequest {
|
||||||
dot,
|
workflow: WorkflowInput::DotSource {
|
||||||
RunCreateOptions {
|
source: dot.to_string(),
|
||||||
settings: FabroSettings::default(),
|
|
||||||
run_dir: None,
|
|
||||||
run_id: None,
|
|
||||||
workflow_slug: None,
|
|
||||||
labels: HashMap::new(),
|
|
||||||
base_branch: None,
|
|
||||||
working_directory: None,
|
|
||||||
host_repo_path: None,
|
|
||||||
goal_override: None,
|
|
||||||
base_dir: None,
|
base_dir: None,
|
||||||
|
workflow_slug: None,
|
||||||
},
|
},
|
||||||
)
|
run_dir: Some(tempfile::tempdir().unwrap().path().join("run")),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
|
|
||||||
match err {
|
match err {
|
||||||
|
|
@ -432,11 +549,14 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn create_persists_normalized_config() {
|
fn create_persists_normalized_config_and_initial_state() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let persisted = create(
|
let created = create(CreateRequest {
|
||||||
MINIMAL_DOT,
|
workflow: WorkflowInput::DotSource {
|
||||||
RunCreateOptions {
|
source: MINIMAL_DOT.to_string(),
|
||||||
|
base_dir: None,
|
||||||
|
workflow_slug: Some("slug".to_string()),
|
||||||
|
},
|
||||||
settings: FabroSettings {
|
settings: FabroSettings {
|
||||||
llm: Some(fabro_config::run::LlmSettings {
|
llm: Some(fabro_config::run::LlmSettings {
|
||||||
model: Some("sonnet".to_string()),
|
model: Some("sonnet".to_string()),
|
||||||
|
|
@ -447,26 +567,24 @@ mod tests {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}),
|
}),
|
||||||
|
goal: Some("override goal".to_string()),
|
||||||
dry_run: Some(true),
|
dry_run: Some(true),
|
||||||
|
labels: HashMap::from([("env".to_string(), "test".to_string())]),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
run_dir: Some(dir.path().join("run")),
|
run_dir: Some(dir.path().join("run")),
|
||||||
run_id: Some("run-123".to_string()),
|
run_id: Some("run-123".to_string()),
|
||||||
workflow_slug: Some("slug".to_string()),
|
|
||||||
labels: HashMap::from([("env".to_string(), "test".to_string())]),
|
|
||||||
base_branch: Some("main".to_string()),
|
|
||||||
working_directory: Some(dir.path().to_path_buf()),
|
|
||||||
host_repo_path: Some(dir.path().display().to_string()),
|
host_repo_path: Some(dir.path().display().to_string()),
|
||||||
goal_override: Some("override goal".to_string()),
|
base_branch: Some("main".to_string()),
|
||||||
base_dir: None,
|
..Default::default()
|
||||||
},
|
})
|
||||||
)
|
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(persisted.run_record().run_id, "run-123");
|
assert_eq!(created.run_id, "run-123");
|
||||||
assert_eq!(persisted.run_record().graph.goal(), "override goal");
|
assert_eq!(created.persisted.run_record().graph.goal(), "override goal");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
persisted
|
created
|
||||||
|
.persisted
|
||||||
.run_record()
|
.run_record()
|
||||||
.settings
|
.settings
|
||||||
.llm
|
.llm
|
||||||
|
|
@ -475,7 +593,8 @@ mod tests {
|
||||||
Some("claude-sonnet-4-6")
|
Some("claude-sonnet-4-6")
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
persisted
|
created
|
||||||
|
.persisted
|
||||||
.run_record()
|
.run_record()
|
||||||
.settings
|
.settings
|
||||||
.llm
|
.llm
|
||||||
|
|
@ -484,13 +603,54 @@ mod tests {
|
||||||
Some("anthropic")
|
Some("anthropic")
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
persisted.run_record().settings.goal.as_deref(),
|
created.persisted.run_record().settings.goal.as_deref(),
|
||||||
Some("override goal")
|
Some("override goal")
|
||||||
);
|
);
|
||||||
assert!(persisted.run_record().settings.pull_request.is_none());
|
assert!(created
|
||||||
|
.persisted
|
||||||
|
.run_record()
|
||||||
|
.settings
|
||||||
|
.pull_request
|
||||||
|
.is_none());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
persisted.run_record().workflow_slug.as_deref(),
|
created.persisted.run_record().workflow_slug.as_deref(),
|
||||||
Some("slug")
|
Some("slug")
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
crate::run_status::RunStatusRecord::load(&created.run_dir.join("status.json"))
|
||||||
|
.unwrap()
|
||||||
|
.status,
|
||||||
|
crate::run_status::RunStatus::Submitted
|
||||||
|
);
|
||||||
|
assert!(!created.run_dir.join("id.txt").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_copies_workflow_toml_snapshot() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let workflow_dir = dir.path().join("workflow");
|
||||||
|
std::fs::create_dir_all(&workflow_dir).unwrap();
|
||||||
|
std::fs::write(workflow_dir.join("workflow.fabro"), MINIMAL_DOT).unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
workflow_dir.join("workflow.toml"),
|
||||||
|
"version = 1\ngraph = \"workflow.fabro\"\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let created = create(CreateRequest {
|
||||||
|
workflow: WorkflowInput::Path(workflow_dir.join("workflow.toml")),
|
||||||
|
settings: FabroSettings {
|
||||||
|
storage_dir: Some(dir.path().join("storage")),
|
||||||
|
dry_run: Some(true),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(created.run_dir.join("workflow.toml")).unwrap(),
|
||||||
|
"version = 1\ngraph = \"workflow.fabro\"\n"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,21 @@
|
||||||
mod create;
|
mod create;
|
||||||
mod fork;
|
mod fork;
|
||||||
mod rewind;
|
mod rewind;
|
||||||
|
mod source;
|
||||||
mod start;
|
mod start;
|
||||||
|
|
||||||
pub use crate::pipeline::{DevcontainerSpec, LlmSpec, SandboxEnvSpec, SandboxSpec};
|
pub use crate::pipeline::{DevcontainerSpec, LlmSpec, SandboxEnvSpec, SandboxSpec};
|
||||||
pub use create::{
|
pub use create::{
|
||||||
create, create_from_file, default_run_dir, make_run_dir, validate, validate_from_file,
|
create, default_run_dir, make_run_dir, validate, validate_from_file, CreateRequest, CreatedRun,
|
||||||
RunCreateOptions, ValidateOptions,
|
ValidateOptions,
|
||||||
};
|
};
|
||||||
pub use fork::fork;
|
pub use fork::fork;
|
||||||
pub use rewind::{
|
pub use rewind::{
|
||||||
build_timeline, find_run_id_by_prefix, load_parallel_map, parse_target, resolve_target, rewind,
|
build_timeline, find_run_id_by_prefix, load_parallel_map, parse_target, resolve_target, rewind,
|
||||||
TimelineEntry,
|
TimelineEntry,
|
||||||
};
|
};
|
||||||
pub use start::{
|
pub use source::{
|
||||||
resume, start, StartFinalizeOptions, StartOptions, StartPullRequestConfig, StartRetroOptions,
|
resolve_settings_for_path, resolve_workflow, resolve_workflow_path, ResolveWorkflowRequest,
|
||||||
Started,
|
ResolvedWorkflow, WorkflowInput, WorkflowPathResolution,
|
||||||
};
|
};
|
||||||
|
pub use start::{resume, start, StartServices, Started};
|
||||||
|
|
|
||||||
|
|
@ -327,9 +327,12 @@ pub fn load_parallel_map(store: &Store, run_id: &str) -> HashMap<String, String>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let graph_bytes = match bs.read_entry("graph.fabro") {
|
let graph_bytes = match bs.read_entry("workflow.fabro") {
|
||||||
|
Ok(Some(bytes)) => bytes,
|
||||||
|
_ => match bs.read_entry("graph.fabro") {
|
||||||
Ok(Some(bytes)) => bytes,
|
Ok(Some(bytes)) => bytes,
|
||||||
_ => return HashMap::new(),
|
_ => return HashMap::new(),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
let dot_source = String::from_utf8_lossy(&graph_bytes);
|
let dot_source = String::from_utf8_lossy(&graph_bytes);
|
||||||
let graph = match fabro_graphviz::parser::parse(&dot_source) {
|
let graph = match fabro_graphviz::parser::parse(&dot_source) {
|
||||||
|
|
|
||||||
235
lib/crates/fabro-workflows/src/operations/source.rs
Normal file
235
lib/crates/fabro-workflows/src/operations/source.rs
Normal file
|
|
@ -0,0 +1,235 @@
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use anyhow::Context;
|
||||||
|
use fabro_config::{project as project_config, run as run_config, FabroConfig, FabroSettings};
|
||||||
|
|
||||||
|
const RUN_GRAPH_FILE: &str = "workflow.fabro";
|
||||||
|
const LEGACY_RUN_GRAPH_FILE: &str = "graph.fabro";
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub enum WorkflowInput {
|
||||||
|
Path(PathBuf),
|
||||||
|
DotSource {
|
||||||
|
source: String,
|
||||||
|
base_dir: Option<PathBuf>,
|
||||||
|
workflow_slug: Option<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct WorkflowPathResolution {
|
||||||
|
pub resolved_workflow_path: PathBuf,
|
||||||
|
pub dot_path: PathBuf,
|
||||||
|
pub workflow_config: Option<FabroConfig>,
|
||||||
|
pub workflow_toml_path: Option<PathBuf>,
|
||||||
|
pub workflow_slug: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct ResolveWorkflowRequest {
|
||||||
|
pub workflow: WorkflowInput,
|
||||||
|
pub settings: FabroSettings,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct ResolvedWorkflow {
|
||||||
|
pub raw_source: String,
|
||||||
|
pub settings: FabroSettings,
|
||||||
|
pub workflow_slug: Option<String>,
|
||||||
|
pub workflow_toml_path: Option<PathBuf>,
|
||||||
|
pub dot_path: Option<PathBuf>,
|
||||||
|
pub resolved_workflow_path: Option<PathBuf>,
|
||||||
|
pub base_dir: Option<PathBuf>,
|
||||||
|
pub goal_override: Option<String>,
|
||||||
|
pub working_directory: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_goal_file(
|
||||||
|
goal_file: Option<&Path>,
|
||||||
|
working_directory: &Path,
|
||||||
|
) -> anyhow::Result<Option<String>> {
|
||||||
|
let Some(goal_file) = goal_file else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let expanded = fabro_util::path::expand_tilde(goal_file);
|
||||||
|
let goal_path = if expanded.is_absolute() {
|
||||||
|
expanded
|
||||||
|
} else {
|
||||||
|
working_directory.join(expanded)
|
||||||
|
};
|
||||||
|
let content = std::fs::read_to_string(&goal_path)
|
||||||
|
.with_context(|| format!("failed to read goal file: {}", goal_path.display()))?;
|
||||||
|
tracing::debug!(path = %goal_path.display(), "Goal loaded from file");
|
||||||
|
Ok(Some(content))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_working_directory(settings: &FabroSettings, caller_cwd: &Path) -> PathBuf {
|
||||||
|
let Some(work_dir) = settings.work_dir.as_deref() else {
|
||||||
|
return caller_cwd.to_path_buf();
|
||||||
|
};
|
||||||
|
let path = PathBuf::from(work_dir);
|
||||||
|
if path.is_absolute() {
|
||||||
|
path
|
||||||
|
} else {
|
||||||
|
caller_cwd.join(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn workflow_slug_from_path(workflow_path: &Path) -> Option<String> {
|
||||||
|
let file_name = workflow_path.file_name()?.to_string_lossy();
|
||||||
|
if workflow_path.extension().is_none() {
|
||||||
|
return Some(file_name.into_owned());
|
||||||
|
}
|
||||||
|
|
||||||
|
let file_stem = workflow_path.file_stem()?.to_string_lossy();
|
||||||
|
if file_stem == "workflow" {
|
||||||
|
return workflow_path
|
||||||
|
.parent()
|
||||||
|
.and_then(|p| p.file_name())
|
||||||
|
.map(|n| n.to_string_lossy().into_owned())
|
||||||
|
.or_else(|| Some(file_stem.into_owned()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(file_stem.into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_workflow_path(workflow_path: &Path) -> anyhow::Result<WorkflowPathResolution> {
|
||||||
|
let path = project_config::resolve_workflow_arg(workflow_path)?;
|
||||||
|
let workflow_slug = workflow_slug_from_path(&path);
|
||||||
|
if path.extension().is_some_and(|ext| ext == "toml") {
|
||||||
|
match run_config::load_run_config(&path) {
|
||||||
|
Ok(cfg) => {
|
||||||
|
let dot_path = run_config::resolve_graph_path(
|
||||||
|
&path,
|
||||||
|
cfg.graph.as_deref().unwrap_or(RUN_GRAPH_FILE),
|
||||||
|
);
|
||||||
|
Ok(WorkflowPathResolution {
|
||||||
|
resolved_workflow_path: path.clone(),
|
||||||
|
dot_path,
|
||||||
|
workflow_config: Some(cfg),
|
||||||
|
workflow_toml_path: Some(path),
|
||||||
|
workflow_slug,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(_)
|
||||||
|
if !path.exists() && path.starts_with(crate::run_lookup::default_runs_base()) =>
|
||||||
|
{
|
||||||
|
let canonical = path.with_file_name(RUN_GRAPH_FILE);
|
||||||
|
let legacy = path.with_file_name(LEGACY_RUN_GRAPH_FILE);
|
||||||
|
let dot_path = if canonical.exists() || !legacy.exists() {
|
||||||
|
canonical
|
||||||
|
} else {
|
||||||
|
legacy
|
||||||
|
};
|
||||||
|
Ok(WorkflowPathResolution {
|
||||||
|
resolved_workflow_path: path,
|
||||||
|
dot_path,
|
||||||
|
workflow_config: None,
|
||||||
|
workflow_toml_path: None,
|
||||||
|
workflow_slug,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(err) => Err(err),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Ok(WorkflowPathResolution {
|
||||||
|
resolved_workflow_path: path.clone(),
|
||||||
|
dot_path: path,
|
||||||
|
workflow_config: None,
|
||||||
|
workflow_toml_path: None,
|
||||||
|
workflow_slug,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_settings_for_path(
|
||||||
|
workflow_path: &Path,
|
||||||
|
defaults: FabroConfig,
|
||||||
|
overrides: FabroConfig,
|
||||||
|
apply_project_config: bool,
|
||||||
|
) -> anyhow::Result<FabroSettings> {
|
||||||
|
let resolution = resolve_workflow_path(workflow_path)?;
|
||||||
|
if resolution.workflow_config.is_none() && !resolution.resolved_workflow_path.is_file() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"Workflow not found: {}",
|
||||||
|
resolution.resolved_workflow_path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let project_config = if apply_project_config {
|
||||||
|
project_config::discover_project_config(
|
||||||
|
resolution
|
||||||
|
.resolved_workflow_path
|
||||||
|
.parent()
|
||||||
|
.unwrap_or_else(|| Path::new(".")),
|
||||||
|
)?
|
||||||
|
.map(|(_, config)| config)
|
||||||
|
.unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
FabroConfig::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
overrides
|
||||||
|
.combine(resolution.workflow_config.unwrap_or_default())
|
||||||
|
.combine(project_config)
|
||||||
|
.combine(defaults)
|
||||||
|
.try_into()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_workflow(request: ResolveWorkflowRequest) -> anyhow::Result<ResolvedWorkflow> {
|
||||||
|
let caller_cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
||||||
|
match request.workflow {
|
||||||
|
WorkflowInput::Path(workflow_path) => {
|
||||||
|
let resolution = resolve_workflow_path(&workflow_path)?;
|
||||||
|
let settings = request.settings;
|
||||||
|
let working_directory = resolve_working_directory(&settings, &caller_cwd);
|
||||||
|
let raw_source = std::fs::read_to_string(&resolution.dot_path)
|
||||||
|
.with_context(|| format!("Failed to read {}", resolution.dot_path.display()))?;
|
||||||
|
let goal_override = settings.goal.clone().or(resolve_goal_file(
|
||||||
|
settings.goal_file.as_deref(),
|
||||||
|
&working_directory,
|
||||||
|
)?);
|
||||||
|
|
||||||
|
Ok(ResolvedWorkflow {
|
||||||
|
raw_source,
|
||||||
|
settings,
|
||||||
|
workflow_slug: resolution.workflow_slug,
|
||||||
|
workflow_toml_path: resolution.workflow_toml_path,
|
||||||
|
dot_path: Some(resolution.dot_path.clone()),
|
||||||
|
resolved_workflow_path: Some(resolution.resolved_workflow_path),
|
||||||
|
base_dir: Some(
|
||||||
|
resolution
|
||||||
|
.dot_path
|
||||||
|
.parent()
|
||||||
|
.unwrap_or_else(|| Path::new("."))
|
||||||
|
.to_path_buf(),
|
||||||
|
),
|
||||||
|
goal_override,
|
||||||
|
working_directory,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
WorkflowInput::DotSource {
|
||||||
|
source,
|
||||||
|
base_dir,
|
||||||
|
workflow_slug,
|
||||||
|
} => {
|
||||||
|
let settings = request.settings;
|
||||||
|
let working_directory = resolve_working_directory(&settings, &caller_cwd);
|
||||||
|
let goal_override = settings.goal.clone().or(resolve_goal_file(
|
||||||
|
settings.goal_file.as_deref(),
|
||||||
|
&working_directory,
|
||||||
|
)?);
|
||||||
|
Ok(ResolvedWorkflow {
|
||||||
|
raw_source: source,
|
||||||
|
settings,
|
||||||
|
workflow_slug,
|
||||||
|
workflow_toml_path: None,
|
||||||
|
dot_path: None,
|
||||||
|
resolved_workflow_path: None,
|
||||||
|
base_dir,
|
||||||
|
goal_override,
|
||||||
|
working_directory,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,59 +1,74 @@
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::atomic::AtomicBool;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
|
use fabro_config::sandbox::WorktreeMode;
|
||||||
|
use fabro_config::FabroSettings;
|
||||||
|
use fabro_config::{project as project_config, run as run_config, sandbox as sandbox_config};
|
||||||
|
use fabro_interview::{AutoApproveInterviewer, Interviewer};
|
||||||
|
use fabro_model::{Catalog, FallbackTarget, Provider};
|
||||||
|
use fabro_sandbox::SandboxProvider;
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
use crate::context::Context;
|
use crate::context::Context;
|
||||||
use crate::error::FabroError;
|
use crate::error::FabroError;
|
||||||
use crate::event::{EventEmitter, ProgressLogger, WorkflowRunEvent};
|
use crate::event::{EventEmitter, ProgressLogger, RunNoticeLevel, WorkflowRunEvent};
|
||||||
use crate::outcome::StageStatus;
|
use crate::outcome::{Outcome, StageStatus};
|
||||||
use crate::pipeline::{
|
use crate::pipeline::{
|
||||||
self, DevcontainerSpec, FinalizeOptions, Finalized, InitOptions, LlmSpec, Persisted,
|
self, build_conclusion, classify_engine_result, persist_terminal_outcome, DevcontainerSpec,
|
||||||
PullRequestOptions, RetroOptions, SandboxEnvSpec, SandboxSpec,
|
FinalizeOptions, Finalized, InitOptions, LlmSpec, Persisted, PullRequestOptions, RetroOptions,
|
||||||
|
SandboxEnvSpec, SandboxSpec,
|
||||||
};
|
};
|
||||||
use crate::records::{Checkpoint, Conclusion};
|
use crate::records::{Checkpoint, Conclusion};
|
||||||
use crate::run_options::{GitCheckpointOptions, LifecycleOptions, RunOptions};
|
use crate::run_options::{GitCheckpointOptions, LifecycleOptions, RunOptions};
|
||||||
use fabro_config::sandbox::WorktreeMode;
|
use crate::run_status::{self, RunStatus, StatusReason};
|
||||||
use fabro_interview::Interviewer;
|
|
||||||
|
|
||||||
pub struct StartRetroOptions {
|
struct StartRetroOptions {
|
||||||
pub enabled: bool,
|
enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct StartFinalizeOptions {
|
struct StartFinalizeOptions {
|
||||||
pub preserve_sandbox: bool,
|
preserve_sandbox: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct StartPullRequestConfig {
|
struct StartPullRequestConfig {
|
||||||
pub pr_config: Option<fabro_config::run::PullRequestSettings>,
|
pr_config: Option<fabro_config::run::PullRequestSettings>,
|
||||||
pub github_app: Option<fabro_github::GitHubAppCredentials>,
|
github_app: Option<fabro_github::GitHubAppCredentials>,
|
||||||
pub origin_url: Option<String>,
|
origin_url: Option<String>,
|
||||||
pub model: String,
|
model: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Options for `start()` and `resume()`.
|
struct InternalStartOptions {
|
||||||
///
|
cancel_token: Option<Arc<AtomicBool>>,
|
||||||
/// Fields that are derivable from `RunRecord` (run_id, labels, base_branch,
|
emitter: Arc<EventEmitter>,
|
||||||
/// host_repo_path, settings, workflow_slug) are read from disk by `run_engine()`.
|
sandbox: SandboxSpec,
|
||||||
/// Callers only provide truly external values.
|
llm: LlmSpec,
|
||||||
pub struct StartOptions {
|
interviewer: Arc<dyn Interviewer>,
|
||||||
// Truly external (not derivable from RunRecord)
|
lifecycle: LifecycleOptions,
|
||||||
pub cancel_token: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
|
hooks: fabro_hooks::HookConfig,
|
||||||
|
sandbox_env: SandboxEnvSpec,
|
||||||
|
devcontainer: Option<DevcontainerSpec>,
|
||||||
|
seed_context: Option<Context>,
|
||||||
|
git_author: crate::git::GitAuthor,
|
||||||
|
git: Option<GitCheckpointOptions>,
|
||||||
|
github_app: Option<fabro_github::GitHubAppCredentials>,
|
||||||
|
worktree_mode: Option<WorktreeMode>,
|
||||||
|
registry_override: Option<Arc<crate::handler::HandlerRegistry>>,
|
||||||
|
retro: StartRetroOptions,
|
||||||
|
finalize: StartFinalizeOptions,
|
||||||
|
pull_request: StartPullRequestConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct StartServices {
|
||||||
|
pub cancel_token: Option<Arc<AtomicBool>>,
|
||||||
pub emitter: Arc<EventEmitter>,
|
pub emitter: Arc<EventEmitter>,
|
||||||
pub sandbox: SandboxSpec,
|
|
||||||
pub llm: LlmSpec,
|
|
||||||
pub interviewer: Arc<dyn Interviewer>,
|
pub interviewer: Arc<dyn Interviewer>,
|
||||||
pub lifecycle: LifecycleOptions,
|
|
||||||
pub hooks: fabro_hooks::HookConfig,
|
|
||||||
pub sandbox_env: SandboxEnvSpec,
|
|
||||||
pub devcontainer: Option<DevcontainerSpec>,
|
|
||||||
pub seed_context: Option<Context>,
|
|
||||||
pub git_author: crate::git::GitAuthor,
|
pub git_author: crate::git::GitAuthor,
|
||||||
pub git: Option<GitCheckpointOptions>,
|
|
||||||
pub github_app: Option<fabro_github::GitHubAppCredentials>,
|
pub github_app: Option<fabro_github::GitHubAppCredentials>,
|
||||||
pub worktree_mode: Option<WorktreeMode>,
|
|
||||||
pub registry_override: Option<Arc<crate::handler::HandlerRegistry>>,
|
pub registry_override: Option<Arc<crate::handler::HandlerRegistry>>,
|
||||||
pub retro: StartRetroOptions,
|
|
||||||
pub finalize: StartFinalizeOptions,
|
|
||||||
pub pull_request: StartPullRequestConfig,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Started {
|
pub struct Started {
|
||||||
|
|
@ -63,26 +78,29 @@ pub struct Started {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start a fresh workflow run. Errors if a checkpoint already exists (use `resume()` instead).
|
/// Start a fresh workflow run. Errors if a checkpoint already exists (use `resume()` instead).
|
||||||
pub async fn start(
|
pub async fn start(run_dir: &Path, services: StartServices) -> Result<Started, FabroError> {
|
||||||
run_dir: &std::path::Path,
|
|
||||||
options: StartOptions,
|
|
||||||
) -> Result<Started, FabroError> {
|
|
||||||
if run_dir.join("checkpoint.json").exists() {
|
if run_dir.join("checkpoint.json").exists() {
|
||||||
return Err(FabroError::Precondition(
|
return Err(FabroError::Precondition(
|
||||||
"checkpoint.json exists in run directory — did you mean to resume?".to_string(),
|
"checkpoint.json exists in run directory — did you mean to resume?".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let persisted = Persisted::load(run_dir)?;
|
|
||||||
run_engine(persisted, None, options).await
|
if let Ok(record) = run_status::RunStatusRecord::load(&run_dir.join("status.json")) {
|
||||||
|
if !matches!(record.status, RunStatus::Submitted | RunStatus::Starting) {
|
||||||
|
return Err(FabroError::Precondition(format!(
|
||||||
|
"cannot start run: status is {:?}, expected submitted",
|
||||||
|
record.status
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
execute_persisted_run(run_dir, None, services).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resume a workflow run from its checkpoint. Errors if no checkpoint is found.
|
/// Resume a workflow run from its checkpoint. Errors if no checkpoint is found.
|
||||||
pub async fn resume(
|
pub async fn resume(run_dir: &Path, services: StartServices) -> Result<Started, FabroError> {
|
||||||
run_dir: &std::path::Path,
|
if let Ok(record) = run_status::RunStatusRecord::load(&run_dir.join("status.json")) {
|
||||||
options: StartOptions,
|
if record.status == RunStatus::Succeeded {
|
||||||
) -> Result<Started, FabroError> {
|
|
||||||
if let Ok(record) = crate::run_status::RunStatusRecord::load(&run_dir.join("status.json")) {
|
|
||||||
if record.status == crate::run_status::RunStatus::Succeeded {
|
|
||||||
return Err(FabroError::Precondition(
|
return Err(FabroError::Precondition(
|
||||||
"run already finished successfully — nothing to resume".to_string(),
|
"run already finished successfully — nothing to resume".to_string(),
|
||||||
));
|
));
|
||||||
|
|
@ -98,22 +116,358 @@ pub async fn resume(
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let cp_path = run_dir.join("checkpoint.json");
|
let cp_path = run_dir.join("checkpoint.json");
|
||||||
let checkpoint = Checkpoint::load(&cp_path)
|
let checkpoint = Checkpoint::load(&cp_path)
|
||||||
.map_err(|e| FabroError::Precondition(format!("no checkpoint to resume from: {e}")))?;
|
.map_err(|e| FabroError::Precondition(format!("no checkpoint to resume from: {e}")))?;
|
||||||
let persisted = Persisted::load(run_dir)?;
|
|
||||||
run_engine(persisted, Some(checkpoint), options).await
|
cleanup_resume_artifacts(run_dir);
|
||||||
|
run_status::write_run_status(run_dir, RunStatus::Submitted, None);
|
||||||
|
|
||||||
|
execute_persisted_run(run_dir, Some(checkpoint), services).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute_persisted_run(
|
||||||
|
run_dir: &Path,
|
||||||
|
checkpoint: Option<Checkpoint>,
|
||||||
|
services: StartServices,
|
||||||
|
) -> Result<Started, FabroError> {
|
||||||
|
let mut bootstrap_guard = DetachedRunBootstrapGuard::arm(run_dir)?;
|
||||||
|
|
||||||
|
let persisted = match Persisted::load(run_dir) {
|
||||||
|
Ok(persisted) => persisted,
|
||||||
|
Err(err) => {
|
||||||
|
let _ =
|
||||||
|
persist_detached_failure(run_dir, "bootstrap", StatusReason::BootstrapFailed, &err);
|
||||||
|
bootstrap_guard.defuse();
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let original_cwd = std::env::current_dir().ok();
|
||||||
|
if let Err(err) = std::env::set_current_dir(&persisted.run_record().working_directory) {
|
||||||
|
let err = FabroError::Io(format!(
|
||||||
|
"Failed to set working directory to {}: {err}",
|
||||||
|
persisted.run_record().working_directory.display()
|
||||||
|
));
|
||||||
|
let _ = persist_detached_failure(run_dir, "bootstrap", StatusReason::BootstrapFailed, &err);
|
||||||
|
bootstrap_guard.defuse();
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
let _cwd_guard = scopeguard::guard(original_cwd, |cwd| {
|
||||||
|
if let Some(cwd) = cwd {
|
||||||
|
let _ = std::env::set_current_dir(cwd);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let options = match derive_start_options(&persisted, services) {
|
||||||
|
Ok(options) => options,
|
||||||
|
Err(err) => {
|
||||||
|
let _ =
|
||||||
|
persist_detached_failure(run_dir, "bootstrap", StatusReason::BootstrapFailed, &err);
|
||||||
|
bootstrap_guard.defuse();
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
bootstrap_guard.defuse();
|
||||||
|
let mut completion_guard = DetachedRunCompletionGuard::arm(run_dir);
|
||||||
|
let run_start = Instant::now();
|
||||||
|
let started = run_engine(persisted, checkpoint, options).await;
|
||||||
|
|
||||||
|
match started {
|
||||||
|
Ok(started) => {
|
||||||
|
completion_guard.defuse();
|
||||||
|
Ok(started)
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
persist_terminal_engine_failure(run_dir, &err, run_start.elapsed());
|
||||||
|
completion_guard.defuse();
|
||||||
|
Err(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn persist_terminal_engine_failure(run_dir: &Path, error: &FabroError, duration: Duration) {
|
||||||
|
let engine_result: Result<Outcome, FabroError> = Err(error.clone());
|
||||||
|
let (final_status, failure_reason, run_status, status_reason) =
|
||||||
|
classify_engine_result(&engine_result);
|
||||||
|
let conclusion = build_conclusion(
|
||||||
|
run_dir,
|
||||||
|
final_status,
|
||||||
|
failure_reason,
|
||||||
|
duration.as_millis() as u64,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
persist_terminal_outcome(run_dir, &conclusion, run_status, status_reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cleanup_resume_artifacts(run_dir: &Path) {
|
||||||
|
for name in [
|
||||||
|
"conclusion.json",
|
||||||
|
"pull_request.json",
|
||||||
|
"detached_failure.json",
|
||||||
|
"interview_request.json",
|
||||||
|
"interview_response.json",
|
||||||
|
"interview_request.claim",
|
||||||
|
"progress.jsonl",
|
||||||
|
] {
|
||||||
|
let _ = std::fs::remove_file(run_dir.join(name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn derive_start_options(
|
||||||
|
persisted: &Persisted,
|
||||||
|
services: StartServices,
|
||||||
|
) -> Result<InternalStartOptions, FabroError> {
|
||||||
|
let record = persisted.run_record();
|
||||||
|
let mut settings = record.settings.clone();
|
||||||
|
let working_directory = record.working_directory.clone();
|
||||||
|
|
||||||
|
if let Some(env) = settings
|
||||||
|
.sandbox
|
||||||
|
.as_mut()
|
||||||
|
.and_then(|sandbox| sandbox.env.as_mut())
|
||||||
|
{
|
||||||
|
run_config::resolve_env_refs(env)
|
||||||
|
.map_err(|err| FabroError::Precondition(err.to_string()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (origin_url, detected_base_branch) =
|
||||||
|
fabro_sandbox::daytona::detect_repo_info(&working_directory)
|
||||||
|
.map(|(url, branch)| (Some(url), branch))
|
||||||
|
.unwrap_or((None, None));
|
||||||
|
|
||||||
|
let sandbox_provider = resolve_sandbox_provider(&settings)?;
|
||||||
|
let sandbox_provider =
|
||||||
|
if settings.dry_run_enabled() && !matches!(sandbox_provider, SandboxProvider::Local) {
|
||||||
|
SandboxProvider::Local
|
||||||
|
} else {
|
||||||
|
sandbox_provider
|
||||||
|
};
|
||||||
|
let model = settings
|
||||||
|
.llm
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|llm| llm.model.clone())
|
||||||
|
.unwrap_or_else(|| Catalog::builtin().default_from_env().id.clone());
|
||||||
|
let provider = settings
|
||||||
|
.llm
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|llm| llm.provider.clone())
|
||||||
|
.filter(|value| !value.is_empty());
|
||||||
|
|
||||||
|
let provider_enum: Provider = provider
|
||||||
|
.as_deref()
|
||||||
|
.map(|value| value.parse::<Provider>())
|
||||||
|
.transpose()
|
||||||
|
.map_err(|err| FabroError::Precondition(err.to_string()))?
|
||||||
|
.unwrap_or_else(Provider::default_from_env);
|
||||||
|
|
||||||
|
let fallback_chain = resolve_fallback_chain(provider_enum, &model, &settings);
|
||||||
|
let mcp_servers = settings
|
||||||
|
.mcp_server_entries()
|
||||||
|
.clone()
|
||||||
|
.into_iter()
|
||||||
|
.map(|(name, entry)| entry.into_config(name))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let sandbox = match sandbox_provider {
|
||||||
|
SandboxProvider::Local => SandboxSpec::Local {
|
||||||
|
working_directory: working_directory.clone(),
|
||||||
|
},
|
||||||
|
SandboxProvider::Docker => SandboxSpec::Docker {
|
||||||
|
config: fabro_agent::DockerSandboxConfig {
|
||||||
|
host_working_directory: working_directory.to_string_lossy().to_string(),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
SandboxProvider::Daytona => SandboxSpec::Daytona {
|
||||||
|
config: resolve_daytona_config(&settings).unwrap_or_default(),
|
||||||
|
github_app: services.github_app.clone(),
|
||||||
|
run_id: Some(record.run_id.clone()),
|
||||||
|
clone_branch: detected_base_branch.or_else(|| record.base_branch.clone()),
|
||||||
|
},
|
||||||
|
#[cfg(feature = "exedev")]
|
||||||
|
SandboxProvider::Exe => SandboxSpec::Exe {
|
||||||
|
config: resolve_exe_config(&settings).unwrap_or_default(),
|
||||||
|
clone_params: resolve_exe_clone_params(&working_directory),
|
||||||
|
run_id: Some(record.run_id.clone()),
|
||||||
|
github_app: services.github_app.clone(),
|
||||||
|
mgmt_destination: "exe.dev".to_string(),
|
||||||
|
},
|
||||||
|
#[cfg(not(feature = "exedev"))]
|
||||||
|
SandboxProvider::Exe => {
|
||||||
|
return Err(FabroError::Precondition(
|
||||||
|
"exe sandbox requires the exedev feature".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
SandboxProvider::Ssh => SandboxSpec::Ssh {
|
||||||
|
config: resolve_ssh_config(&settings).ok_or_else(|| {
|
||||||
|
FabroError::Precondition("--sandbox ssh requires [sandbox.ssh] config".to_string())
|
||||||
|
})?,
|
||||||
|
clone_params: resolve_ssh_clone_params(&working_directory),
|
||||||
|
run_id: Some(record.run_id.clone()),
|
||||||
|
github_app: services.github_app.clone(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let sandbox_env = SandboxEnvSpec {
|
||||||
|
devcontainer_env: HashMap::new(),
|
||||||
|
toml_env: settings
|
||||||
|
.sandbox_settings()
|
||||||
|
.and_then(|sandbox| sandbox.env.clone())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
github_permissions: settings.github_permissions().cloned(),
|
||||||
|
origin_url: origin_url.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let devcontainer = settings
|
||||||
|
.sandbox_settings()
|
||||||
|
.and_then(|sandbox| sandbox.devcontainer)
|
||||||
|
.unwrap_or(false)
|
||||||
|
.then(|| DevcontainerSpec {
|
||||||
|
enabled: true,
|
||||||
|
resolve_dir: working_directory.clone(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let interviewer: Arc<dyn Interviewer> = if settings.auto_approve_enabled() {
|
||||||
|
Arc::new(AutoApproveInterviewer)
|
||||||
|
} else {
|
||||||
|
services.interviewer
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(InternalStartOptions {
|
||||||
|
cancel_token: services.cancel_token,
|
||||||
|
emitter: services.emitter,
|
||||||
|
sandbox,
|
||||||
|
llm: LlmSpec {
|
||||||
|
model: model.clone(),
|
||||||
|
provider: provider_enum,
|
||||||
|
fallback_chain,
|
||||||
|
mcp_servers,
|
||||||
|
dry_run: settings.dry_run_enabled(),
|
||||||
|
},
|
||||||
|
interviewer,
|
||||||
|
lifecycle: LifecycleOptions {
|
||||||
|
setup_commands: settings.setup_commands().to_vec(),
|
||||||
|
setup_command_timeout_ms: settings.setup_timeout_ms().unwrap_or(300_000),
|
||||||
|
devcontainer_phases: Vec::new(),
|
||||||
|
},
|
||||||
|
hooks: fabro_hooks::HookConfig {
|
||||||
|
hooks: settings.hooks.clone(),
|
||||||
|
},
|
||||||
|
sandbox_env,
|
||||||
|
devcontainer,
|
||||||
|
seed_context: None,
|
||||||
|
git_author: services.git_author,
|
||||||
|
git: None,
|
||||||
|
github_app: services.github_app.clone(),
|
||||||
|
worktree_mode: Some(resolve_worktree_mode(&settings)),
|
||||||
|
registry_override: services.registry_override,
|
||||||
|
retro: StartRetroOptions {
|
||||||
|
enabled: !settings.no_retro_enabled() && project_config::is_retro_enabled(),
|
||||||
|
},
|
||||||
|
finalize: StartFinalizeOptions {
|
||||||
|
preserve_sandbox: resolve_preserve_sandbox(&settings),
|
||||||
|
},
|
||||||
|
pull_request: StartPullRequestConfig {
|
||||||
|
pr_config: settings.pull_request.clone(),
|
||||||
|
github_app: services.github_app,
|
||||||
|
origin_url,
|
||||||
|
model,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_sandbox_provider(settings: &FabroSettings) -> Result<SandboxProvider, FabroError> {
|
||||||
|
settings
|
||||||
|
.sandbox_settings()
|
||||||
|
.and_then(|sandbox| sandbox.provider.as_deref())
|
||||||
|
.map(|provider| provider.parse::<SandboxProvider>())
|
||||||
|
.transpose()
|
||||||
|
.map_err(|err| FabroError::Precondition(format!("Invalid sandbox provider: {err}")))?
|
||||||
|
.map_or_else(|| Ok(SandboxProvider::default()), Ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_preserve_sandbox(settings: &FabroSettings) -> bool {
|
||||||
|
settings.preserve_sandbox_enabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_worktree_mode(settings: &FabroSettings) -> sandbox_config::WorktreeMode {
|
||||||
|
settings
|
||||||
|
.sandbox_settings()
|
||||||
|
.and_then(|sandbox| sandbox.local.as_ref())
|
||||||
|
.map(|local| local.worktree_mode)
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_daytona_config(
|
||||||
|
settings: &FabroSettings,
|
||||||
|
) -> Option<fabro_sandbox::daytona::DaytonaConfig> {
|
||||||
|
settings
|
||||||
|
.sandbox_settings()
|
||||||
|
.and_then(|sandbox| sandbox.daytona.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "exedev")]
|
||||||
|
fn resolve_exe_config(settings: &FabroSettings) -> Option<fabro_sandbox::exe::ExeConfig> {
|
||||||
|
settings
|
||||||
|
.sandbox_settings()
|
||||||
|
.and_then(|sandbox| sandbox.exe.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "exedev")]
|
||||||
|
fn resolve_exe_clone_params(cwd: &Path) -> Option<fabro_sandbox::exe::GitCloneParams> {
|
||||||
|
let (detected_url, branch) = match fabro_sandbox::daytona::detect_repo_info(cwd) {
|
||||||
|
Ok(info) => info,
|
||||||
|
Err(err) => {
|
||||||
|
tracing::warn!("No git repo detected for exe.dev clone: {err}");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let url = fabro_github::ssh_url_to_https(&detected_url);
|
||||||
|
Some(fabro_sandbox::exe::GitCloneParams { url, branch })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_ssh_config(settings: &FabroSettings) -> Option<fabro_sandbox::ssh::SshConfig> {
|
||||||
|
settings
|
||||||
|
.sandbox_settings()
|
||||||
|
.and_then(|sandbox| sandbox.ssh.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_ssh_clone_params(cwd: &Path) -> Option<fabro_sandbox::ssh::GitCloneParams> {
|
||||||
|
let (detected_url, branch) = match fabro_sandbox::daytona::detect_repo_info(cwd) {
|
||||||
|
Ok(info) => info,
|
||||||
|
Err(err) => {
|
||||||
|
tracing::warn!("No git repo detected for SSH clone: {err}");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let url = fabro_github::ssh_url_to_https(&detected_url);
|
||||||
|
Some(fabro_sandbox::ssh::GitCloneParams { url, branch })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_fallback_chain(
|
||||||
|
provider: Provider,
|
||||||
|
model: &str,
|
||||||
|
settings: &FabroSettings,
|
||||||
|
) -> Vec<FallbackTarget> {
|
||||||
|
let fallbacks = settings.llm.as_ref().and_then(|llm| llm.fallbacks.as_ref());
|
||||||
|
|
||||||
|
match fallbacks {
|
||||||
|
Some(map) => Catalog::builtin().build_fallback_chain(provider, model, map),
|
||||||
|
None => Vec::new(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shared engine: initialize, execute, retro, finalize, pull_request.
|
/// Shared engine: initialize, execute, retro, finalize, pull_request.
|
||||||
async fn run_engine(
|
async fn run_engine(
|
||||||
persisted: Persisted,
|
persisted: Persisted,
|
||||||
checkpoint: Option<Checkpoint>,
|
checkpoint: Option<Checkpoint>,
|
||||||
options: StartOptions,
|
options: InternalStartOptions,
|
||||||
) -> Result<Started, FabroError> {
|
) -> Result<Started, FabroError> {
|
||||||
let preserve_sandbox = options.finalize.preserve_sandbox;
|
let preserve_sandbox = options.finalize.preserve_sandbox;
|
||||||
|
|
||||||
// Build RunOptions from the persisted RunRecord + external caller options.
|
|
||||||
let record = persisted.run_record();
|
let record = persisted.run_record();
|
||||||
let run_options = RunOptions {
|
let run_options = RunOptions {
|
||||||
settings: record.settings.clone(),
|
settings: record.settings.clone(),
|
||||||
|
|
@ -124,10 +478,7 @@ async fn run_engine(
|
||||||
git_author: options.git_author,
|
git_author: options.git_author,
|
||||||
workflow_slug: record.workflow_slug.clone(),
|
workflow_slug: record.workflow_slug.clone(),
|
||||||
github_app: options.github_app.clone(),
|
github_app: options.github_app.clone(),
|
||||||
host_repo_path: record
|
host_repo_path: record.host_repo_path.as_deref().map(PathBuf::from),
|
||||||
.host_repo_path
|
|
||||||
.as_deref()
|
|
||||||
.map(std::path::PathBuf::from),
|
|
||||||
base_branch: record.base_branch.clone(),
|
base_branch: record.base_branch.clone(),
|
||||||
display_base_sha: None,
|
display_base_sha: None,
|
||||||
git: options.git.clone(),
|
git: options.git.clone(),
|
||||||
|
|
@ -241,13 +592,188 @@ async fn run_engine(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct DetachedRunBootstrapGuard {
|
||||||
|
run_dir: PathBuf,
|
||||||
|
active: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DetachedRunBootstrapGuard {
|
||||||
|
fn arm(run_dir: &Path) -> Result<Self, FabroError> {
|
||||||
|
run_status::write_run_status(
|
||||||
|
run_dir,
|
||||||
|
RunStatus::Starting,
|
||||||
|
Some(StatusReason::SandboxInitializing),
|
||||||
|
);
|
||||||
|
Ok(Self {
|
||||||
|
run_dir: run_dir.to_path_buf(),
|
||||||
|
active: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn defuse(&mut self) {
|
||||||
|
self.active = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for DetachedRunBootstrapGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if self.active {
|
||||||
|
run_status::write_run_status(
|
||||||
|
&self.run_dir,
|
||||||
|
RunStatus::Failed,
|
||||||
|
Some(StatusReason::SandboxInitFailed),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const POSTRUN_ABORTED_MESSAGE: &str = "Run aborted before post-run finalization completed.";
|
||||||
|
|
||||||
|
struct DetachedRunCompletionGuard {
|
||||||
|
run_dir: PathBuf,
|
||||||
|
active: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DetachedRunCompletionGuard {
|
||||||
|
fn arm(run_dir: &Path) -> Self {
|
||||||
|
Self {
|
||||||
|
run_dir: run_dir.to_path_buf(),
|
||||||
|
active: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn defuse(&mut self) {
|
||||||
|
self.active = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for DetachedRunCompletionGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if !self.active {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
run_status::write_run_status(
|
||||||
|
&self.run_dir,
|
||||||
|
RunStatus::Failed,
|
||||||
|
Some(StatusReason::WorkflowError),
|
||||||
|
);
|
||||||
|
if !self.run_dir.join("conclusion.json").exists() {
|
||||||
|
let _ = write_failure_conclusion(
|
||||||
|
&self.run_dir,
|
||||||
|
POSTRUN_ABORTED_MESSAGE,
|
||||||
|
Some(StatusReason::WorkflowError),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(run_id) = load_run_id(&self.run_dir) {
|
||||||
|
let _ = crate::event::append_progress_event(
|
||||||
|
&self.run_dir,
|
||||||
|
&run_id,
|
||||||
|
&WorkflowRunEvent::RunNotice {
|
||||||
|
level: RunNoticeLevel::Error,
|
||||||
|
code: "postrun_aborted".to_string(),
|
||||||
|
message: POSTRUN_ABORTED_MESSAGE.to_string(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_run_id(run_dir: &Path) -> Option<String> {
|
||||||
|
crate::records::RunRecord::load(run_dir)
|
||||||
|
.ok()
|
||||||
|
.map(|record| record.run_id)
|
||||||
|
.filter(|run_id| !run_id.trim().is_empty())
|
||||||
|
.or_else(|| {
|
||||||
|
std::fs::read_to_string(run_dir.join("id.txt"))
|
||||||
|
.ok()
|
||||||
|
.map(|run_id| run_id.trim().to_string())
|
||||||
|
.filter(|run_id| !run_id.is_empty())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn persist_detached_failure(
|
||||||
|
run_dir: &Path,
|
||||||
|
phase: &'static str,
|
||||||
|
reason: StatusReason,
|
||||||
|
error: &FabroError,
|
||||||
|
) -> Result<(), FabroError> {
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct DetachedFailureRecord<'a> {
|
||||||
|
timestamp: chrono::DateTime<Utc>,
|
||||||
|
phase: &'a str,
|
||||||
|
reason: StatusReason,
|
||||||
|
error: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
let message = error.to_string();
|
||||||
|
let record = DetachedFailureRecord {
|
||||||
|
timestamp: Utc::now(),
|
||||||
|
phase,
|
||||||
|
reason,
|
||||||
|
error: message.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
std::fs::write(
|
||||||
|
run_dir.join("detached_failure.json"),
|
||||||
|
serde_json::to_string_pretty(&record).map_err(|err| FabroError::Io(err.to_string()))?,
|
||||||
|
)
|
||||||
|
.map_err(|err| FabroError::Io(err.to_string()))?;
|
||||||
|
|
||||||
|
write_failure_conclusion(run_dir, &message, Some(reason))?;
|
||||||
|
run_status::write_run_status(run_dir, RunStatus::Failed, Some(reason));
|
||||||
|
|
||||||
|
if let Some(run_id) = load_run_id(run_dir) {
|
||||||
|
crate::event::append_progress_event(
|
||||||
|
run_dir,
|
||||||
|
&run_id,
|
||||||
|
&WorkflowRunEvent::RunNotice {
|
||||||
|
level: RunNoticeLevel::Error,
|
||||||
|
code: format!("{phase}_failed"),
|
||||||
|
message,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map_err(|err| FabroError::Io(err.to_string()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_failure_conclusion(
|
||||||
|
run_dir: &Path,
|
||||||
|
message: &str,
|
||||||
|
_reason: Option<StatusReason>,
|
||||||
|
) -> Result<(), FabroError> {
|
||||||
|
if run_dir.join("conclusion.json").exists() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let conclusion = Conclusion {
|
||||||
|
timestamp: Utc::now(),
|
||||||
|
status: StageStatus::Fail,
|
||||||
|
duration_ms: 0,
|
||||||
|
failure_reason: Some(message.to_string()),
|
||||||
|
final_git_commit_sha: None,
|
||||||
|
stages: vec![],
|
||||||
|
total_cost: None,
|
||||||
|
total_retries: 0,
|
||||||
|
total_input_tokens: 0,
|
||||||
|
total_output_tokens: 0,
|
||||||
|
total_cache_read_tokens: 0,
|
||||||
|
total_cache_write_tokens: 0,
|
||||||
|
total_reasoning_tokens: 0,
|
||||||
|
has_pricing: false,
|
||||||
|
};
|
||||||
|
conclusion.save(&run_dir.join("conclusion.json"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use fabro_agent::{LocalSandbox, Sandbox};
|
|
||||||
use fabro_config::FabroSettings;
|
use fabro_config::FabroSettings;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -256,8 +782,6 @@ mod tests {
|
||||||
use crate::handler::exit::ExitHandler;
|
use crate::handler::exit::ExitHandler;
|
||||||
use crate::handler::start::StartHandler;
|
use crate::handler::start::StartHandler;
|
||||||
use crate::handler::HandlerRegistry;
|
use crate::handler::HandlerRegistry;
|
||||||
use crate::pipeline::{LlmSpec, SandboxEnvSpec, SandboxSpec};
|
|
||||||
use crate::run_options::LifecycleOptions;
|
|
||||||
|
|
||||||
const MINIMAL_DOT: &str = r#"digraph Test {
|
const MINIMAL_DOT: &str = r#"digraph Test {
|
||||||
graph [goal="Build feature"]
|
graph [goal="Build feature"]
|
||||||
|
|
@ -266,23 +790,25 @@ mod tests {
|
||||||
start -> exit
|
start -> exit
|
||||||
}"#;
|
}"#;
|
||||||
|
|
||||||
fn persisted_workflow(dot: &str, run_dir: &std::path::Path) -> Persisted {
|
fn persisted_workflow(dot: &str, run_dir: &Path) -> Persisted {
|
||||||
crate::operations::create(
|
crate::operations::create(crate::operations::CreateRequest {
|
||||||
dot,
|
workflow: crate::operations::WorkflowInput::DotSource {
|
||||||
crate::operations::RunCreateOptions {
|
source: dot.to_string(),
|
||||||
settings: FabroSettings::default(),
|
base_dir: None,
|
||||||
|
workflow_slug: Some("test".to_string()),
|
||||||
|
},
|
||||||
|
settings: FabroSettings {
|
||||||
|
dry_run: Some(true),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
run_dir: Some(run_dir.to_path_buf()),
|
run_dir: Some(run_dir.to_path_buf()),
|
||||||
run_id: Some("run-test".to_string()),
|
run_id: Some("run-test".to_string()),
|
||||||
workflow_slug: Some("test".to_string()),
|
|
||||||
labels: std::collections::HashMap::new(),
|
|
||||||
base_branch: Some("main".to_string()),
|
|
||||||
working_directory: Some(std::env::current_dir().unwrap()),
|
|
||||||
host_repo_path: Some(std::env::current_dir().unwrap().display().to_string()),
|
host_repo_path: Some(std::env::current_dir().unwrap().display().to_string()),
|
||||||
goal_override: None,
|
base_branch: Some("main".to_string()),
|
||||||
base_dir: None,
|
..Default::default()
|
||||||
},
|
})
|
||||||
)
|
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
.persisted
|
||||||
}
|
}
|
||||||
|
|
||||||
fn test_registry() -> HandlerRegistry {
|
fn test_registry() -> HandlerRegistry {
|
||||||
|
|
@ -292,92 +818,26 @@ mod tests {
|
||||||
registry
|
registry
|
||||||
}
|
}
|
||||||
|
|
||||||
fn test_start_options(
|
fn test_start_services(
|
||||||
_run_dir: &std::path::Path,
|
|
||||||
_sandbox: Arc<dyn Sandbox>,
|
|
||||||
emitter: Arc<EventEmitter>,
|
emitter: Arc<EventEmitter>,
|
||||||
registry: Arc<HandlerRegistry>,
|
registry: Arc<HandlerRegistry>,
|
||||||
lifecycle: LifecycleOptions,
|
) -> StartServices {
|
||||||
preserve_sandbox: bool,
|
StartServices {
|
||||||
) -> StartOptions {
|
|
||||||
StartOptions {
|
|
||||||
cancel_token: None,
|
cancel_token: None,
|
||||||
emitter,
|
emitter,
|
||||||
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),
|
interviewer: Arc::new(fabro_interview::AutoApproveInterviewer),
|
||||||
lifecycle,
|
|
||||||
hooks: fabro_hooks::HookConfig { hooks: vec![] },
|
|
||||||
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_author: crate::git::GitAuthor::default(),
|
||||||
git: None,
|
|
||||||
github_app: None,
|
github_app: None,
|
||||||
worktree_mode: None,
|
|
||||||
registry_override: Some(registry),
|
registry_override: Some(registry),
|
||||||
retro: StartRetroOptions { enabled: false },
|
|
||||||
finalize: StartFinalizeOptions { preserve_sandbox },
|
|
||||||
pull_request: StartPullRequestConfig {
|
|
||||||
pr_config: None,
|
|
||||||
github_app: None,
|
|
||||||
origin_url: None,
|
|
||||||
model: "test-model".to_string(),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[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: Arc<dyn Sandbox> =
|
|
||||||
Arc::new(LocalSandbox::new(std::env::current_dir().unwrap()));
|
|
||||||
|
|
||||||
persisted_workflow(MINIMAL_DOT, &run_dir);
|
|
||||||
let result = start(
|
|
||||||
&run_dir,
|
|
||||||
test_start_options(
|
|
||||||
&run_dir,
|
|
||||||
sandbox,
|
|
||||||
emitter,
|
|
||||||
registry,
|
|
||||||
LifecycleOptions {
|
|
||||||
setup_commands: vec!["false".to_string()],
|
|
||||||
setup_command_timeout_ms: 1_000,
|
|
||||||
devcontainer_phases: vec![],
|
|
||||||
},
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
assert!(result.is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn start_captures_checkpoint_git_sha_in_conclusion() {
|
async fn start_captures_checkpoint_git_sha_in_conclusion() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let run_dir = temp.path().join("run");
|
let run_dir = temp.path().join("run");
|
||||||
let emitter = Arc::new(EventEmitter::new());
|
let emitter = Arc::new(EventEmitter::new());
|
||||||
let registry = Arc::new(test_registry());
|
let registry = Arc::new(test_registry());
|
||||||
let sandbox: Arc<dyn Sandbox> =
|
|
||||||
Arc::new(LocalSandbox::new(std::env::current_dir().unwrap()));
|
|
||||||
let injected = Arc::new(AtomicBool::new(false));
|
let injected = Arc::new(AtomicBool::new(false));
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|
@ -401,21 +861,7 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
persisted_workflow(MINIMAL_DOT, &run_dir);
|
persisted_workflow(MINIMAL_DOT, &run_dir);
|
||||||
let started = start(
|
let started = start(&run_dir, test_start_services(emitter, registry))
|
||||||
&run_dir,
|
|
||||||
test_start_options(
|
|
||||||
&run_dir,
|
|
||||||
sandbox,
|
|
||||||
emitter,
|
|
||||||
registry,
|
|
||||||
LifecycleOptions {
|
|
||||||
setup_commands: vec![],
|
|
||||||
setup_command_timeout_ms: 1_000,
|
|
||||||
devcontainer_phases: vec![],
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
|
@ -433,26 +879,10 @@ mod tests {
|
||||||
let run_dir = temp.path().join("run");
|
let run_dir = temp.path().join("run");
|
||||||
let emitter = Arc::new(EventEmitter::new());
|
let emitter = Arc::new(EventEmitter::new());
|
||||||
let registry = Arc::new(test_registry());
|
let registry = Arc::new(test_registry());
|
||||||
let sandbox: Arc<dyn Sandbox> =
|
|
||||||
Arc::new(LocalSandbox::new(std::env::current_dir().unwrap()));
|
|
||||||
|
|
||||||
persisted_workflow(MINIMAL_DOT, &run_dir);
|
persisted_workflow(MINIMAL_DOT, &run_dir);
|
||||||
|
|
||||||
let started = start(
|
let started = start(&run_dir, test_start_services(emitter, registry))
|
||||||
&run_dir,
|
|
||||||
test_start_options(
|
|
||||||
&run_dir,
|
|
||||||
sandbox,
|
|
||||||
emitter,
|
|
||||||
registry,
|
|
||||||
LifecycleOptions {
|
|
||||||
setup_commands: vec![],
|
|
||||||
setup_command_timeout_ms: 1_000,
|
|
||||||
devcontainer_phases: vec![],
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
|
@ -466,29 +896,11 @@ mod tests {
|
||||||
let run_dir = temp.path().join("run");
|
let run_dir = temp.path().join("run");
|
||||||
let emitter = Arc::new(EventEmitter::new());
|
let emitter = Arc::new(EventEmitter::new());
|
||||||
let registry = Arc::new(test_registry());
|
let registry = Arc::new(test_registry());
|
||||||
let sandbox: Arc<dyn Sandbox> =
|
|
||||||
Arc::new(LocalSandbox::new(std::env::current_dir().unwrap()));
|
|
||||||
|
|
||||||
persisted_workflow(MINIMAL_DOT, &run_dir);
|
persisted_workflow(MINIMAL_DOT, &run_dir);
|
||||||
// Create a fake checkpoint file
|
|
||||||
std::fs::write(run_dir.join("checkpoint.json"), "{}").unwrap();
|
std::fs::write(run_dir.join("checkpoint.json"), "{}").unwrap();
|
||||||
|
|
||||||
let result = start(
|
let result = start(&run_dir, test_start_services(emitter, registry)).await;
|
||||||
&run_dir,
|
|
||||||
test_start_options(
|
|
||||||
&run_dir,
|
|
||||||
sandbox,
|
|
||||||
emitter,
|
|
||||||
registry,
|
|
||||||
LifecycleOptions {
|
|
||||||
setup_commands: vec![],
|
|
||||||
setup_command_timeout_ms: 1_000,
|
|
||||||
devcontainer_phases: vec![],
|
|
||||||
},
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
matches!(&result, Err(crate::error::FabroError::Precondition(_))),
|
matches!(&result, Err(crate::error::FabroError::Precondition(_))),
|
||||||
|
|
@ -503,27 +915,10 @@ mod tests {
|
||||||
let run_dir = temp.path().join("run");
|
let run_dir = temp.path().join("run");
|
||||||
let emitter = Arc::new(EventEmitter::new());
|
let emitter = Arc::new(EventEmitter::new());
|
||||||
let registry = Arc::new(test_registry());
|
let registry = Arc::new(test_registry());
|
||||||
let sandbox: Arc<dyn Sandbox> =
|
|
||||||
Arc::new(LocalSandbox::new(std::env::current_dir().unwrap()));
|
|
||||||
|
|
||||||
persisted_workflow(MINIMAL_DOT, &run_dir);
|
persisted_workflow(MINIMAL_DOT, &run_dir);
|
||||||
|
|
||||||
let result = resume(
|
let result = resume(&run_dir, test_start_services(emitter, registry)).await;
|
||||||
&run_dir,
|
|
||||||
test_start_options(
|
|
||||||
&run_dir,
|
|
||||||
sandbox,
|
|
||||||
emitter,
|
|
||||||
registry,
|
|
||||||
LifecycleOptions {
|
|
||||||
setup_commands: vec![],
|
|
||||||
setup_command_timeout_ms: 1_000,
|
|
||||||
devcontainer_phases: vec![],
|
|
||||||
},
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
matches!(&result, Err(crate::error::FabroError::Precondition(_))),
|
matches!(&result, Err(crate::error::FabroError::Precondition(_))),
|
||||||
|
|
@ -538,8 +933,6 @@ mod tests {
|
||||||
let run_dir = temp.path().join("run");
|
let run_dir = temp.path().join("run");
|
||||||
let emitter = Arc::new(EventEmitter::new());
|
let emitter = Arc::new(EventEmitter::new());
|
||||||
let registry = Arc::new(test_registry());
|
let registry = Arc::new(test_registry());
|
||||||
let sandbox: Arc<dyn Sandbox> =
|
|
||||||
Arc::new(LocalSandbox::new(std::env::current_dir().unwrap()));
|
|
||||||
|
|
||||||
persisted_workflow(MINIMAL_DOT, &run_dir);
|
persisted_workflow(MINIMAL_DOT, &run_dir);
|
||||||
|
|
||||||
|
|
@ -575,22 +968,7 @@ mod tests {
|
||||||
.save(&run_dir.join("conclusion.json"))
|
.save(&run_dir.join("conclusion.json"))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let result = resume(
|
let result = resume(&run_dir, test_start_services(emitter, registry)).await;
|
||||||
&run_dir,
|
|
||||||
test_start_options(
|
|
||||||
&run_dir,
|
|
||||||
sandbox,
|
|
||||||
emitter,
|
|
||||||
registry,
|
|
||||||
LifecycleOptions {
|
|
||||||
setup_commands: vec![],
|
|
||||||
setup_command_timeout_ms: 1_000,
|
|
||||||
devcontainer_phases: vec![],
|
|
||||||
},
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
matches!(&result, Err(crate::error::FabroError::Precondition(_))),
|
matches!(&result, Err(crate::error::FabroError::Precondition(_))),
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,10 @@ use crate::error::FabroError;
|
||||||
|
|
||||||
use super::types::{PersistOptions, Persisted, Validated};
|
use super::types::{PersistOptions, Persisted, Validated};
|
||||||
|
|
||||||
const GRAPH_FILE_NAME: &str = "graph.fabro";
|
const GRAPH_FILE_NAME: &str = "workflow.fabro";
|
||||||
|
const LEGACY_GRAPH_FILE_NAME: &str = "graph.fabro";
|
||||||
|
|
||||||
/// PERSIST phase: create run directory, write graph.fabro and run.json to disk.
|
/// PERSIST phase: create run directory, write workflow.fabro and run.json to disk.
|
||||||
///
|
///
|
||||||
/// Overwrites `run_record.graph` with the validated graph before saving.
|
/// Overwrites `run_record.graph` with the validated graph before saving.
|
||||||
pub fn persist(validated: Validated, mut options: PersistOptions) -> Result<Persisted, FabroError> {
|
pub fn persist(validated: Validated, mut options: PersistOptions) -> Result<Persisted, FabroError> {
|
||||||
|
|
@ -30,15 +31,21 @@ pub fn persist(validated: Validated, mut options: PersistOptions) -> Result<Pers
|
||||||
|
|
||||||
/// Load a previously persisted run from disk.
|
/// Load a previously persisted run from disk.
|
||||||
///
|
///
|
||||||
/// `run.json` is authoritative for graph + config; `graph.fabro` provides the
|
/// `run.json` is authoritative for graph + config; `workflow.fabro` provides the
|
||||||
/// original DOT source string when present.
|
/// original DOT source string when present.
|
||||||
pub(crate) fn load(run_dir: &Path) -> Result<Persisted, FabroError> {
|
pub(crate) fn load(run_dir: &Path) -> Result<Persisted, FabroError> {
|
||||||
let run_record = crate::records::RunRecord::load(run_dir)?;
|
let run_record = crate::records::RunRecord::load(run_dir)?;
|
||||||
let graph = run_record.graph.clone();
|
let graph = run_record.graph.clone();
|
||||||
let source = match std::fs::read_to_string(run_dir.join(GRAPH_FILE_NAME)) {
|
let source = match std::fs::read_to_string(run_dir.join(GRAPH_FILE_NAME)) {
|
||||||
|
Ok(source) => source,
|
||||||
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
||||||
|
match std::fs::read_to_string(run_dir.join(LEGACY_GRAPH_FILE_NAME)) {
|
||||||
Ok(source) => source,
|
Ok(source) => source,
|
||||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(),
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(),
|
||||||
Err(err) => return Err(err.into()),
|
Err(err) => return Err(err.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => return Err(err.into()),
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Persisted::new(
|
Ok(Persisted::new(
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,7 @@ pub struct PersistOptions {
|
||||||
pub run_record: RunRecord,
|
pub run_record: RunRecord,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Output of the PERSIST phase. Run directory created, run.json and graph.fabro written.
|
/// Output of the PERSIST phase. Run directory created, run.json and workflow.fabro written.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
#[non_exhaustive]
|
#[non_exhaustive]
|
||||||
pub struct Persisted {
|
pub struct Persisted {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue