From 98e8a9b7f6f1b5cedff12cc41a7709195a90c8de Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Tue, 17 Mar 2026 15:17:55 -0400 Subject: [PATCH] Fix fabro-config dependency inversion Move config/data types from upstream crates (fabro-agent, fabro-mcp, fabro-workflows, fabro-hooks) down into fabro-config so it becomes a leaf crate depending only on fabro-util + external crates. New modules in fabro-config: - mcp.rs: McpServerConfig, McpTransport, McpServerEntry - sandbox.rs: DaytonaConfig, ExeConfig, SshConfig, SandboxConfig, etc. - hook.rs: HookEvent, HookDefinition, HookConfig, HookType, TlsMode - run.rs: RunDefaults, WorkflowRunConfig, LlmConfig, SetupConfig, etc. - project.rs: ProjectConfig, workflow discovery/resolution functions Source crates re-export from fabro-config for backward compatibility. Also removes stale strsim dep and moves toml to dev-deps in fabro-workflows. Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 14 +- lib/crates/fabro-agent/Cargo.toml | 1 + lib/crates/fabro-agent/src/cli.rs | 17 +- lib/crates/fabro-config/Cargo.toml | 9 +- lib/crates/fabro-config/src/cli.rs | 24 +- lib/crates/fabro-config/src/hook.rs | 230 ++ lib/crates/fabro-config/src/lib.rs | 4 + lib/crates/fabro-config/src/mcp.rs | 82 + lib/crates/fabro-config/src/project.rs | 662 ++++- lib/crates/fabro-config/src/run.rs | 495 ++++ lib/crates/fabro-config/src/sandbox.rs | 188 ++ lib/crates/fabro-config/src/server.rs | 7 +- lib/crates/fabro-daytona/Cargo.toml | 1 + lib/crates/fabro-daytona/src/lib.rs | 140 +- lib/crates/fabro-exe/Cargo.toml | 1 + lib/crates/fabro-exe/src/lib.rs | 7 +- lib/crates/fabro-hooks/Cargo.toml | 1 + lib/crates/fabro-hooks/src/config.rs | 171 +- lib/crates/fabro-hooks/src/types.rs | 64 +- lib/crates/fabro-mcp/Cargo.toml | 1 + lib/crates/fabro-mcp/src/config.rs | 64 +- lib/crates/fabro-ssh/Cargo.toml | 1 + lib/crates/fabro-ssh/src/lib.rs | 15 +- lib/crates/fabro-workflows/Cargo.toml | 6 +- .../fabro-workflows/src/cli/project_config.rs | 958 +------ .../fabro-workflows/src/cli/run_config.rs | 2383 ++--------------- 26 files changed, 1941 insertions(+), 3605 deletions(-) create mode 100644 lib/crates/fabro-config/src/hook.rs create mode 100644 lib/crates/fabro-config/src/mcp.rs create mode 100644 lib/crates/fabro-config/src/run.rs create mode 100644 lib/crates/fabro-config/src/sandbox.rs diff --git a/Cargo.lock b/Cargo.lock index 937e9d42e..a0ddf207f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1246,6 +1246,7 @@ dependencies = [ "clap", "dirs", "dotenvy", + "fabro-config", "fabro-llm", "fabro-mcp", "fabro-util", @@ -1390,13 +1391,11 @@ name = "fabro-config" version = "0.174.0" dependencies = [ "anyhow", + "clap", "dirs", - "fabro-agent", - "fabro-hooks", - "fabro-mcp", "fabro-util", - "fabro-workflows", "serde", + "strsim", "tempfile", "toml", "tracing", @@ -1412,6 +1411,7 @@ dependencies = [ "daytona-api-client", "daytona-sdk", "fabro-agent", + "fabro-config", "fabro-github", "git2", "rand 0.8.5", @@ -1457,6 +1457,7 @@ dependencies = [ "async-trait", "base64", "fabro-agent", + "fabro-config", "fabro-github", "openssh", "reqwest 0.12.28", @@ -1513,6 +1514,7 @@ version = "0.174.0" dependencies = [ "async-trait", "fabro-agent", + "fabro-config", "fabro-llm", "mockito", "regex", @@ -1586,6 +1588,7 @@ name = "fabro-mcp" version = "0.174.0" dependencies = [ "anyhow", + "fabro-config", "futures", "reqwest 0.12.28", "rmcp", @@ -1671,6 +1674,7 @@ dependencies = [ "async-trait", "base64", "fabro-agent", + "fabro-config", "fabro-github", "openssh", "serde", @@ -1759,6 +1763,7 @@ dependencies = [ "dirs", "dotenvy", "fabro-agent", + "fabro-config", "fabro-daytona", "fabro-devcontainer", "fabro-exe", @@ -1787,7 +1792,6 @@ dependencies = [ "serde_json", "sha2", "shlex", - "strsim", "tempfile", "thiserror 2.0.18", "tokio", diff --git a/lib/crates/fabro-agent/Cargo.toml b/lib/crates/fabro-agent/Cargo.toml index b890db054..34fa58ed0 100644 --- a/lib/crates/fabro-agent/Cargo.toml +++ b/lib/crates/fabro-agent/Cargo.toml @@ -21,6 +21,7 @@ doctest = false clap.workspace = true anyhow.workspace = true dotenvy.workspace = true +fabro-config = { path = "../fabro-config", features = ["clap"] } fabro-llm = { path = "../fabro-llm" } fabro-mcp = { path = "../fabro-mcp" } fabro-util = { path = "../fabro-util" } diff --git a/lib/crates/fabro-agent/src/cli.rs b/lib/crates/fabro-agent/src/cli.rs index 7cc53ba44..297ba4f07 100644 --- a/lib/crates/fabro-agent/src/cli.rs +++ b/lib/crates/fabro-agent/src/cli.rs @@ -4,7 +4,7 @@ use crate::{ AgentEvent, AnthropicProfile, GeminiProfile, LocalSandbox, OpenAiProfile, ProviderProfile, Session, SessionConfig, Turn, }; -use clap::{Args, Parser, ValueEnum}; +use clap::{Args, Parser}; use fabro_llm::client::Client; use fabro_llm::provider::{ModelId, Provider}; use fabro_util::terminal::Styles; @@ -58,20 +58,7 @@ struct Cli { args: AgentArgs, } -#[derive(Clone, Copy, Debug, PartialEq, ValueEnum, serde::Deserialize)] -#[serde(rename_all = "kebab-case")] -pub enum OutputFormat { - Text, - Json, -} - -#[derive(Clone, Copy, Debug, PartialEq, ValueEnum, serde::Deserialize)] -#[serde(rename_all = "kebab-case")] -pub enum PermissionLevel { - ReadOnly, - ReadWrite, - Full, -} +pub use fabro_config::cli::{OutputFormat, PermissionLevel}; impl AgentArgs { /// Fill `None` fields from cli.toml values, then hardcoded defaults. diff --git a/lib/crates/fabro-config/Cargo.toml b/lib/crates/fabro-config/Cargo.toml index afb29a3c3..c159b9547 100644 --- a/lib/crates/fabro-config/Cargo.toml +++ b/lib/crates/fabro-config/Cargo.toml @@ -10,17 +10,16 @@ doctest = false [features] default = [] -exedev = ["fabro-workflows/exedev"] +exedev = [] +clap = ["dep:clap"] [dependencies] anyhow.workspace = true -fabro-agent = { path = "../fabro-agent" } -fabro-hooks = { path = "../fabro-hooks" } -fabro-mcp = { path = "../fabro-mcp" } -fabro-workflows = { path = "../fabro-workflows" } +clap = { workspace = true, optional = true } fabro-util = { path = "../fabro-util" } dirs.workspace = true serde.workspace = true +strsim = "0.11" toml.workspace = true tracing.workspace = true diff --git a/lib/crates/fabro-config/src/cli.rs b/lib/crates/fabro-config/src/cli.rs index eb864a8b4..95af2cb7f 100644 --- a/lib/crates/fabro-config/src/cli.rs +++ b/lib/crates/fabro-config/src/cli.rs @@ -1,10 +1,27 @@ use std::path::{Path, PathBuf}; -use fabro_agent::cli::{OutputFormat, PermissionLevel}; -use fabro_workflows::cli::run_config::RunDefaults; use serde::Deserialize; use tracing::debug; +use crate::run::RunDefaults; + +#[derive(Clone, Copy, Debug, PartialEq, Deserialize)] +#[cfg_attr(feature = "clap", derive(clap::ValueEnum))] +#[serde(rename_all = "kebab-case")] +pub enum OutputFormat { + Text, + Json, +} + +#[derive(Clone, Copy, Debug, PartialEq, Deserialize)] +#[cfg_attr(feature = "clap", derive(clap::ValueEnum))] +#[serde(rename_all = "kebab-case")] +pub enum PermissionLevel { + ReadOnly, + ReadWrite, + Full, +} + #[derive(Clone, Debug, Default, Deserialize, PartialEq)] #[serde(rename_all = "lowercase")] pub enum ExecutionMode { @@ -124,8 +141,7 @@ pub fn load_cli_config(path: Option<&Path>) -> anyhow::Result { #[cfg(test)] mod tests { use super::*; - use fabro_mcp::config::McpTransport; - use fabro_workflows::cli::run_config::McpServerEntry; + use crate::mcp::{McpServerEntry, McpTransport}; use std::collections::HashMap; #[test] diff --git a/lib/crates/fabro-config/src/hook.rs b/lib/crates/fabro-config/src/hook.rs new file mode 100644 index 000000000..5ceea43c3 --- /dev/null +++ b/lib/crates/fabro-config/src/hook.rs @@ -0,0 +1,230 @@ +use std::borrow::Cow; + +use serde::{Deserialize, Serialize}; + +/// Lifecycle events that can trigger user-defined hooks. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum HookEvent { + RunStart, + RunComplete, + RunFailed, + StageStart, + StageComplete, + StageFailed, + StageRetrying, + EdgeSelected, + ParallelStart, + ParallelComplete, + /// Reserved: hooks for this event are not yet invoked by the engine. + SandboxReady, + /// Reserved: hooks for this event are not yet invoked by the engine. + SandboxCleanup, + CheckpointSaved, + PreToolUse, + PostToolUse, + PostToolUseFailure, +} + +impl HookEvent { + /// Whether hooks for this event block execution by default. + #[must_use] + pub fn is_blocking_by_default(self) -> bool { + matches!( + self, + Self::RunStart + | Self::StageStart + | Self::EdgeSelected + | Self::PreToolUse + | Self::SandboxReady + ) + } +} + +impl std::fmt::Display for HookEvent { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::RunStart => "run_start", + Self::RunComplete => "run_complete", + Self::RunFailed => "run_failed", + Self::StageStart => "stage_start", + Self::StageComplete => "stage_complete", + Self::StageFailed => "stage_failed", + Self::StageRetrying => "stage_retrying", + Self::EdgeSelected => "edge_selected", + Self::ParallelStart => "parallel_start", + Self::ParallelComplete => "parallel_complete", + Self::SandboxReady => "sandbox_ready", + Self::SandboxCleanup => "sandbox_cleanup", + Self::CheckpointSaved => "checkpoint_saved", + Self::PreToolUse => "pre_tool_use", + Self::PostToolUse => "post_tool_use", + Self::PostToolUseFailure => "post_tool_use_failure", + }) + } +} + +/// TLS verification mode for HTTP hooks. +#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, Default, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TlsMode { + /// Require `https://` and verify certificates (default). + #[default] + Verify, + /// Require `https://` but skip certificate verification. + NoVerify, + /// Allow `http://`; skip certificate verification for `https://`. + Off, +} + +/// How a hook is executed. +#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum HookType { + Command { + command: String, + }, + Http { + url: String, + headers: Option>, + #[serde(default)] + allowed_env_vars: Vec, + #[serde(default)] + tls: TlsMode, + }, + Prompt { + prompt: String, + model: Option, + }, + Agent { + prompt: String, + model: Option, + max_tool_rounds: Option, + }, +} + +/// A single hook definition. +#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)] +pub struct HookDefinition { + pub name: Option, + pub event: HookEvent, + /// Inline command shorthand — if set, implies `type = "command"`. + #[serde(default)] + pub command: Option, + /// Explicit hook type (command or http). If omitted and `command` is set, + /// defaults to `Command`. + #[serde(flatten)] + pub hook_type: Option, + /// Regex matched against node_id, handler_type, or event-specific fields. + pub matcher: Option, + /// Override the event's default blocking behavior. + pub blocking: Option, + /// Timeout in milliseconds (default: 60_000). + pub timeout_ms: Option, + /// Run inside the sandbox (true, default) or on the host (false). + pub sandbox: Option, +} + +impl HookDefinition { + /// Resolve the effective hook type: explicit `hook_type` wins, then `command` + /// shorthand, then error. + pub fn resolved_hook_type(&self) -> Option> { + if let Some(ref ht) = self.hook_type { + return Some(Cow::Borrowed(ht)); + } + self.command.as_ref().map(|cmd| { + Cow::Owned(HookType::Command { + command: cmd.clone(), + }) + }) + } + + /// Whether this hook is blocking for its event. + #[must_use] + pub fn is_blocking(&self) -> bool { + self.blocking + .unwrap_or_else(|| self.event.is_blocking_by_default()) + } + + /// Timeout duration for this hook. + /// + /// Defaults: 30s for prompt hooks, 60s for all others. + #[must_use] + pub fn timeout(&self) -> std::time::Duration { + if let Some(ms) = self.timeout_ms { + return std::time::Duration::from_millis(ms); + } + let default_ms = match self.resolved_hook_type().as_deref() { + Some(HookType::Prompt { .. }) => 30_000, + _ => 60_000, + }; + std::time::Duration::from_millis(default_ms) + } + + /// Whether this hook runs in the sandbox. + #[must_use] + pub fn runs_in_sandbox(&self) -> bool { + self.sandbox.unwrap_or(true) + } + + /// The effective name: explicit name or a generated one. + #[must_use] + pub fn effective_name(&self) -> String { + if let Some(ref n) = self.name { + return n.clone(); + } + let event_str = self.event.to_string(); + match self.resolved_hook_type().as_deref() { + Some(HookType::Command { ref command }) => { + let short = &command[..command.floor_char_boundary(20)]; + format!("{event_str}:{short}") + } + Some(HookType::Http { ref url, .. }) => format!("{event_str}:{url}"), + Some(HookType::Prompt { ref prompt, .. }) + | Some(HookType::Agent { ref prompt, .. }) => { + let short = &prompt[..prompt.floor_char_boundary(20)]; + format!("{event_str}:{short}") + } + None => event_str, + } + } +} + +/// Top-level hook configuration: a list of hook definitions. +#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize)] +pub struct HookConfig { + #[serde(default)] + pub hooks: Vec, +} + +impl HookConfig { + /// Merge with another config. Concatenates lists; on name collisions, `other` wins. + #[must_use] + pub fn merge(self, other: Self) -> Self { + let mut by_name: std::collections::HashMap = + std::collections::HashMap::new(); + let mut order: Vec = Vec::new(); + + for hook in self.hooks { + let name = hook.effective_name(); + if !by_name.contains_key(&name) { + order.push(name.clone()); + } + by_name.insert(name, hook); + } + for hook in other.hooks { + let name = hook.effective_name(); + if !by_name.contains_key(&name) { + order.push(name.clone()); + } + by_name.insert(name, hook); + } + + let hooks = order + .into_iter() + .filter_map(|name| by_name.remove(&name)) + .collect(); + + Self { hooks } + } +} diff --git a/lib/crates/fabro-config/src/lib.rs b/lib/crates/fabro-config/src/lib.rs index e2e08619f..41a13815e 100644 --- a/lib/crates/fabro-config/src/lib.rs +++ b/lib/crates/fabro-config/src/lib.rs @@ -1,5 +1,9 @@ pub mod cli; +pub mod hook; +pub mod mcp; pub mod project; +pub mod run; +pub mod sandbox; pub mod server; pub use fabro_util::path::expand_tilde; diff --git a/lib/crates/fabro-config/src/mcp.rs b/lib/crates/fabro-config/src/mcp.rs new file mode 100644 index 000000000..8fa917215 --- /dev/null +++ b/lib/crates/fabro-config/src/mcp.rs @@ -0,0 +1,82 @@ +use std::collections::HashMap; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +pub fn default_startup_timeout_secs() -> u64 { + 10 +} + +pub fn default_tool_timeout_secs() -> u64 { + 60 +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct McpServerConfig { + pub name: String, + pub transport: McpTransport, + #[serde(default = "default_startup_timeout_secs")] + pub startup_timeout_secs: u64, + #[serde(default = "default_tool_timeout_secs")] + pub tool_timeout_secs: u64, +} + +impl McpServerConfig { + #[must_use] + pub fn startup_timeout(&self) -> Duration { + Duration::from_secs(self.startup_timeout_secs) + } + + #[must_use] + pub fn tool_timeout(&self) -> Duration { + Duration::from_secs(self.tool_timeout_secs) + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum McpTransport { + Stdio { + command: Vec, + #[serde(default)] + env: HashMap, + }, + Http { + url: String, + #[serde(default)] + headers: HashMap, + }, + /// MCP server that runs inside a sandbox and is accessed via HTTP preview URL. + /// During session init, the server is started inside the sandbox and this + /// variant is resolved into an `Http` transport using the sandbox's preview URL. + Sandbox { + command: Vec, + port: u16, + #[serde(default)] + env: HashMap, + }, +} + +/// MCP server entry as it appears in TOML config files (without a `name` field). +/// +/// Converted to [`McpServerConfig`] via [`McpServerEntry::into_config`]. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct McpServerEntry { + #[serde(flatten)] + pub transport: McpTransport, + #[serde(default = "default_startup_timeout_secs")] + pub startup_timeout_secs: u64, + #[serde(default = "default_tool_timeout_secs")] + pub tool_timeout_secs: u64, +} + +impl McpServerEntry { + pub fn into_config(self, name: String) -> McpServerConfig { + McpServerConfig { + name, + transport: self.transport, + startup_timeout_secs: self.startup_timeout_secs, + tool_timeout_secs: self.tool_timeout_secs, + } + } +} diff --git a/lib/crates/fabro-config/src/project.rs b/lib/crates/fabro-config/src/project.rs index 9ffe524bb..f25e964fb 100644 --- a/lib/crates/fabro-config/src/project.rs +++ b/lib/crates/fabro-config/src/project.rs @@ -1 +1,661 @@ -pub use fabro_workflows::cli::project_config::*; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Context}; +use serde::Deserialize; + +use crate::hook::HookDefinition; +use crate::mcp::McpServerEntry; +use crate::run::{ + AssetsConfig, CheckpointConfig, GitHubConfig, LlmConfig, PullRequestConfig, RunDefaults, + SetupConfig, +}; +use crate::sandbox::SandboxConfig; + +const CONFIG_FILENAME: &str = "fabro.toml"; +const SUPPORTED_VERSION: u32 = 1; + +#[derive(Debug, Default, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct ProjectConfig { + #[serde(default)] + pub version: u32, + #[serde(default)] + pub fabro: ProjectFabroConfig, + #[serde(default)] + pub features: ProjectFeatures, + #[serde(alias = "directory")] + pub work_dir: Option, + pub llm: Option, + pub setup: Option, + pub sandbox: Option, + pub vars: Option>, + #[serde(default)] + pub checkpoint: CheckpointConfig, + pub pull_request: Option, + pub assets: Option, + #[serde(default)] + pub hooks: Vec, + #[serde(default)] + pub mcp_servers: HashMap, + pub github: Option, +} + +impl ProjectConfig { + /// Convert project config fields into `RunDefaults`. + pub fn into_run_defaults(self) -> RunDefaults { + RunDefaults { + work_dir: self.work_dir, + llm: self.llm, + setup: self.setup, + sandbox: self.sandbox, + vars: self.vars, + checkpoint: self.checkpoint, + pull_request: self.pull_request, + assets: self.assets, + hooks: self.hooks, + mcp_servers: self.mcp_servers, + github: self.github, + } + } +} + +#[derive(Debug, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct ProjectFabroConfig { + #[serde(default = "default_root")] + pub root: String, +} + +fn default_root() -> String { + ".".to_string() +} + +impl Default for ProjectFabroConfig { + fn default() -> Self { + Self { + root: default_root(), + } + } +} + +/// Feature flags for the project. All features default to `false` (opt-in). +#[derive(Debug, Default, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct ProjectFeatures { + /// Experimental: enable automatic retro generation after workflow runs. + #[serde(default)] + pub retros: bool, +} + +/// Parse a project config from a TOML string. +pub fn parse_project_config(content: &str) -> anyhow::Result { + let config: ProjectConfig = + toml::from_str(content).context("Failed to parse project config")?; + if config.version != SUPPORTED_VERSION { + bail!( + "Unsupported project config version: {}. Only version {SUPPORTED_VERSION} is supported.", + config.version, + ); + } + Ok(config) +} + +/// Load a project config from a file path. +pub fn load_project_config(path: &Path) -> anyhow::Result { + let content = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read {}", path.display()))?; + let config = parse_project_config(&content)?; + tracing::debug!(path = %path.display(), root = %config.fabro.root, "Loaded project config"); + Ok(config) +} + +/// Walk ancestor directories from `start` looking for `fabro.toml`. +/// Returns the config file path and parsed config, or `None` if not found. +pub fn discover_project_config(start: &Path) -> anyhow::Result> { + for ancestor in start.ancestors() { + let candidate = ancestor.join(CONFIG_FILENAME); + if candidate.is_file() { + tracing::debug!(path = %candidate.display(), "Discovered project config"); + let config = load_project_config(&candidate)?; + return Ok(Some((candidate, config))); + } + } + Ok(None) +} + +/// Resolve a workflow argument to a path. +/// +/// - If the arg has a file extension (`.toml`, `.fabro`, etc.), return it as-is. +/// - If no extension, attempt project-based resolution: find `fabro.toml`, resolve +/// `{fabro_root}/workflows/{name}/workflow.toml`. Returns an error with suggestions +/// if an `fabro.toml` exists but the workflow wasn't found. +pub fn resolve_workflow_arg(arg: &Path) -> anyhow::Result { + let start = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + resolve_workflow_arg_from(arg, &start) +} + +fn resolve_workflow_arg_from(arg: &Path, start_dir: &Path) -> anyhow::Result { + resolve_workflow_arg_impl(arg, start_dir, user_workflows_dir().as_deref()) +} + +fn resolve_workflow_arg_impl( + arg: &Path, + start_dir: &Path, + user_workflows: Option<&Path>, +) -> anyhow::Result { + if arg.extension().is_some() { + tracing::debug!(arg = %arg.display(), "Workflow arg has extension, returning as-is"); + return Ok(arg.to_path_buf()); + } + + let name = arg.to_string_lossy(); + match discover_project_config(start_dir) { + Ok(Some((config_path, config))) => { + let fabro_root = resolve_fabro_root(&config_path, &config); + let project_candidate = fabro_root + .join("workflows") + .join(&*name) + .join("workflow.toml"); + if project_candidate.is_file() { + tracing::debug!(arg = %arg.display(), resolved = %project_candidate.display(), "Resolved workflow name via project config"); + return Ok(project_candidate); + } + + if let Some(resolved) = resolve_user_workflow(user_workflows, &name, arg) { + return Ok(resolved); + } + + let project_wf_dir = fabro_root.join("workflows"); + let available = list_available_workflows(Some(&project_wf_dir), user_workflows); + if available.is_empty() { + bail!( + "Unknown workflow '{name}'\n\nNo workflows found in {}", + project_wf_dir.display() + ); + } + let mut msg = format!( + "Unknown workflow '{name}'\n\nAvailable workflows: {}", + available.join(", ") + ); + if let Some(suggestion) = find_closest_match(&name, &available) { + msg.push_str(&format!("\n\nDid you mean '{suggestion}'?")); + } + bail!("{msg}"); + } + Ok(None) => { + if let Some(resolved) = resolve_user_workflow(user_workflows, &name, arg) { + return Ok(resolved); + } + tracing::debug!(arg = %arg.display(), "No project config found, returning literal"); + Ok(arg.to_path_buf()) + } + Err(err) => { + tracing::debug!(arg = %arg.display(), error = %err, "Error discovering project config, returning literal"); + Ok(arg.to_path_buf()) + } + } +} + +/// Check if a workflow exists in the user-level workflows directory. +fn resolve_user_workflow(user_workflows: Option<&Path>, name: &str, arg: &Path) -> Option { + let user_wf = user_workflows?; + let candidate = user_wf.join(name).join("workflow.toml"); + if candidate.is_file() { + tracing::debug!(arg = %arg.display(), resolved = %candidate.display(), "Resolved workflow name via user workflows"); + Some(candidate) + } else { + None + } +} + +/// Return the user-level workflows directory (`~/.fabro/workflows/`). +fn user_workflows_dir() -> Option { + dirs::home_dir().map(|h| h.join(".fabro").join("workflows")) +} + +/// Metadata about a discovered workflow. +pub struct WorkflowInfo { + pub name: String, + pub goal: Option, + pub source: WorkflowSource, +} + +/// Where a workflow was discovered. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum WorkflowSource { + Project, + User, +} + +/// List workflow names in a single directory by scanning for subdirs containing `workflow.toml`. +fn list_workflows_in(workflows_dir: &Path) -> Vec { + let Ok(entries) = std::fs::read_dir(workflows_dir) else { + return Vec::new(); + }; + entries + .filter_map(|entry| { + let entry = entry.ok()?; + let path = entry.path(); + if path.is_dir() && path.join("workflow.toml").is_file() { + entry.file_name().to_str().map(String::from) + } else { + None + } + }) + .collect() +} + +/// Read the `goal` field from a `workflow.toml` without full config validation. +fn read_workflow_goal(workflow_toml: &Path) -> Option { + let content = std::fs::read_to_string(workflow_toml).ok()?; + let table: toml::Table = content.parse().ok()?; + table.get("goal")?.as_str().map(String::from) +} + +/// List workflows with metadata by scanning project and user workflow directories. +pub fn list_workflows_detailed( + project_workflows_dir: Option<&Path>, + user_workflows_dir: Option<&Path>, +) -> Vec { + let mut infos: Vec = Vec::new(); + let mut seen: Vec = Vec::new(); + + if let Some(dir) = project_workflows_dir { + for name in list_workflows_in(dir) { + let goal = read_workflow_goal(&dir.join(&name).join("workflow.toml")); + seen.push(name.clone()); + infos.push(WorkflowInfo { + name, + goal, + source: WorkflowSource::Project, + }); + } + } + if let Some(dir) = user_workflows_dir { + for name in list_workflows_in(dir) { + if !seen.contains(&name) { + let goal = read_workflow_goal(&dir.join(&name).join("workflow.toml")); + seen.push(name.clone()); + infos.push(WorkflowInfo { + name, + goal, + source: WorkflowSource::User, + }); + } + } + } + + infos.sort_by(|a, b| a.name.cmp(&b.name)); + infos +} + +/// List workflow names by scanning project and user workflow directories. +/// Project workflows appear first; user workflows are deduplicated. +pub fn list_available_workflows( + project_workflows_dir: Option<&Path>, + user_workflows_dir: Option<&Path>, +) -> Vec { + let mut names: Vec = Vec::new(); + + if let Some(dir) = project_workflows_dir { + names.extend(list_workflows_in(dir)); + } + if let Some(dir) = user_workflows_dir { + for name in list_workflows_in(dir) { + if !names.contains(&name) { + names.push(name); + } + } + } + + names.sort(); + names +} + +/// Find the closest match using normalized Levenshtein distance (threshold: 0.5). +fn find_closest_match(input: &str, candidates: &[String]) -> Option { + candidates + .iter() + .map(|c| (c, strsim::normalized_levenshtein(input, c))) + .filter(|(_, score)| *score >= 0.5) + .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)) + .map(|(name, _)| name.clone()) +} + +/// Resolve a workflow argument to a DOT path and optional run config. +/// +/// Calls `resolve_workflow_arg` first, then if the result is a `.toml` file, +/// loads the run config and resolves the graph path within it. +pub fn resolve_workflow( + arg: &Path, +) -> anyhow::Result<(PathBuf, Option)> { + let start = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + resolve_workflow_from(arg, &start) +} + +fn resolve_workflow_from( + arg: &Path, + start_dir: &Path, +) -> anyhow::Result<(PathBuf, Option)> { + let path = resolve_workflow_arg_from(arg, start_dir)?; + if path.extension().is_some_and(|ext| ext == "toml") { + let cfg = crate::run::load_run_config(&path)?; + let dot = crate::run::resolve_graph_path(&path, &cfg.graph); + Ok((dot, Some(cfg))) + } else { + Ok((path, None)) + } +} + +/// Check whether retros are enabled in the project config. +/// Returns `false` (the default) if no config is found or on error. +/// Retros are an experimental feature gated behind `[features] retros = true`. +pub fn is_retro_enabled() -> bool { + let start = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + match discover_project_config(&start) { + Ok(Some((_path, config))) => config.features.retros, + _ => false, + } +} + +/// Resolve the fabro root directory from a config file path and its config. +/// The returned path is the directory containing `fabro.toml` joined with the `root` value. +pub fn resolve_fabro_root(config_path: &Path, config: &ProjectConfig) -> PathBuf { + let project_dir = config_path + .parent() + .expect("config_path should have a parent directory"); + project_dir.join(&config.fabro.root) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::run::MergeStrategy; + use std::fs; + use tempfile::TempDir; + + #[test] + fn parse_minimal_config() { + let config = parse_project_config("version = 1\n").unwrap(); + assert_eq!( + config, + ProjectConfig { + version: 1, + fabro: ProjectFabroConfig { + root: ".".to_string(), + }, + ..Default::default() + } + ); + } + + #[test] + fn parse_full_config() { + let config = parse_project_config("version = 1\n[fabro]\nroot = \"fabro/\"\n").unwrap(); + assert_eq!(config.fabro.root, "fabro/"); + } + + #[test] + fn parse_retros_default_false() { + let config = parse_project_config("version = 1\n").unwrap(); + assert!(!config.features.retros); + } + + #[test] + fn parse_retros_enabled() { + let config = parse_project_config("version = 1\n[features]\nretros = true\n").unwrap(); + assert!(config.features.retros); + } + + #[test] + fn parse_version_mismatch() { + let err = parse_project_config("version = 2\n").unwrap_err(); + assert!( + err.to_string().contains("Unsupported"), + "Expected 'Unsupported' in error, got: {err}" + ); + } + + #[test] + fn parse_pull_request_config() { + let config = + parse_project_config("version = 1\n\n[pull_request]\nenabled = true\ndraft = false\n") + .unwrap(); + assert_eq!( + config.pull_request, + Some(PullRequestConfig { + enabled: true, + draft: false, + auto_merge: false, + merge_strategy: MergeStrategy::Squash, + }) + ); + } + + #[test] + fn parse_project_config_with_sandbox() { + let toml = r#" +version = 1 +[sandbox] +provider = "daytona" +[sandbox.daytona.snapshot] +name = "my-snapshot" +cpu = 4 +memory = 8 +"#; + let config = parse_project_config(toml).unwrap(); + let sandbox = config.sandbox.unwrap(); + assert_eq!(sandbox.provider.as_deref(), Some("daytona")); + let snap = sandbox.daytona.unwrap().snapshot.unwrap(); + assert_eq!(snap.name, "my-snapshot"); + assert_eq!(snap.cpu, Some(4)); + assert_eq!(snap.memory, Some(8)); + } + + #[test] + fn parse_project_config_with_hooks_and_mcp() { + let toml = r#" +version = 1 +[[hooks]] +event = "run_start" +command = "echo start" +[mcp_servers.playwright] +type = "stdio" +command = ["npx", "@playwright/mcp@latest"] +"#; + let config = parse_project_config(toml).unwrap(); + assert_eq!(config.hooks.len(), 1); + assert_eq!(config.mcp_servers.len(), 1); + assert!(config.mcp_servers.contains_key("playwright")); + } + + #[test] + fn parse_project_config_with_llm_and_work_dir() { + let toml = r#" +version = 1 +work_dir = "/workspace" +[llm] +model = "claude-sonnet-4-6" +"#; + let config = parse_project_config(toml).unwrap(); + assert_eq!(config.work_dir.as_deref(), Some("/workspace")); + assert_eq!( + config.llm.unwrap().model.as_deref(), + Some("claude-sonnet-4-6") + ); + } + + #[test] + fn into_run_defaults_preserves_fields() { + let toml = r#" +version = 1 +work_dir = "/ws" +[llm] +model = "m" +[sandbox] +provider = "daytona" +"#; + let config = parse_project_config(toml).unwrap(); + let defaults = config.into_run_defaults(); + assert_eq!(defaults.work_dir.as_deref(), Some("/ws")); + assert_eq!(defaults.llm.unwrap().model.as_deref(), Some("m")); + assert_eq!( + defaults.sandbox.unwrap().provider.as_deref(), + Some("daytona") + ); + } + + #[test] + fn parse_unknown_field_rejected() { + let err = parse_project_config("version = 1\nfoo = \"bar\"\n").unwrap_err(); + let chain = format!("{err:#}"); + assert!(chain.contains("unknown field"), "got: {chain}"); + } + + #[test] + fn load_from_disk() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("fabro.toml"); + fs::write(&path, "version = 1\n").unwrap(); + let config = load_project_config(&path).unwrap(); + assert_eq!(config.version, 1); + assert_eq!(config.fabro.root, "."); + } + + #[test] + fn discover_walks_ancestors() { + let tmp = TempDir::new().unwrap(); + fs::write(tmp.path().join("fabro.toml"), "version = 1\n").unwrap(); + let sub = tmp.path().join("sub").join("dir"); + fs::create_dir_all(&sub).unwrap(); + + let (found_path, config) = discover_project_config(&sub).unwrap().unwrap(); + assert_eq!(found_path, tmp.path().join("fabro.toml")); + assert_eq!(config.version, 1); + } + + #[test] + fn discover_returns_none_when_absent() { + let tmp = TempDir::new().unwrap(); + let result = discover_project_config(tmp.path()).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn resolve_fabro_root_with_subdirectory() { + let config_path = Path::new("/repo/fabro.toml"); + let config = ProjectConfig { + version: 1, + fabro: ProjectFabroConfig { + root: "fabro/".to_string(), + ..Default::default() + }, + ..Default::default() + }; + assert_eq!( + resolve_fabro_root(config_path, &config), + Path::new("/repo/fabro/") + ); + } + + #[test] + fn resolve_fabro_root_with_dot() { + let config_path = Path::new("/repo/fabro.toml"); + let config = ProjectConfig { + version: 1, + fabro: ProjectFabroConfig { + root: ".".to_string(), + ..Default::default() + }, + ..Default::default() + }; + assert_eq!( + resolve_fabro_root(config_path, &config), + Path::new("/repo/.") + ); + } + + #[test] + fn resolve_workflow_arg_toml_extension_returned_as_is() { + let tmp = TempDir::new().unwrap(); + let result = resolve_workflow_arg_from(Path::new("my-workflow.toml"), tmp.path()).unwrap(); + assert_eq!(result, Path::new("my-workflow.toml")); + } + + #[test] + fn resolve_workflow_arg_fabro_extension_returned_as_is() { + let tmp = TempDir::new().unwrap(); + let result = resolve_workflow_arg_from(Path::new("my-workflow.fabro"), tmp.path()).unwrap(); + assert_eq!(result, Path::new("my-workflow.fabro")); + } + + #[test] + fn resolve_workflow_arg_no_extension_no_config_returns_literal() { + let tmp = TempDir::new().unwrap(); + let result = resolve_workflow_arg_from(Path::new("my-workflow"), tmp.path()).unwrap(); + assert_eq!(result, Path::new("my-workflow")); + } + + #[test] + fn resolve_workflow_arg_no_extension_with_config_and_workflow_file() { + let tmp = TempDir::new().unwrap(); + fs::write(tmp.path().join("fabro.toml"), "version = 1\n").unwrap(); + let wf_dir = tmp.path().join("workflows").join("my-workflow"); + fs::create_dir_all(&wf_dir).unwrap(); + fs::write( + wf_dir.join("workflow.toml"), + "version = 1\ngraph = \"workflow.fabro\"\n", + ) + .unwrap(); + + let result = resolve_workflow_arg_from(Path::new("my-workflow"), tmp.path()).unwrap(); + assert_eq!(result, wf_dir.join("workflow.toml")); + } + + #[test] + fn resolve_workflow_arg_typo_suggests_similar_name() { + let tmp = TempDir::new().unwrap(); + fs::write(tmp.path().join("fabro.toml"), "version = 1\n").unwrap(); + let wf_dir = tmp.path().join("workflows").join("implement"); + fs::create_dir_all(&wf_dir).unwrap(); + fs::write( + wf_dir.join("workflow.toml"), + "version = 1\ngraph = \"w.fabro\"\n", + ) + .unwrap(); + + let err = resolve_workflow_arg_from(Path::new("implemet"), tmp.path()).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("Unknown workflow 'implemet'"), "got: {msg}"); + assert!(msg.contains("Did you mean 'implement'?"), "got: {msg}"); + } + + #[test] + fn parse_project_config_with_github() { + let toml = r#" +version = 1 + +[github] +permissions = { contents = "read" } +"#; + let config = parse_project_config(toml).unwrap(); + let github = config.github.unwrap(); + assert_eq!(github.permissions["contents"], "read"); + } + + #[test] + fn into_run_defaults_preserves_github() { + let toml = r#" +version = 1 + +[github] +permissions = { contents = "read", issues = "write" } +"#; + let config = parse_project_config(toml).unwrap(); + let defaults = config.into_run_defaults(); + let github = defaults.github.unwrap(); + assert_eq!(github.permissions["contents"], "read"); + assert_eq!(github.permissions["issues"], "write"); + } +} diff --git a/lib/crates/fabro-config/src/run.rs b/lib/crates/fabro-config/src/run.rs new file mode 100644 index 000000000..672df8198 --- /dev/null +++ b/lib/crates/fabro-config/src/run.rs @@ -0,0 +1,495 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Context}; +use serde::{Deserialize, Serialize}; + +use tracing::debug; + +use crate::hook::{HookConfig, HookDefinition}; +use crate::mcp::McpServerEntry; +use crate::sandbox::{DockerfileSource, SandboxConfig}; + +const SUPPORTED_VERSION: u32 = 1; + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +pub struct CheckpointConfig { + #[serde(default)] + pub exclude_globs: Vec, +} + +fn default_true() -> bool { + true +} + +fn default_graph() -> String { + "workflow.fabro".to_string() +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +pub struct PullRequestConfig { + #[serde(default)] + pub enabled: bool, + #[serde(default = "default_true")] + pub draft: bool, + #[serde(default)] + pub auto_merge: bool, + #[serde(default)] + pub merge_strategy: MergeStrategy, +} + +#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum MergeStrategy { + #[default] + Squash, + Merge, + Rebase, +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +pub struct AssetsConfig { + #[serde(default)] + pub include: Vec, +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +pub struct GitHubConfig { + #[serde(default)] + pub permissions: HashMap, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct WorkflowRunConfig { + pub version: u32, + pub goal: Option, + #[serde(default = "default_graph")] + pub graph: String, + #[serde(alias = "directory")] + pub work_dir: Option, + pub llm: Option, + pub setup: Option, + pub sandbox: Option, + pub vars: Option>, + #[serde(default)] + pub hooks: Vec, + #[serde(default)] + pub checkpoint: CheckpointConfig, + pub pull_request: Option, + pub assets: Option, + #[serde(default)] + pub mcp_servers: HashMap, + pub github: Option, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct LlmConfig { + pub model: Option, + pub provider: Option, + #[serde(default)] + pub fallbacks: Option>>, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct SetupConfig { + pub commands: Vec, + pub timeout_ms: Option, +} + +/// Defaults for workflow runs, loaded from the server config. +/// +/// Fields mirror `WorkflowRunConfig` but are all optional. +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +pub struct RunDefaults { + #[serde(alias = "directory")] + pub work_dir: Option, + pub llm: Option, + pub setup: Option, + pub sandbox: Option, + pub vars: Option>, + #[serde(default)] + pub checkpoint: CheckpointConfig, + pub pull_request: Option, + pub assets: Option, + #[serde(default)] + pub hooks: Vec, + #[serde(default)] + pub mcp_servers: HashMap, + pub github: Option, +} + +impl WorkflowRunConfig { + /// Apply server-level run defaults to this config. + /// + /// Each field uses the first non-`None` value (task config wins). + /// Vars are merged: defaults first, then task config overwrites. + pub fn apply_defaults(&mut self, defaults: &RunDefaults) { + if self.work_dir.is_none() { + self.work_dir = defaults.work_dir.clone(); + } + + match (&mut self.llm, &defaults.llm) { + (Some(task), Some(default)) => { + if task.model.is_none() { + task.model = default.model.clone(); + } + if task.provider.is_none() { + task.provider = default.provider.clone(); + } + if task.fallbacks.is_none() { + task.fallbacks = default.fallbacks.clone(); + } + } + (None, Some(_)) => self.llm = defaults.llm.clone(), + _ => {} + } + + match (&mut self.setup, &defaults.setup) { + (Some(task), Some(default)) => { + if task.timeout_ms.is_none() { + task.timeout_ms = default.timeout_ms; + } + } + (None, Some(_)) => self.setup = defaults.setup.clone(), + _ => {} + } + + match (&mut self.sandbox, &defaults.sandbox) { + (Some(task), Some(default)) => { + if task.provider.is_none() { + task.provider = default.provider.clone(); + } + if task.preserve.is_none() { + task.preserve = default.preserve; + } + if task.devcontainer.is_none() { + task.devcontainer = default.devcontainer; + } + if task.local.is_none() { + task.local = default.local.clone(); + } + match (&mut task.daytona, &default.daytona) { + (Some(task_d), Some(default_d)) => { + if task_d.auto_stop_interval.is_none() { + task_d.auto_stop_interval = default_d.auto_stop_interval; + } + if task_d.snapshot.is_none() { + task_d.snapshot = default_d.snapshot.clone(); + } + if let Some(ref default_labels) = default_d.labels { + let mut merged = default_labels.clone(); + if let Some(ref task_labels) = task_d.labels { + merged.extend(task_labels.clone()); + } + task_d.labels = Some(merged); + } + if task_d.network.is_none() { + task_d.network = default_d.network.clone(); + } + } + (None, Some(_)) => task.daytona = default.daytona.clone(), + _ => {} + } + #[cfg(feature = "exedev")] + match (&mut task.exe, &default.exe) { + (Some(task_e), Some(default_e)) => { + if task_e.image.is_none() { + task_e.image = default_e.image.clone(); + } + } + (None, Some(_)) => task.exe = default.exe.clone(), + _ => {} + } + if task.ssh.is_none() { + task.ssh = default.ssh.clone(); + } + if let Some(ref default_env) = default.env { + let mut merged = default_env.clone(); + if let Some(ref task_env) = task.env { + merged.extend(task_env.clone()); + } + task.env = Some(merged); + } + } + (None, Some(_)) => self.sandbox = defaults.sandbox.clone(), + _ => {} + } + + if let Some(ref default_vars) = defaults.vars { + let mut merged = default_vars.clone(); + if let Some(ref task_vars) = self.vars { + merged.extend(task_vars.clone()); + } + self.vars = Some(merged); + } + + if !defaults.checkpoint.exclude_globs.is_empty() { + let mut merged = defaults.checkpoint.exclude_globs.clone(); + merged.append(&mut self.checkpoint.exclude_globs.clone()); + merged.sort(); + merged.dedup(); + self.checkpoint.exclude_globs = merged; + } + + if self.pull_request.is_none() { + self.pull_request = defaults.pull_request.clone(); + } + + if self.assets.is_none() { + self.assets = defaults.assets.clone(); + } + + // Merge hooks: defaults as base, workflow overrides by name + if !defaults.hooks.is_empty() { + let base = HookConfig { + hooks: defaults.hooks.clone(), + }; + let overlay = HookConfig { + hooks: std::mem::take(&mut self.hooks), + }; + self.hooks = base.merge(overlay).hooks; + } + + // Merge mcp_servers: defaults as base, workflow overrides by key + if !defaults.mcp_servers.is_empty() { + let mut merged = defaults.mcp_servers.clone(); + merged.extend(std::mem::take(&mut self.mcp_servers)); + self.mcp_servers = merged; + } + + if self.github.is_none() { + self.github = defaults.github.clone(); + } + } +} + +impl RunDefaults { + /// Merge an overlay on top of this base. The overlay takes precedence + /// for simple fields; compound fields (vars, hooks, mcp_servers) are + /// deep-merged with the overlay winning on collision. + /// + /// Uses the same deep-merge semantics as `WorkflowRunConfig::apply_defaults`. + pub fn merge_overlay(&mut self, overlay: RunDefaults) { + if overlay.work_dir.is_some() { + self.work_dir = overlay.work_dir; + } + + match (&mut self.llm, overlay.llm) { + (Some(base), Some(over)) => { + if over.model.is_some() { + base.model = over.model; + } + if over.provider.is_some() { + base.provider = over.provider; + } + if over.fallbacks.is_some() { + base.fallbacks = over.fallbacks; + } + } + (None, Some(over)) => self.llm = Some(over), + _ => {} + } + + match (&mut self.setup, overlay.setup) { + (Some(base), Some(over)) => { + if over.timeout_ms.is_some() { + base.timeout_ms = over.timeout_ms; + } + } + (None, Some(over)) => self.setup = Some(over), + _ => {} + } + + match (&mut self.sandbox, overlay.sandbox) { + (Some(base), Some(over)) => { + if over.provider.is_some() { + base.provider = over.provider; + } + if over.preserve.is_some() { + base.preserve = over.preserve; + } + if over.devcontainer.is_some() { + base.devcontainer = over.devcontainer; + } + if over.local.is_some() { + base.local = over.local; + } + match (&mut base.daytona, over.daytona) { + (Some(base_d), Some(over_d)) => { + if over_d.auto_stop_interval.is_some() { + base_d.auto_stop_interval = over_d.auto_stop_interval; + } + if over_d.snapshot.is_some() { + base_d.snapshot = over_d.snapshot; + } + if let Some(over_labels) = over_d.labels { + let mut merged = base_d.labels.take().unwrap_or_default(); + merged.extend(over_labels); + base_d.labels = Some(merged); + } + if over_d.network.is_some() { + base_d.network = over_d.network; + } + } + (None, Some(over_d)) => base.daytona = Some(over_d), + _ => {} + } + #[cfg(feature = "exedev")] + match (&mut base.exe, over.exe) { + (Some(base_e), Some(over_e)) => { + if over_e.image.is_some() { + base_e.image = over_e.image; + } + } + (None, Some(over_e)) => base.exe = Some(over_e), + _ => {} + } + if let Some(over_env) = over.env { + let mut merged = base.env.take().unwrap_or_default(); + merged.extend(over_env); + base.env = Some(merged); + } + } + (None, Some(over)) => self.sandbox = Some(over), + _ => {} + } + + if let Some(overlay_vars) = overlay.vars { + let mut merged = self.vars.take().unwrap_or_default(); + merged.extend(overlay_vars); + self.vars = Some(merged); + } + + if !overlay.checkpoint.exclude_globs.is_empty() { + self.checkpoint + .exclude_globs + .append(&mut overlay.checkpoint.exclude_globs.clone()); + self.checkpoint.exclude_globs.sort(); + self.checkpoint.exclude_globs.dedup(); + } + + if overlay.pull_request.is_some() { + self.pull_request = overlay.pull_request; + } + + if overlay.assets.is_some() { + self.assets = overlay.assets; + } + + if !overlay.hooks.is_empty() { + let base = HookConfig { + hooks: std::mem::take(&mut self.hooks), + }; + let over = HookConfig { + hooks: overlay.hooks, + }; + self.hooks = base.merge(over).hooks; + } + + if !overlay.mcp_servers.is_empty() { + let mut merged = std::mem::take(&mut self.mcp_servers); + merged.extend(overlay.mcp_servers); + self.mcp_servers = merged; + } + + if overlay.github.is_some() { + self.github = overlay.github; + } + } +} + +/// Load and validate a run config from a TOML file. +/// +/// The `graph` path in the returned config is resolved relative to the +/// TOML file's parent directory. Any `dockerfile = { path = "..." }` is +/// resolved to inline content. +pub fn load_run_config(path: &Path) -> anyhow::Result { + let contents = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read {}", path.display()))?; + let mut config = parse_run_config(&contents)?; + + let config_dir = path.parent().unwrap_or(Path::new(".")); + resolve_dockerfile(&mut config, config_dir)?; + resolve_sandbox_env(&mut config)?; + + Ok(config) +} + +/// Resolve `${env.VARNAME}` references in `[sandbox.env]` values. +/// +/// Only whole-value references are supported (no partial interpolation). +/// Missing host env vars produce a hard error. +fn resolve_sandbox_env(config: &mut WorkflowRunConfig) -> anyhow::Result<()> { + if let Some(env) = config.sandbox.as_mut().and_then(|s| s.env.as_mut()) { + resolve_env_refs(env)?; + } + Ok(()) +} + +/// Resolve `${env.VARNAME}` patterns in a map of env vars. +/// +/// If the entire value is `${env.VARNAME}`, it is replaced with the host +/// environment variable. Any other value is left as-is. Missing host +/// variables produce an error. +pub fn resolve_env_refs(env: &mut HashMap) -> anyhow::Result<()> { + for (key, value) in env.iter_mut() { + if let Some(var_name) = value + .strip_prefix("${env.") + .and_then(|s| s.strip_suffix('}')) + { + *value = std::env::var(var_name).with_context(|| { + format!("sandbox.env.{key}: host environment variable {var_name:?} is not set") + })?; + } + } + Ok(()) +} + +/// If the config contains a `dockerfile = { path = "..." }`, read the file +/// and replace it with `DockerfileSource::Inline(contents)`. +fn resolve_dockerfile(config: &mut WorkflowRunConfig, config_dir: &Path) -> anyhow::Result<()> { + let source = config + .sandbox + .as_mut() + .and_then(|s| s.daytona.as_mut()) + .and_then(|d| d.snapshot.as_mut()) + .and_then(|snap| snap.dockerfile.as_mut()); + + if let Some(DockerfileSource::Path { path: ref rel }) = source { + let path = config_dir.join(rel); + let contents = std::fs::read_to_string(&path) + .with_context(|| format!("Failed to read dockerfile at {}", path.display()))?; + debug!(path = %path.display(), "Resolved dockerfile from path"); + *source.unwrap() = DockerfileSource::Inline(contents); + } + + Ok(()) +} + +/// Resolve the graph path relative to the TOML file's parent directory. +pub fn resolve_graph_path(toml_path: &Path, graph: &str) -> PathBuf { + let graph_path = Path::new(graph); + if graph_path.is_absolute() { + graph_path.to_path_buf() + } else { + toml_path + .parent() + .unwrap_or(Path::new(".")) + .join(graph_path) + } +} + +pub fn parse_run_config(contents: &str) -> anyhow::Result { + let config: WorkflowRunConfig = + toml::from_str(contents).context("Failed to parse run config TOML")?; + + if config.version != SUPPORTED_VERSION { + bail!( + "Unsupported run config version {}. Only version {SUPPORTED_VERSION} is supported.", + config.version + ); + } + + Ok(config) +} diff --git a/lib/crates/fabro-config/src/sandbox.rs b/lib/crates/fabro-config/src/sandbox.rs new file mode 100644 index 000000000..d94be3914 --- /dev/null +++ b/lib/crates/fabro-config/src/sandbox.rs @@ -0,0 +1,188 @@ +use std::collections::HashMap; + +use serde::de::{self, MapAccess, Visitor}; +use serde::{Deserialize, Serialize}; + +/// Configuration for a Daytona cloud sandbox. +/// +/// Doubles as the TOML deserialization target for `[sandbox.daytona]`. +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +pub struct DaytonaConfig { + pub auto_stop_interval: Option, + pub labels: Option>, + pub snapshot: Option, + pub network: Option, +} + +/// Network access mode for a Daytona sandbox. +/// +/// TOML syntax: +/// ```toml +/// network = "block" # no egress +/// network = "allow_all" # full access (default) +/// network = { allow_list = ["208.80.154.232/32"] } # CIDR allowlist +/// ``` +#[derive(Clone, Debug, PartialEq)] +pub enum DaytonaNetwork { + Block, + AllowAll, + AllowList(Vec), +} + +impl Serialize for DaytonaNetwork { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + DaytonaNetwork::Block => serializer.serialize_str("block"), + DaytonaNetwork::AllowAll => serializer.serialize_str("allow_all"), + DaytonaNetwork::AllowList(cidrs) => { + use serde::ser::SerializeMap; + let mut map = serializer.serialize_map(Some(1))?; + map.serialize_entry("allow_list", cidrs)?; + map.end() + } + } + } +} + +impl<'de> Deserialize<'de> for DaytonaNetwork { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + struct DaytonaNetworkVisitor; + + impl<'de> Visitor<'de> for DaytonaNetworkVisitor { + type Value = DaytonaNetwork; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + formatter, + r#""block", "allow_all", or {{ allow_list = [...] }}"# + ) + } + + fn visit_str(self, value: &str) -> Result { + match value { + "block" => Ok(DaytonaNetwork::Block), + "allow_all" => Ok(DaytonaNetwork::AllowAll), + other => Err(de::Error::custom(format!( + "unknown network mode \"{other}\": expected \"block\" or \"allow_all\"" + ))), + } + } + + fn visit_map>(self, mut map: M) -> Result { + let Some(key) = map.next_key::()? else { + return Err(de::Error::custom( + "empty table: expected { allow_list = [...] }", + )); + }; + + if key != "allow_list" { + return Err(de::Error::custom(format!( + "unknown key \"{key}\": expected \"allow_list\"" + ))); + } + + let cidrs: Vec = map.next_value()?; + + if cidrs.is_empty() { + return Err(de::Error::custom("allow_list must not be empty")); + } + + if let Some(extra) = map.next_key::()? { + return Err(de::Error::custom(format!( + "unexpected key \"{extra}\": allow_list table must have exactly one key" + ))); + } + + Ok(DaytonaNetwork::AllowList(cidrs)) + } + } + + deserializer.deserialize_any(DaytonaNetworkVisitor) + } +} + +/// Source for a snapshot Dockerfile. +/// +/// TOML syntax: +/// ```toml +/// dockerfile = "FROM rust:1.85-slim-bookworm" # inline content +/// dockerfile = { path = "./Dockerfile" } # file reference +/// ``` +/// +/// `Path` variants are resolved to `Inline` during config loading +/// (see `run_config::resolve_dockerfile`), so downstream consumers +/// should only ever see `Inline`. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum DockerfileSource { + Inline(String), + Path { path: String }, +} + +/// Snapshot configuration: when present, the sandbox is created from a snapshot +/// instead of a bare Docker image. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct DaytonaSnapshotConfig { + pub name: String, + pub cpu: Option, + pub memory: Option, + pub disk: Option, + pub dockerfile: Option, +} + +/// Configuration for an exe.dev sandbox (TOML target for `[sandbox.exe]`). +#[cfg(feature = "exedev")] +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +pub struct ExeConfig { + pub image: Option, +} + +/// Configuration for an SSH sandbox (TOML target for `[sandbox.ssh]`). +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct SshConfig { + /// SSH destination (e.g. `user@host` or an SSH alias). + pub destination: String, + /// Remote working directory. + pub working_directory: String, + /// Optional path to a custom SSH config file. + pub config_file: Option, + /// Base URL for port previews (e.g. `"http://beast"`). + /// When set, `get_preview_url(port)` returns `"{preview_url_base}:{port}"`. + pub preview_url_base: Option, +} + +#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum WorktreeMode { + Always, + #[default] + Clean, + Dirty, + Never, +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +pub struct LocalSandboxConfig { + #[serde(default)] + pub worktree_mode: WorktreeMode, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct SandboxConfig { + pub provider: Option, + pub preserve: Option, + #[serde(default)] + pub devcontainer: Option, + pub local: Option, + pub daytona: Option, + #[cfg(feature = "exedev")] + pub exe: Option, + pub ssh: Option, + pub env: Option>, +} diff --git a/lib/crates/fabro-config/src/server.rs b/lib/crates/fabro-config/src/server.rs index 41cf77902..8bfd35b1e 100644 --- a/lib/crates/fabro-config/src/server.rs +++ b/lib/crates/fabro-config/src/server.rs @@ -1,9 +1,10 @@ use std::path::{Path, PathBuf}; -use fabro_workflows::cli::run_config::RunDefaults; use serde::{Deserialize, Serialize}; use tracing::debug; +use crate::run::RunDefaults; + #[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize)] #[serde(rename_all = "snake_case")] pub enum AuthProvider { @@ -465,7 +466,7 @@ matcher = "agent_loop" assert_eq!(config.run_defaults.hooks.len(), 2); assert_eq!( config.run_defaults.hooks[0].event, - fabro_hooks::HookEvent::RunStart + crate::hook::HookEvent::RunStart ); assert_eq!( config.run_defaults.hooks[0].command.as_deref(), @@ -473,7 +474,7 @@ matcher = "agent_loop" ); assert_eq!( config.run_defaults.hooks[1].event, - fabro_hooks::HookEvent::StageComplete + crate::hook::HookEvent::StageComplete ); assert_eq!( config.run_defaults.hooks[1].matcher.as_deref(), diff --git a/lib/crates/fabro-daytona/Cargo.toml b/lib/crates/fabro-daytona/Cargo.toml index 1083fa7b1..921a75dfe 100644 --- a/lib/crates/fabro-daytona/Cargo.toml +++ b/lib/crates/fabro-daytona/Cargo.toml @@ -10,6 +10,7 @@ doctest = false [dependencies] fabro-agent = { path = "../fabro-agent" } +fabro-config = { path = "../fabro-config" } fabro-github = { path = "../fabro-github" } async-trait.workspace = true tokio.workspace = true diff --git a/lib/crates/fabro-daytona/src/lib.rs b/lib/crates/fabro-daytona/src/lib.rs index 3249e6f6d..1f8083771 100644 --- a/lib/crates/fabro-daytona/src/lib.rs +++ b/lib/crates/fabro-daytona/src/lib.rs @@ -7,147 +7,15 @@ use fabro_agent::sandbox::{ format_lines_numbered, DirEntry, ExecResult, GrepOptions, Sandbox, SandboxEvent, SandboxEventCallback, }; -use rand::Rng; -use serde::de::{self, MapAccess, Visitor}; -use serde::{Deserialize, Serialize}; - use fabro_github::GitHubAppCredentials; +use rand::Rng; const WORKING_DIRECTORY: &str = "/home/daytona/workspace"; const DEFAULT_SNAPSHOT: &str = "daytona-medium"; -/// Configuration for a Daytona cloud sandbox. -/// -/// Doubles as the TOML deserialization target for `[sandbox.daytona]`. -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] -pub struct DaytonaConfig { - pub auto_stop_interval: Option, - pub labels: Option>, - pub snapshot: Option, - pub network: Option, -} - -/// Network access mode for a Daytona sandbox. -/// -/// TOML syntax: -/// ```toml -/// network = "block" # no egress -/// network = "allow_all" # full access (default) -/// network = { allow_list = ["208.80.154.232/32"] } # CIDR allowlist -/// ``` -#[derive(Clone, Debug, PartialEq)] -pub enum DaytonaNetwork { - Block, - AllowAll, - AllowList(Vec), -} - -impl Serialize for DaytonaNetwork { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - match self { - DaytonaNetwork::Block => serializer.serialize_str("block"), - DaytonaNetwork::AllowAll => serializer.serialize_str("allow_all"), - DaytonaNetwork::AllowList(cidrs) => { - use serde::ser::SerializeMap; - let mut map = serializer.serialize_map(Some(1))?; - map.serialize_entry("allow_list", cidrs)?; - map.end() - } - } - } -} - -impl<'de> Deserialize<'de> for DaytonaNetwork { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - struct DaytonaNetworkVisitor; - - impl<'de> Visitor<'de> for DaytonaNetworkVisitor { - type Value = DaytonaNetwork; - - fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - formatter, - r#""block", "allow_all", or {{ allow_list = [...] }}"# - ) - } - - fn visit_str(self, value: &str) -> Result { - match value { - "block" => Ok(DaytonaNetwork::Block), - "allow_all" => Ok(DaytonaNetwork::AllowAll), - other => Err(de::Error::custom(format!( - "unknown network mode \"{other}\": expected \"block\" or \"allow_all\"" - ))), - } - } - - fn visit_map>(self, mut map: M) -> Result { - let Some(key) = map.next_key::()? else { - return Err(de::Error::custom( - "empty table: expected { allow_list = [...] }", - )); - }; - - if key != "allow_list" { - return Err(de::Error::custom(format!( - "unknown key \"{key}\": expected \"allow_list\"" - ))); - } - - let cidrs: Vec = map.next_value()?; - - if cidrs.is_empty() { - return Err(de::Error::custom("allow_list must not be empty")); - } - - if let Some(extra) = map.next_key::()? { - return Err(de::Error::custom(format!( - "unexpected key \"{extra}\": allow_list table must have exactly one key" - ))); - } - - Ok(DaytonaNetwork::AllowList(cidrs)) - } - } - - deserializer.deserialize_any(DaytonaNetworkVisitor) - } -} - -/// Source for a snapshot Dockerfile. -/// -/// TOML syntax: -/// ```toml -/// dockerfile = "FROM rust:1.85-slim-bookworm" # inline content -/// dockerfile = { path = "./Dockerfile" } # file reference -/// ``` -/// -/// `Path` variants are resolved to `Inline` during config loading -/// (see `run_config::resolve_dockerfile`), so downstream consumers -/// should only ever see `Inline`. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(untagged)] -pub enum DockerfileSource { - Inline(String), - Path { path: String }, -} - -/// Snapshot configuration: when present, the sandbox is created from a snapshot -/// instead of a bare Docker image. -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] -pub struct DaytonaSnapshotConfig { - pub name: String, - pub cpu: Option, - pub memory: Option, - pub disk: Option, - pub dockerfile: Option, -} +pub use fabro_config::sandbox::{ + DaytonaConfig, DaytonaNetwork, DaytonaSnapshotConfig, DockerfileSource, +}; /// Sandbox that runs all operations inside a Daytona cloud sandbox. pub struct DaytonaSandbox { diff --git a/lib/crates/fabro-exe/Cargo.toml b/lib/crates/fabro-exe/Cargo.toml index 5a108f243..1d03c1a3e 100644 --- a/lib/crates/fabro-exe/Cargo.toml +++ b/lib/crates/fabro-exe/Cargo.toml @@ -10,6 +10,7 @@ doctest = false [dependencies] fabro-agent = { path = "../fabro-agent" } +fabro-config = { path = "../fabro-config", features = ["exedev"] } fabro-github = { path = "../fabro-github" } async-trait.workspace = true tokio.workspace = true diff --git a/lib/crates/fabro-exe/src/lib.rs b/lib/crates/fabro-exe/src/lib.rs index 74efc9cd4..119e99998 100644 --- a/lib/crates/fabro-exe/src/lib.rs +++ b/lib/crates/fabro-exe/src/lib.rs @@ -10,7 +10,6 @@ use fabro_agent::sandbox::{ format_lines_numbered, DirEntry, ExecResult, GrepOptions, Sandbox, SandboxEvent, SandboxEventCallback, }; -use serde::{Deserialize, Serialize}; use tokio_util::sync::CancellationToken; pub use openssh_runner::OpensshRunner; @@ -82,11 +81,7 @@ pub trait SshRunner: Send + Sync { async fn download_file(&self, path: &str) -> Result, String>; } -/// Configuration for an exe.dev sandbox (TOML target for `[sandbox.exe]`). -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] -pub struct ExeConfig { - pub image: Option, -} +pub use fabro_config::sandbox::ExeConfig; /// Parameters for cloning a git repo into the sandbox during initialization. #[derive(Clone, Debug)] diff --git a/lib/crates/fabro-hooks/Cargo.toml b/lib/crates/fabro-hooks/Cargo.toml index fc38f9613..62f5bac30 100644 --- a/lib/crates/fabro-hooks/Cargo.toml +++ b/lib/crates/fabro-hooks/Cargo.toml @@ -10,6 +10,7 @@ doctest = false [dependencies] fabro-agent = { path = "../fabro-agent" } +fabro-config = { path = "../fabro-config" } fabro-llm = { path = "../fabro-llm" } serde.workspace = true serde_json.workspace = true diff --git a/lib/crates/fabro-hooks/src/config.rs b/lib/crates/fabro-hooks/src/config.rs index 523bb44f7..13815df1a 100644 --- a/lib/crates/fabro-hooks/src/config.rs +++ b/lib/crates/fabro-hooks/src/config.rs @@ -1,173 +1,4 @@ -use std::borrow::Cow; - -use serde::{Deserialize, Serialize}; - -use crate::types::HookEvent; - -/// TLS verification mode for HTTP hooks. -#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, Default, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum TlsMode { - /// Require `https://` and verify certificates (default). - #[default] - Verify, - /// Require `https://` but skip certificate verification. - NoVerify, - /// Allow `http://`; skip certificate verification for `https://`. - Off, -} - -/// How a hook is executed. -#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum HookType { - Command { - command: String, - }, - Http { - url: String, - headers: Option>, - #[serde(default)] - allowed_env_vars: Vec, - #[serde(default)] - tls: TlsMode, - }, - Prompt { - prompt: String, - model: Option, - }, - Agent { - prompt: String, - model: Option, - max_tool_rounds: Option, - }, -} - -/// A single hook definition. -#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)] -pub struct HookDefinition { - pub name: Option, - pub event: HookEvent, - /// Inline command shorthand — if set, implies `type = "command"`. - #[serde(default)] - pub command: Option, - /// Explicit hook type (command or http). If omitted and `command` is set, - /// defaults to `Command`. - #[serde(flatten)] - pub hook_type: Option, - /// Regex matched against node_id, handler_type, or event-specific fields. - pub matcher: Option, - /// Override the event's default blocking behavior. - pub blocking: Option, - /// Timeout in milliseconds (default: 60_000). - pub timeout_ms: Option, - /// Run inside the sandbox (true, default) or on the host (false). - pub sandbox: Option, -} - -impl HookDefinition { - /// Resolve the effective hook type: explicit `hook_type` wins, then `command` - /// shorthand, then error. - pub fn resolved_hook_type(&self) -> Option> { - if let Some(ref ht) = self.hook_type { - return Some(Cow::Borrowed(ht)); - } - self.command.as_ref().map(|cmd| { - Cow::Owned(HookType::Command { - command: cmd.clone(), - }) - }) - } - - /// Whether this hook is blocking for its event. - #[must_use] - pub fn is_blocking(&self) -> bool { - self.blocking - .unwrap_or_else(|| self.event.is_blocking_by_default()) - } - - /// Timeout duration for this hook. - /// - /// Defaults: 30s for prompt hooks, 60s for all others. - #[must_use] - pub fn timeout(&self) -> std::time::Duration { - if let Some(ms) = self.timeout_ms { - return std::time::Duration::from_millis(ms); - } - let default_ms = match self.resolved_hook_type().as_deref() { - Some(HookType::Prompt { .. }) => 30_000, - _ => 60_000, - }; - std::time::Duration::from_millis(default_ms) - } - - /// Whether this hook runs in the sandbox. - #[must_use] - pub fn runs_in_sandbox(&self) -> bool { - self.sandbox.unwrap_or(true) - } - - /// The effective name: explicit name or a generated one. - #[must_use] - pub fn effective_name(&self) -> String { - if let Some(ref n) = self.name { - return n.clone(); - } - let event_str = self.event.to_string(); - match self.resolved_hook_type().as_deref() { - Some(HookType::Command { ref command }) => { - let short = &command[..fabro_agent::floor_char_boundary(command, 20)]; - format!("{event_str}:{short}") - } - Some(HookType::Http { ref url, .. }) => format!("{event_str}:{url}"), - Some(HookType::Prompt { ref prompt, .. }) - | Some(HookType::Agent { ref prompt, .. }) => { - let short = &prompt[..fabro_agent::floor_char_boundary(prompt, 20)]; - format!("{event_str}:{short}") - } - None => event_str, - } - } -} - -/// Top-level hook configuration: a list of hook definitions. -#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize)] -pub struct HookConfig { - #[serde(default)] - pub hooks: Vec, -} - -impl HookConfig { - /// Merge with another config. Concatenates lists; on name collisions, `other` wins. - #[must_use] - pub fn merge(self, other: Self) -> Self { - let mut by_name: std::collections::HashMap = - std::collections::HashMap::new(); - let mut order: Vec = Vec::new(); - - for hook in self.hooks { - let name = hook.effective_name(); - if !by_name.contains_key(&name) { - order.push(name.clone()); - } - by_name.insert(name, hook); - } - for hook in other.hooks { - let name = hook.effective_name(); - if !by_name.contains_key(&name) { - order.push(name.clone()); - } - by_name.insert(name, hook); - } - - let hooks = order - .into_iter() - .filter_map(|name| by_name.remove(&name)) - .collect(); - - Self { hooks } - } -} +pub use fabro_config::hook::{HookConfig, HookDefinition, HookEvent, HookType, TlsMode}; #[cfg(test)] mod tests { diff --git a/lib/crates/fabro-hooks/src/types.rs b/lib/crates/fabro-hooks/src/types.rs index b20aeaab0..59913d392 100644 --- a/lib/crates/fabro-hooks/src/types.rs +++ b/lib/crates/fabro-hooks/src/types.rs @@ -1,67 +1,7 @@ +pub use fabro_config::hook::HookEvent; + use serde::{Deserialize, Serialize}; -/// Lifecycle events that can trigger user-defined hooks. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum HookEvent { - RunStart, - RunComplete, - RunFailed, - StageStart, - StageComplete, - StageFailed, - StageRetrying, - EdgeSelected, - ParallelStart, - ParallelComplete, - /// Reserved: hooks for this event are not yet invoked by the engine. - SandboxReady, - /// Reserved: hooks for this event are not yet invoked by the engine. - SandboxCleanup, - CheckpointSaved, - PreToolUse, - PostToolUse, - PostToolUseFailure, -} - -impl HookEvent { - /// Whether hooks for this event block execution by default. - #[must_use] - pub fn is_blocking_by_default(self) -> bool { - matches!( - self, - Self::RunStart - | Self::StageStart - | Self::EdgeSelected - | Self::PreToolUse - | Self::SandboxReady - ) - } -} - -impl std::fmt::Display for HookEvent { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(match self { - Self::RunStart => "run_start", - Self::RunComplete => "run_complete", - Self::RunFailed => "run_failed", - Self::StageStart => "stage_start", - Self::StageComplete => "stage_complete", - Self::StageFailed => "stage_failed", - Self::StageRetrying => "stage_retrying", - Self::EdgeSelected => "edge_selected", - Self::ParallelStart => "parallel_start", - Self::ParallelComplete => "parallel_complete", - Self::SandboxReady => "sandbox_ready", - Self::SandboxCleanup => "sandbox_cleanup", - Self::CheckpointSaved => "checkpoint_saved", - Self::PreToolUse => "pre_tool_use", - Self::PostToolUse => "post_tool_use", - Self::PostToolUseFailure => "post_tool_use_failure", - }) - } -} - /// Rich JSON payload sent to hooks. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HookContext { diff --git a/lib/crates/fabro-mcp/Cargo.toml b/lib/crates/fabro-mcp/Cargo.toml index a04fd27d7..286769aed 100644 --- a/lib/crates/fabro-mcp/Cargo.toml +++ b/lib/crates/fabro-mcp/Cargo.toml @@ -10,6 +10,7 @@ doctest = false [dependencies] anyhow.workspace = true +fabro-config = { path = "../fabro-config" } serde.workspace = true serde_json.workspace = true tokio.workspace = true diff --git a/lib/crates/fabro-mcp/src/config.rs b/lib/crates/fabro-mcp/src/config.rs index 5b640e99d..0b0511dfe 100644 --- a/lib/crates/fabro-mcp/src/config.rs +++ b/lib/crates/fabro-mcp/src/config.rs @@ -1,65 +1,13 @@ -use std::collections::HashMap; -use std::time::Duration; - -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct McpServerConfig { - pub name: String, - pub transport: McpTransport, - #[serde(default = "default_startup_timeout_secs")] - pub startup_timeout_secs: u64, - #[serde(default = "default_tool_timeout_secs")] - pub tool_timeout_secs: u64, -} - -pub fn default_startup_timeout_secs() -> u64 { - 10 -} - -pub fn default_tool_timeout_secs() -> u64 { - 60 -} - -impl McpServerConfig { - #[must_use] - pub fn startup_timeout(&self) -> Duration { - Duration::from_secs(self.startup_timeout_secs) - } - - #[must_use] - pub fn tool_timeout(&self) -> Duration { - Duration::from_secs(self.tool_timeout_secs) - } -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum McpTransport { - Stdio { - command: Vec, - #[serde(default)] - env: HashMap, - }, - Http { - url: String, - #[serde(default)] - headers: HashMap, - }, - /// MCP server that runs inside a sandbox and is accessed via HTTP preview URL. - /// During session init, the server is started inside the sandbox and this - /// variant is resolved into an `Http` transport using the sandbox's preview URL. - Sandbox { - command: Vec, - port: u16, - #[serde(default)] - env: HashMap, - }, -} +pub use fabro_config::mcp::{ + default_startup_timeout_secs, default_tool_timeout_secs, McpServerConfig, McpServerEntry, + McpTransport, +}; #[cfg(test)] mod tests { use super::*; + use std::collections::HashMap; + use std::time::Duration; #[test] fn stdio_config_construction() { diff --git a/lib/crates/fabro-ssh/Cargo.toml b/lib/crates/fabro-ssh/Cargo.toml index 33afd98b3..9109d9aed 100644 --- a/lib/crates/fabro-ssh/Cargo.toml +++ b/lib/crates/fabro-ssh/Cargo.toml @@ -10,6 +10,7 @@ doctest = false [dependencies] fabro-agent = { path = "../fabro-agent" } +fabro-config = { path = "../fabro-config" } fabro-github = { path = "../fabro-github" } async-trait.workspace = true tokio.workspace = true diff --git a/lib/crates/fabro-ssh/src/lib.rs b/lib/crates/fabro-ssh/src/lib.rs index d5c5bac7a..98331b04a 100644 --- a/lib/crates/fabro-ssh/src/lib.rs +++ b/lib/crates/fabro-ssh/src/lib.rs @@ -10,7 +10,6 @@ use fabro_agent::sandbox::{ format_lines_numbered, DirEntry, ExecResult, GrepOptions, Sandbox, SandboxEvent, SandboxEventCallback, }; -use serde::{Deserialize, Serialize}; use tokio_util::sync::CancellationToken; pub use openssh_runner::OpensshRunner; @@ -47,19 +46,7 @@ pub trait SshRunner: Send + Sync { async fn download_file(&self, path: &str) -> Result, String>; } -/// Configuration for an SSH sandbox (TOML target for `[sandbox.ssh]`). -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] -pub struct SshConfig { - /// SSH destination (e.g. `user@host` or an SSH alias). - pub destination: String, - /// Remote working directory. - pub working_directory: String, - /// Optional path to a custom SSH config file. - pub config_file: Option, - /// Base URL for port previews (e.g. `"http://beast"`). - /// When set, `get_preview_url(port)` returns `"{preview_url_base}:{port}"`. - pub preview_url_base: Option, -} +pub use fabro_config::sandbox::SshConfig; /// Parameters for cloning a git repo into the sandbox during initialization. #[derive(Clone, Debug)] diff --git a/lib/crates/fabro-workflows/Cargo.toml b/lib/crates/fabro-workflows/Cargo.toml index 4f5f2cc13..af1edffa2 100644 --- a/lib/crates/fabro-workflows/Cargo.toml +++ b/lib/crates/fabro-workflows/Cargo.toml @@ -14,13 +14,14 @@ doctest = false [features] default = [] -exedev = ["dep:fabro-exe"] +exedev = ["dep:fabro-exe", "fabro-config/exedev"] [dependencies] clap.workspace = true anyhow.workspace = true dotenvy.workspace = true fabro-agent = { path = "../fabro-agent" } +fabro-config = { path = "../fabro-config" } fabro-graphviz = { path = "../fabro-graphviz" } fabro-hooks = { path = "../fabro-hooks" } fabro-validate = { path = "../fabro-validate" } @@ -45,14 +46,12 @@ rand.workspace = true async-trait.workspace = true futures.workspace = true chrono = { workspace = true, features = ["serde"] } -toml.workspace = true dirs = "6" regex.workspace = true scopeguard = "1" hex.workspace = true sha2 = { workspace = true } shlex = "1" -strsim = "0.11" git2.workspace = true cli-table.workspace = true console.workspace = true @@ -63,6 +62,7 @@ walkdir.workspace = true reqwest.workspace = true [dev-dependencies] base64.workspace = true +toml.workspace = true fabro-mcp = { path = "../fabro-mcp" } mockito = "1" tokio = { workspace = true, features = ["test-util", "macros"] } diff --git a/lib/crates/fabro-workflows/src/cli/project_config.rs b/lib/crates/fabro-workflows/src/cli/project_config.rs index 31b960631..48dc18860 100644 --- a/lib/crates/fabro-workflows/src/cli/project_config.rs +++ b/lib/crates/fabro-workflows/src/cli/project_config.rs @@ -1,956 +1,2 @@ -use std::collections::HashMap; -use std::path::{Path, PathBuf}; - -use anyhow::{bail, Context}; -use serde::Deserialize; - -use super::run_config::{ - AssetsConfig, CheckpointConfig, GitHubConfig, LlmConfig, McpServerEntry, PullRequestConfig, - RunDefaults, SandboxConfig, SetupConfig, -}; -use fabro_hooks::HookDefinition; - -const CONFIG_FILENAME: &str = "fabro.toml"; - -#[derive(Debug, Default, Deserialize, PartialEq)] -#[serde(deny_unknown_fields)] -pub struct ProjectConfig { - #[serde(default)] - pub version: u32, - #[serde(default)] - pub fabro: ProjectFabroConfig, - #[serde(default)] - pub features: ProjectFeatures, - #[serde(alias = "directory")] - pub work_dir: Option, - pub llm: Option, - pub setup: Option, - pub sandbox: Option, - pub vars: Option>, - #[serde(default)] - pub checkpoint: CheckpointConfig, - pub pull_request: Option, - pub assets: Option, - #[serde(default)] - pub hooks: Vec, - #[serde(default)] - pub mcp_servers: HashMap, - pub github: Option, -} - -impl ProjectConfig { - /// Convert project config fields into `RunDefaults`. - pub fn into_run_defaults(self) -> RunDefaults { - RunDefaults { - work_dir: self.work_dir, - llm: self.llm, - setup: self.setup, - sandbox: self.sandbox, - vars: self.vars, - checkpoint: self.checkpoint, - pull_request: self.pull_request, - assets: self.assets, - hooks: self.hooks, - mcp_servers: self.mcp_servers, - github: self.github, - } - } -} - -#[derive(Debug, Deserialize, PartialEq)] -#[serde(deny_unknown_fields)] -pub struct ProjectFabroConfig { - #[serde(default = "default_root")] - pub root: String, -} - -fn default_root() -> String { - ".".to_string() -} - -impl Default for ProjectFabroConfig { - fn default() -> Self { - Self { - root: default_root(), - } - } -} - -/// Feature flags for the project. All features default to `false` (opt-in). -#[derive(Debug, Default, Deserialize, PartialEq)] -#[serde(deny_unknown_fields)] -pub struct ProjectFeatures { - /// Experimental: enable automatic retro generation after workflow runs. - #[serde(default)] - pub retros: bool, -} - -/// Parse a project config from a TOML string. -pub fn parse_project_config(content: &str) -> anyhow::Result { - let config: ProjectConfig = - toml::from_str(content).context("Failed to parse project config")?; - if config.version != 1 { - bail!( - "Unsupported project config version: {}. Only version 1 is supported.", - config.version, - ); - } - Ok(config) -} - -/// Load a project config from a file path. -pub fn load_project_config(path: &Path) -> anyhow::Result { - let content = std::fs::read_to_string(path) - .with_context(|| format!("Failed to read {}", path.display()))?; - let config = parse_project_config(&content)?; - tracing::debug!(path = %path.display(), root = %config.fabro.root, "Loaded project config"); - Ok(config) -} - -/// Walk ancestor directories from `start` looking for `fabro.toml`. -/// Returns the config file path and parsed config, or `None` if not found. -pub fn discover_project_config(start: &Path) -> anyhow::Result> { - for ancestor in start.ancestors() { - let candidate = ancestor.join(CONFIG_FILENAME); - if candidate.is_file() { - tracing::debug!(path = %candidate.display(), "Discovered project config"); - let config = load_project_config(&candidate)?; - return Ok(Some((candidate, config))); - } - } - Ok(None) -} - -/// Resolve a workflow argument to a path. -/// -/// - If the arg has a file extension (`.toml`, `.fabro`, etc.), return it as-is. -/// - If no extension, attempt project-based resolution: find `fabro.toml`, resolve -/// `{fabro_root}/workflows/{name}/workflow.toml`. Returns an error with suggestions -/// if an `fabro.toml` exists but the workflow wasn't found. -pub fn resolve_workflow_arg(arg: &Path) -> anyhow::Result { - let start = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); - resolve_workflow_arg_from(arg, &start) -} - -fn resolve_workflow_arg_from(arg: &Path, start_dir: &Path) -> anyhow::Result { - resolve_workflow_arg_impl(arg, start_dir, user_workflows_dir().as_deref()) -} - -fn resolve_workflow_arg_impl( - arg: &Path, - start_dir: &Path, - user_workflows: Option<&Path>, -) -> anyhow::Result { - if arg.extension().is_some() { - tracing::debug!(arg = %arg.display(), "Workflow arg has extension, returning as-is"); - return Ok(arg.to_path_buf()); - } - - let name = arg.to_string_lossy(); - match discover_project_config(start_dir) { - Ok(Some((config_path, config))) => { - let fabro_root = resolve_fabro_root(&config_path, &config); - let project_candidate = fabro_root - .join("workflows") - .join(&*name) - .join("workflow.toml"); - if project_candidate.is_file() { - tracing::debug!(arg = %arg.display(), resolved = %project_candidate.display(), "Resolved workflow name via project config"); - return Ok(project_candidate); - } - - if let Some(resolved) = resolve_user_workflow(user_workflows, &name, arg) { - return Ok(resolved); - } - - let project_wf_dir = fabro_root.join("workflows"); - let available = list_available_workflows(Some(&project_wf_dir), user_workflows); - if available.is_empty() { - bail!( - "Unknown workflow '{name}'\n\nNo workflows found in {}", - project_wf_dir.display() - ); - } - let mut msg = format!( - "Unknown workflow '{name}'\n\nAvailable workflows: {}", - available.join(", ") - ); - if let Some(suggestion) = find_closest_match(&name, &available) { - msg.push_str(&format!("\n\nDid you mean '{suggestion}'?")); - } - bail!("{msg}"); - } - Ok(None) => { - if let Some(resolved) = resolve_user_workflow(user_workflows, &name, arg) { - return Ok(resolved); - } - tracing::debug!(arg = %arg.display(), "No project config found, returning literal"); - Ok(arg.to_path_buf()) - } - Err(err) => { - tracing::debug!(arg = %arg.display(), error = %err, "Error discovering project config, returning literal"); - Ok(arg.to_path_buf()) - } - } -} - -/// Check if a workflow exists in the user-level workflows directory. -fn resolve_user_workflow(user_workflows: Option<&Path>, name: &str, arg: &Path) -> Option { - let user_wf = user_workflows?; - let candidate = user_wf.join(name).join("workflow.toml"); - if candidate.is_file() { - tracing::debug!(arg = %arg.display(), resolved = %candidate.display(), "Resolved workflow name via user workflows"); - Some(candidate) - } else { - None - } -} - -/// Return the user-level workflows directory (`~/.fabro/workflows/`). -fn user_workflows_dir() -> Option { - dirs::home_dir().map(|h| h.join(".fabro").join("workflows")) -} - -/// Metadata about a discovered workflow. -pub struct WorkflowInfo { - pub name: String, - pub goal: Option, - pub source: WorkflowSource, -} - -/// Where a workflow was discovered. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum WorkflowSource { - Project, - User, -} - -/// List workflow names in a single directory by scanning for subdirs containing `workflow.toml`. -fn list_workflows_in(workflows_dir: &Path) -> Vec { - let Ok(entries) = std::fs::read_dir(workflows_dir) else { - return Vec::new(); - }; - entries - .filter_map(|entry| { - let entry = entry.ok()?; - let path = entry.path(); - if path.is_dir() && path.join("workflow.toml").is_file() { - entry.file_name().to_str().map(String::from) - } else { - None - } - }) - .collect() -} - -/// Read the `goal` field from a `workflow.toml` without full config validation. -fn read_workflow_goal(workflow_toml: &Path) -> Option { - let content = std::fs::read_to_string(workflow_toml).ok()?; - let table: toml::Table = content.parse().ok()?; - table.get("goal")?.as_str().map(String::from) -} - -/// List workflows with metadata by scanning project and user workflow directories. -pub fn list_workflows_detailed( - project_workflows_dir: Option<&Path>, - user_workflows_dir: Option<&Path>, -) -> Vec { - let mut infos: Vec = Vec::new(); - let mut seen: Vec = Vec::new(); - - if let Some(dir) = project_workflows_dir { - for name in list_workflows_in(dir) { - let goal = read_workflow_goal(&dir.join(&name).join("workflow.toml")); - seen.push(name.clone()); - infos.push(WorkflowInfo { - name, - goal, - source: WorkflowSource::Project, - }); - } - } - if let Some(dir) = user_workflows_dir { - for name in list_workflows_in(dir) { - if !seen.contains(&name) { - let goal = read_workflow_goal(&dir.join(&name).join("workflow.toml")); - seen.push(name.clone()); - infos.push(WorkflowInfo { - name, - goal, - source: WorkflowSource::User, - }); - } - } - } - - infos.sort_by(|a, b| a.name.cmp(&b.name)); - infos -} - -/// List workflow names by scanning project and user workflow directories. -/// Project workflows appear first; user workflows are deduplicated. -pub fn list_available_workflows( - project_workflows_dir: Option<&Path>, - user_workflows_dir: Option<&Path>, -) -> Vec { - let mut names: Vec = Vec::new(); - - if let Some(dir) = project_workflows_dir { - names.extend(list_workflows_in(dir)); - } - if let Some(dir) = user_workflows_dir { - for name in list_workflows_in(dir) { - if !names.contains(&name) { - names.push(name); - } - } - } - - names.sort(); - names -} - -/// Find the closest match using normalized Levenshtein distance (threshold: 0.5). -fn find_closest_match(input: &str, candidates: &[String]) -> Option { - candidates - .iter() - .map(|c| (c, strsim::normalized_levenshtein(input, c))) - .filter(|(_, score)| *score >= 0.5) - .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)) - .map(|(name, _)| name.clone()) -} - -/// Resolve a workflow argument to a DOT path and optional run config. -/// -/// Calls `resolve_workflow_arg` first, then if the result is a `.toml` file, -/// loads the run config and resolves the graph path within it. -pub fn resolve_workflow( - arg: &Path, -) -> anyhow::Result<(PathBuf, Option)> { - let start = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); - resolve_workflow_from(arg, &start) -} - -fn resolve_workflow_from( - arg: &Path, - start_dir: &Path, -) -> anyhow::Result<(PathBuf, Option)> { - let path = resolve_workflow_arg_from(arg, start_dir)?; - if path.extension().is_some_and(|ext| ext == "toml") { - let cfg = super::run_config::load_run_config(&path)?; - let dot = super::run_config::resolve_graph_path(&path, &cfg.graph); - Ok((dot, Some(cfg))) - } else { - Ok((path, None)) - } -} - -/// Check whether retros are enabled in the project config. -/// Returns `false` (the default) if no config is found or on error. -/// Retros are an experimental feature gated behind `[features] retros = true`. -pub fn is_retro_enabled() -> bool { - let start = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); - match discover_project_config(&start) { - Ok(Some((_path, config))) => config.features.retros, - _ => false, - } -} - -/// Resolve the fabro root directory from a config file path and its config. -/// The returned path is the directory containing `fabro.toml` joined with the `root` value. -pub fn resolve_fabro_root(config_path: &Path, config: &ProjectConfig) -> PathBuf { - let project_dir = config_path - .parent() - .expect("config_path should have a parent directory"); - project_dir.join(&config.fabro.root) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::fs; - use tempfile::TempDir; - - #[test] - fn parse_minimal_config() { - let config = parse_project_config("version = 1\n").unwrap(); - assert_eq!( - config, - ProjectConfig { - version: 1, - fabro: ProjectFabroConfig { - root: ".".to_string(), - }, - ..Default::default() - } - ); - } - - #[test] - fn parse_full_config() { - let config = parse_project_config("version = 1\n[fabro]\nroot = \"fabro/\"\n").unwrap(); - assert_eq!(config.fabro.root, "fabro/"); - } - - #[test] - fn parse_retros_default_false() { - let config = parse_project_config("version = 1\n").unwrap(); - assert!(!config.features.retros); - } - - #[test] - fn parse_retros_enabled() { - let config = parse_project_config("version = 1\n[features]\nretros = true\n").unwrap(); - assert!(config.features.retros); - } - - #[test] - fn parse_version_mismatch() { - let err = parse_project_config("version = 2\n").unwrap_err(); - assert!( - err.to_string().contains("Unsupported"), - "Expected 'Unsupported' in error, got: {err}" - ); - } - - #[test] - fn parse_pull_request_config() { - let config = - parse_project_config("version = 1\n\n[pull_request]\nenabled = true\ndraft = false\n") - .unwrap(); - assert_eq!( - config.pull_request, - Some(PullRequestConfig { - enabled: true, - draft: false, - auto_merge: false, - merge_strategy: crate::cli::run_config::MergeStrategy::Squash, - }) - ); - } - - #[test] - fn parse_project_config_with_sandbox() { - let toml = r#" -version = 1 -[sandbox] -provider = "daytona" -[sandbox.daytona.snapshot] -name = "my-snapshot" -cpu = 4 -memory = 8 -"#; - let config = parse_project_config(toml).unwrap(); - let sandbox = config.sandbox.unwrap(); - assert_eq!(sandbox.provider.as_deref(), Some("daytona")); - let snap = sandbox.daytona.unwrap().snapshot.unwrap(); - assert_eq!(snap.name, "my-snapshot"); - assert_eq!(snap.cpu, Some(4)); - assert_eq!(snap.memory, Some(8)); - } - - #[test] - fn parse_project_config_with_hooks_and_mcp() { - let toml = r#" -version = 1 -[[hooks]] -event = "run_start" -command = "echo start" -[mcp_servers.playwright] -type = "stdio" -command = ["npx", "@playwright/mcp@latest"] -"#; - let config = parse_project_config(toml).unwrap(); - assert_eq!(config.hooks.len(), 1); - assert_eq!(config.mcp_servers.len(), 1); - assert!(config.mcp_servers.contains_key("playwright")); - } - - #[test] - fn parse_project_config_with_llm_and_work_dir() { - let toml = r#" -version = 1 -work_dir = "/workspace" -[llm] -model = "claude-sonnet-4-6" -"#; - let config = parse_project_config(toml).unwrap(); - assert_eq!(config.work_dir.as_deref(), Some("/workspace")); - assert_eq!( - config.llm.unwrap().model.as_deref(), - Some("claude-sonnet-4-6") - ); - } - - #[test] - fn into_run_defaults_preserves_fields() { - let toml = r#" -version = 1 -work_dir = "/ws" -[llm] -model = "m" -[sandbox] -provider = "daytona" -"#; - let config = parse_project_config(toml).unwrap(); - let defaults = config.into_run_defaults(); - assert_eq!(defaults.work_dir.as_deref(), Some("/ws")); - assert_eq!(defaults.llm.unwrap().model.as_deref(), Some("m")); - assert_eq!( - defaults.sandbox.unwrap().provider.as_deref(), - Some("daytona") - ); - } - - #[test] - fn parse_unknown_field_rejected() { - let err = parse_project_config("version = 1\nfoo = \"bar\"\n").unwrap_err(); - let chain = format!("{err:#}"); - assert!(chain.contains("unknown field"), "got: {chain}"); - } - - #[test] - fn load_from_disk() { - let tmp = TempDir::new().unwrap(); - let path = tmp.path().join("fabro.toml"); - fs::write(&path, "version = 1\n").unwrap(); - let config = load_project_config(&path).unwrap(); - assert_eq!(config.version, 1); - assert_eq!(config.fabro.root, "."); - } - - #[test] - fn discover_walks_ancestors() { - let tmp = TempDir::new().unwrap(); - fs::write(tmp.path().join("fabro.toml"), "version = 1\n").unwrap(); - let sub = tmp.path().join("sub").join("dir"); - fs::create_dir_all(&sub).unwrap(); - - let (found_path, config) = discover_project_config(&sub).unwrap().unwrap(); - assert_eq!(found_path, tmp.path().join("fabro.toml")); - assert_eq!(config.version, 1); - } - - #[test] - fn discover_returns_none_when_absent() { - let tmp = TempDir::new().unwrap(); - let result = discover_project_config(tmp.path()).unwrap(); - assert!(result.is_none()); - } - - #[test] - fn resolve_fabro_root_with_subdirectory() { - let config_path = Path::new("/repo/fabro.toml"); - let config = ProjectConfig { - version: 1, - fabro: ProjectFabroConfig { - root: "fabro/".to_string(), - ..Default::default() - }, - ..Default::default() - }; - assert_eq!( - resolve_fabro_root(config_path, &config), - Path::new("/repo/fabro/") - ); - } - - #[test] - fn resolve_fabro_root_with_dot() { - let config_path = Path::new("/repo/fabro.toml"); - let config = ProjectConfig { - version: 1, - fabro: ProjectFabroConfig { - root: ".".to_string(), - ..Default::default() - }, - ..Default::default() - }; - assert_eq!( - resolve_fabro_root(config_path, &config), - Path::new("/repo/.") - ); - } - - #[test] - fn resolve_workflow_arg_toml_extension_returned_as_is() { - let tmp = TempDir::new().unwrap(); - let result = resolve_workflow_arg_from(Path::new("my-workflow.toml"), tmp.path()).unwrap(); - assert_eq!(result, Path::new("my-workflow.toml")); - } - - #[test] - fn resolve_workflow_arg_fabro_extension_returned_as_is() { - let tmp = TempDir::new().unwrap(); - let result = resolve_workflow_arg_from(Path::new("my-workflow.fabro"), tmp.path()).unwrap(); - assert_eq!(result, Path::new("my-workflow.fabro")); - } - - #[test] - fn resolve_workflow_arg_no_extension_no_config_returns_literal() { - let tmp = TempDir::new().unwrap(); - let result = resolve_workflow_arg_from(Path::new("my-workflow"), tmp.path()).unwrap(); - assert_eq!(result, Path::new("my-workflow")); - } - - #[test] - fn resolve_workflow_arg_no_extension_with_config_and_workflow_file() { - let tmp = TempDir::new().unwrap(); - fs::write(tmp.path().join("fabro.toml"), "version = 1\n").unwrap(); - let wf_dir = tmp.path().join("workflows").join("my-workflow"); - fs::create_dir_all(&wf_dir).unwrap(); - fs::write( - wf_dir.join("workflow.toml"), - "version = 1\ngraph = \"workflow.fabro\"\n", - ) - .unwrap(); - - let result = resolve_workflow_arg_from(Path::new("my-workflow"), tmp.path()).unwrap(); - assert_eq!(result, wf_dir.join("workflow.toml")); - } - - #[test] - fn resolve_workflow_arg_typo_suggests_similar_name() { - let tmp = TempDir::new().unwrap(); - fs::write(tmp.path().join("fabro.toml"), "version = 1\n").unwrap(); - let wf_dir = tmp.path().join("workflows").join("implement"); - fs::create_dir_all(&wf_dir).unwrap(); - fs::write( - wf_dir.join("workflow.toml"), - "version = 1\ngraph = \"w.fabro\"\n", - ) - .unwrap(); - - let err = resolve_workflow_arg_from(Path::new("implemet"), tmp.path()).unwrap_err(); - let msg = err.to_string(); - assert!(msg.contains("Unknown workflow 'implemet'"), "got: {msg}"); - assert!(msg.contains("Did you mean 'implement'?"), "got: {msg}"); - } - - #[test] - fn resolve_workflow_arg_unknown_lists_available() { - let tmp = TempDir::new().unwrap(); - fs::write(tmp.path().join("fabro.toml"), "version = 1\n").unwrap(); - let wf_dir = tmp.path().join("workflows").join("hello"); - fs::create_dir_all(&wf_dir).unwrap(); - fs::write( - wf_dir.join("workflow.toml"), - "version = 1\ngraph = \"w.fabro\"\n", - ) - .unwrap(); - - let err = resolve_workflow_arg_from(Path::new("zzzzz"), tmp.path()).unwrap_err(); - let msg = err.to_string(); - assert!(msg.contains("Unknown workflow 'zzzzz'"), "got: {msg}"); - assert!(msg.contains("Available workflows: hello"), "got: {msg}"); - assert!(!msg.contains("Did you mean"), "got: {msg}"); - } - - #[test] - fn resolve_workflow_arg_no_workflows_dir() { - let tmp = TempDir::new().unwrap(); - fs::write(tmp.path().join("fabro.toml"), "version = 1\n").unwrap(); - - let err = resolve_workflow_arg_from(Path::new("my-workflow"), tmp.path()).unwrap_err(); - let msg = err.to_string(); - assert!(msg.contains("No workflows found"), "got: {msg}"); - } - - #[test] - fn resolve_workflow_arg_custom_root_respected() { - let tmp = TempDir::new().unwrap(); - fs::write( - tmp.path().join("fabro.toml"), - "version = 1\n[fabro]\nroot = \"fabro/\"\n", - ) - .unwrap(); - let wf_dir = tmp.path().join("fabro").join("workflows").join("factory"); - fs::create_dir_all(&wf_dir).unwrap(); - fs::write( - wf_dir.join("workflow.toml"), - "version = 1\ngraph = \"workflow.fabro\"\n", - ) - .unwrap(); - - let result = resolve_workflow_arg_from(Path::new("factory"), tmp.path()).unwrap(); - assert_eq!(result, wf_dir.join("workflow.toml")); - } - - /// Helper: create a workflow dir with workflow.toml + workflow.fabro inside `base/workflows/{name}/` - fn create_workflow_in(base: &Path, name: &str) { - let wf_dir = base.join("workflows").join(name); - fs::create_dir_all(&wf_dir).unwrap(); - fs::write( - wf_dir.join("workflow.toml"), - "version = 1\ngraph = \"workflow.fabro\"\n", - ) - .unwrap(); - fs::write( - wf_dir.join("workflow.fabro"), - "digraph G { start [shape=Mdiamond]; exit [shape=Msquare]; start -> exit }", - ) - .unwrap(); - } - - /// Helper: create a temp dir with fabro.toml + workflows/{name}/{workflow.toml, workflow.fabro} - fn setup_workflow_project(name: &str) -> (TempDir, PathBuf) { - let tmp = TempDir::new().unwrap(); - fs::write(tmp.path().join("fabro.toml"), "version = 1\n").unwrap(); - create_workflow_in(tmp.path(), name); - let dot_path = tmp - .path() - .join("workflows") - .join(name) - .join("workflow.fabro"); - (tmp, dot_path) - } - - #[test] - fn resolve_workflow_bare_name() { - let (tmp, expected_dot) = setup_workflow_project("hello"); - let (dot_path, cfg) = resolve_workflow_from(Path::new("hello"), tmp.path()).unwrap(); - assert_eq!( - dot_path.canonicalize().unwrap(), - expected_dot.canonicalize().unwrap() - ); - assert!(cfg.is_some(), "expected Some(RunConfig) for bare name"); - } - - #[test] - fn resolve_workflow_toml_path() { - let (tmp, expected_dot) = setup_workflow_project("hello"); - let toml_path = tmp.path().join("workflows/hello/workflow.toml"); - let (dot_path, cfg) = resolve_workflow_from(&toml_path, tmp.path()).unwrap(); - assert_eq!(dot_path, expected_dot); - assert!(cfg.is_some(), "expected Some(RunConfig) for .toml path"); - } - - #[test] - fn resolve_workflow_fabro_path() { - let (tmp, expected_dot) = setup_workflow_project("hello"); - let (dot_path, cfg) = resolve_workflow_from(&expected_dot, tmp.path()).unwrap(); - assert_eq!(dot_path, expected_dot); - assert!(cfg.is_none(), "expected None for .fabro path"); - } - - #[test] - fn resolve_workflow_arg_user_workflow_found() { - let project_dir = TempDir::new().unwrap(); - // No fabro.toml in project_dir - let user_dir = TempDir::new().unwrap(); - create_workflow_in(user_dir.path(), "my-wf"); - - let result = resolve_workflow_arg_impl( - Path::new("my-wf"), - project_dir.path(), - Some(user_dir.path().join("workflows").as_path()), - ) - .unwrap(); - assert_eq!( - result, - user_dir.path().join("workflows/my-wf/workflow.toml") - ); - } - - #[test] - fn resolve_workflow_arg_project_takes_precedence() { - let project_dir = TempDir::new().unwrap(); - fs::write(project_dir.path().join("fabro.toml"), "version = 1\n").unwrap(); - create_workflow_in(project_dir.path(), "shared"); - - let user_dir = TempDir::new().unwrap(); - create_workflow_in(user_dir.path(), "shared"); - - let result = resolve_workflow_arg_impl( - Path::new("shared"), - project_dir.path(), - Some(user_dir.path().join("workflows").as_path()), - ) - .unwrap(); - // Should resolve to project, not user - assert_eq!( - result, - project_dir.path().join("workflows/shared/workflow.toml") - ); - } - - #[test] - fn resolve_workflow_arg_user_fallback_when_project_missing() { - let project_dir = TempDir::new().unwrap(); - fs::write(project_dir.path().join("fabro.toml"), "version = 1\n").unwrap(); - // Project has a different workflow - create_workflow_in(project_dir.path(), "other"); - - let user_dir = TempDir::new().unwrap(); - create_workflow_in(user_dir.path(), "my-wf"); - - let result = resolve_workflow_arg_impl( - Path::new("my-wf"), - project_dir.path(), - Some(user_dir.path().join("workflows").as_path()), - ) - .unwrap(); - assert_eq!( - result, - user_dir.path().join("workflows/my-wf/workflow.toml") - ); - } - - #[test] - fn resolve_workflow_arg_user_workflow_listed_in_error() { - let project_dir = TempDir::new().unwrap(); - fs::write(project_dir.path().join("fabro.toml"), "version = 1\n").unwrap(); - create_workflow_in(project_dir.path(), "proj-wf"); - - let user_dir = TempDir::new().unwrap(); - create_workflow_in(user_dir.path(), "user-wf"); - - let err = resolve_workflow_arg_impl( - Path::new("nonexistent"), - project_dir.path(), - Some(user_dir.path().join("workflows").as_path()), - ) - .unwrap_err(); - let msg = err.to_string(); - assert!(msg.contains("proj-wf"), "expected proj-wf in: {msg}"); - assert!(msg.contains("user-wf"), "expected user-wf in: {msg}"); - } - - fn create_workflow_with_goal(base: &Path, name: &str, goal: &str) { - let wf_dir = base.join("workflows").join(name); - fs::create_dir_all(&wf_dir).unwrap(); - fs::write( - wf_dir.join("workflow.toml"), - format!("version = 1\ngoal = \"{goal}\"\ngraph = \"workflow.fabro\"\n"), - ) - .unwrap(); - } - - #[test] - fn list_workflows_detailed_project_only() { - let tmp = TempDir::new().unwrap(); - create_workflow_in(tmp.path(), "alpha"); - create_workflow_with_goal(tmp.path(), "beta", "Run tests"); - - let wf_dir = tmp.path().join("workflows"); - let infos = list_workflows_detailed(Some(&wf_dir), None); - - assert_eq!(infos.len(), 2); - assert_eq!(infos[0].name, "alpha"); - assert_eq!(infos[0].goal, None); - assert_eq!(infos[0].source, WorkflowSource::Project); - assert_eq!(infos[1].name, "beta"); - assert_eq!(infos[1].goal.as_deref(), Some("Run tests")); - assert_eq!(infos[1].source, WorkflowSource::Project); - } - - #[test] - fn list_workflows_detailed_user_only() { - let user = TempDir::new().unwrap(); - create_workflow_with_goal(user.path(), "my-wf", "Deploy app"); - - let user_wf_dir = user.path().join("workflows"); - let infos = list_workflows_detailed(None, Some(&user_wf_dir)); - - assert_eq!(infos.len(), 1); - assert_eq!(infos[0].name, "my-wf"); - assert_eq!(infos[0].goal.as_deref(), Some("Deploy app")); - assert_eq!(infos[0].source, WorkflowSource::User); - } - - #[test] - fn list_workflows_detailed_deduplicates_user() { - let project = TempDir::new().unwrap(); - create_workflow_with_goal(project.path(), "shared", "Project version"); - - let user = TempDir::new().unwrap(); - create_workflow_with_goal(user.path(), "shared", "User version"); - create_workflow_in(user.path(), "user-only"); - - let project_wf_dir = project.path().join("workflows"); - let user_wf_dir = user.path().join("workflows"); - let infos = list_workflows_detailed(Some(&project_wf_dir), Some(&user_wf_dir)); - - assert_eq!(infos.len(), 2); - let shared = infos.iter().find(|w| w.name == "shared").unwrap(); - assert_eq!(shared.source, WorkflowSource::Project); - assert_eq!(shared.goal.as_deref(), Some("Project version")); - let user_only = infos.iter().find(|w| w.name == "user-only").unwrap(); - assert_eq!(user_only.source, WorkflowSource::User); - } - - #[test] - fn list_workflows_detailed_sorted() { - let tmp = TempDir::new().unwrap(); - create_workflow_in(tmp.path(), "zebra"); - create_workflow_in(tmp.path(), "alpha"); - create_workflow_in(tmp.path(), "middle"); - - let wf_dir = tmp.path().join("workflows"); - let infos = list_workflows_detailed(Some(&wf_dir), None); - let names: Vec<_> = infos.iter().map(|w| w.name.as_str()).collect(); - assert_eq!(names, vec!["alpha", "middle", "zebra"]); - } - - #[test] - fn list_workflows_detailed_empty_dirs() { - let infos = list_workflows_detailed(None, None); - assert!(infos.is_empty()); - } - - #[test] - fn read_workflow_goal_extracts_goal() { - let tmp = TempDir::new().unwrap(); - let path = tmp.path().join("workflow.toml"); - fs::write( - &path, - "version = 1\ngoal = \"Hello world\"\ngraph = \"w.fabro\"\n", - ) - .unwrap(); - assert_eq!(read_workflow_goal(&path).as_deref(), Some("Hello world")); - } - - #[test] - fn read_workflow_goal_missing_field() { - let tmp = TempDir::new().unwrap(); - let path = tmp.path().join("workflow.toml"); - fs::write(&path, "version = 1\ngraph = \"w.fabro\"\n").unwrap(); - assert_eq!(read_workflow_goal(&path), None); - } - - #[test] - fn read_workflow_goal_missing_file() { - assert_eq!( - read_workflow_goal(Path::new("/nonexistent/workflow.toml")), - None - ); - } - - #[test] - fn parse_project_config_with_github() { - let toml = r#" -version = 1 - -[github] -permissions = { contents = "read" } -"#; - let config = parse_project_config(toml).unwrap(); - let github = config.github.unwrap(); - assert_eq!(github.permissions["contents"], "read"); - } - - #[test] - fn into_run_defaults_preserves_github() { - let toml = r#" -version = 1 - -[github] -permissions = { contents = "read", issues = "write" } -"#; - let config = parse_project_config(toml).unwrap(); - let defaults = config.into_run_defaults(); - let github = defaults.github.unwrap(); - assert_eq!(github.permissions["contents"], "read"); - assert_eq!(github.permissions["issues"], "write"); - } -} +// Re-export all project config types from fabro-config for backward compatibility. +pub use fabro_config::project::*; diff --git a/lib/crates/fabro-workflows/src/cli/run_config.rs b/lib/crates/fabro-workflows/src/cli/run_config.rs index 07d739fe8..be4fb615c 100644 --- a/lib/crates/fabro-workflows/src/cli/run_config.rs +++ b/lib/crates/fabro-workflows/src/cli/run_config.rs @@ -1,547 +1,19 @@ use std::collections::HashMap; -use std::path::{Path, PathBuf}; +use std::path::Path; use anyhow::{bail, Context}; -use serde::{Deserialize, Serialize}; -use tracing::debug; - -use fabro_mcp::config::{McpServerConfig, McpTransport}; - -use fabro_daytona::{DaytonaConfig, DockerfileSource}; - -const SUPPORTED_VERSION: u32 = 1; - -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] -pub struct CheckpointConfig { - #[serde(default)] - pub exclude_globs: Vec, -} - -fn default_true() -> bool { - true -} - -fn default_graph() -> String { - "workflow.fabro".to_string() -} - -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] -pub struct PullRequestConfig { - #[serde(default)] - pub enabled: bool, - #[serde(default = "default_true")] - pub draft: bool, - #[serde(default)] - pub auto_merge: bool, - #[serde(default)] - pub merge_strategy: MergeStrategy, -} - -#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum MergeStrategy { - #[default] - Squash, - Merge, - Rebase, -} - -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] -pub struct AssetsConfig { - #[serde(default)] - pub include: Vec, -} - -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] -pub struct GitHubConfig { - #[serde(default)] - pub permissions: HashMap, -} - -#[derive(Debug, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -pub struct WorkflowRunConfig { - pub version: u32, - pub goal: Option, - #[serde(default = "default_graph")] - pub graph: String, - #[serde(alias = "directory")] - pub work_dir: Option, - pub llm: Option, - pub setup: Option, - pub sandbox: Option, - pub vars: Option>, - #[serde(default)] - pub hooks: Vec, - #[serde(default)] - pub checkpoint: CheckpointConfig, - pub pull_request: Option, - pub assets: Option, - #[serde(default)] - pub mcp_servers: HashMap, - pub github: Option, -} - -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] -pub struct McpServerEntry { - #[serde(flatten)] - pub transport: McpTransport, - #[serde(default = "fabro_mcp::config::default_startup_timeout_secs")] - pub startup_timeout_secs: u64, - #[serde(default = "fabro_mcp::config::default_tool_timeout_secs")] - pub tool_timeout_secs: u64, -} - -impl McpServerEntry { - pub fn into_config(self, name: String) -> McpServerConfig { - McpServerConfig { - name, - transport: self.transport, - startup_timeout_secs: self.startup_timeout_secs, - tool_timeout_secs: self.tool_timeout_secs, - } - } -} - -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] -pub struct LlmConfig { - pub model: Option, - pub provider: Option, - #[serde(default)] - pub fallbacks: Option>>, -} - -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] -pub struct SetupConfig { - pub commands: Vec, - pub timeout_ms: Option, -} - -#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum WorktreeMode { - Always, - #[default] - Clean, - Dirty, - Never, -} - -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] -pub struct LocalSandboxConfig { - #[serde(default)] - pub worktree_mode: WorktreeMode, -} - -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] -pub struct SandboxConfig { - pub provider: Option, - pub preserve: Option, - #[serde(default)] - pub devcontainer: Option, - pub local: Option, - pub daytona: Option, - #[cfg(feature = "exedev")] - pub exe: Option, - pub ssh: Option, - pub env: Option>, -} - -/// Defaults for workflow runs, loaded from the server config. -/// -/// Fields mirror `WorkflowRunConfig` but are all optional. -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] -pub struct RunDefaults { - #[serde(alias = "directory")] - pub work_dir: Option, - pub llm: Option, - pub setup: Option, - pub sandbox: Option, - pub vars: Option>, - #[serde(default)] - pub checkpoint: CheckpointConfig, - pub pull_request: Option, - pub assets: Option, - #[serde(default)] - pub hooks: Vec, - #[serde(default)] - pub mcp_servers: HashMap, - pub github: Option, -} - -impl WorkflowRunConfig { - /// Apply server-level run defaults to this config. - /// - /// Each field uses the first non-`None` value (task config wins). - /// Vars are merged: defaults first, then task config overwrites. - pub fn apply_defaults(&mut self, defaults: &RunDefaults) { - if self.work_dir.is_none() { - self.work_dir = defaults.work_dir.clone(); - } - - match (&mut self.llm, &defaults.llm) { - (Some(task), Some(default)) => { - if task.model.is_none() { - task.model = default.model.clone(); - } - if task.provider.is_none() { - task.provider = default.provider.clone(); - } - if task.fallbacks.is_none() { - task.fallbacks = default.fallbacks.clone(); - } - } - (None, Some(_)) => self.llm = defaults.llm.clone(), - _ => {} - } - - match (&mut self.setup, &defaults.setup) { - (Some(task), Some(default)) => { - if task.timeout_ms.is_none() { - task.timeout_ms = default.timeout_ms; - } - } - (None, Some(_)) => self.setup = defaults.setup.clone(), - _ => {} - } - - match (&mut self.sandbox, &defaults.sandbox) { - (Some(task), Some(default)) => { - if task.provider.is_none() { - task.provider = default.provider.clone(); - } - if task.preserve.is_none() { - task.preserve = default.preserve; - } - if task.devcontainer.is_none() { - task.devcontainer = default.devcontainer; - } - if task.local.is_none() { - task.local = default.local.clone(); - } - match (&mut task.daytona, &default.daytona) { - (Some(task_d), Some(default_d)) => { - if task_d.auto_stop_interval.is_none() { - task_d.auto_stop_interval = default_d.auto_stop_interval; - } - if task_d.snapshot.is_none() { - task_d.snapshot = default_d.snapshot.clone(); - } - if let Some(ref default_labels) = default_d.labels { - let mut merged = default_labels.clone(); - if let Some(ref task_labels) = task_d.labels { - merged.extend(task_labels.clone()); - } - task_d.labels = Some(merged); - } - if task_d.network.is_none() { - task_d.network = default_d.network.clone(); - } - } - (None, Some(_)) => task.daytona = default.daytona.clone(), - _ => {} - } - #[cfg(feature = "exedev")] - match (&mut task.exe, &default.exe) { - (Some(task_e), Some(default_e)) => { - if task_e.image.is_none() { - task_e.image = default_e.image.clone(); - } - } - (None, Some(_)) => task.exe = default.exe.clone(), - _ => {} - } - if let Some(ref default_env) = default.env { - let mut merged = default_env.clone(); - if let Some(ref task_env) = task.env { - merged.extend(task_env.clone()); - } - task.env = Some(merged); - } - } - (None, Some(_)) => self.sandbox = defaults.sandbox.clone(), - _ => {} - } - - if let Some(ref default_vars) = defaults.vars { - let mut merged = default_vars.clone(); - if let Some(ref task_vars) = self.vars { - merged.extend(task_vars.clone()); - } - self.vars = Some(merged); - } - - // Union checkpoint exclude globs from defaults and task config, dedup - if !defaults.checkpoint.exclude_globs.is_empty() { - let mut merged = defaults.checkpoint.exclude_globs.clone(); - merged.append(&mut self.checkpoint.exclude_globs); - merged.sort(); - merged.dedup(); - self.checkpoint.exclude_globs = merged; - } - - if self.pull_request.is_none() { - self.pull_request = defaults.pull_request.clone(); - } - - if self.assets.is_none() { - self.assets = defaults.assets.clone(); - } - - // Merge hooks: defaults as base, workflow overrides by name - if !defaults.hooks.is_empty() { - let base = fabro_hooks::HookConfig { - hooks: defaults.hooks.clone(), - }; - let overlay = fabro_hooks::HookConfig { - hooks: std::mem::take(&mut self.hooks), - }; - self.hooks = base.merge(overlay).hooks; - } - - // Merge mcp_servers: defaults as base, workflow overrides by key - if !defaults.mcp_servers.is_empty() { - let mut merged = defaults.mcp_servers.clone(); - merged.extend(std::mem::take(&mut self.mcp_servers)); - self.mcp_servers = merged; - } - - if self.github.is_none() { - self.github = defaults.github.clone(); - } - } -} - -impl RunDefaults { - /// Merge an overlay on top of this base. The overlay takes precedence - /// for simple fields; compound fields (vars, hooks, mcp_servers) are - /// deep-merged with the overlay winning on collision. - /// - /// Uses the same deep-merge semantics as `WorkflowRunConfig::apply_defaults`. - pub fn merge_overlay(&mut self, overlay: RunDefaults) { - if overlay.work_dir.is_some() { - self.work_dir = overlay.work_dir; - } - - match (&mut self.llm, overlay.llm) { - (Some(base), Some(over)) => { - if over.model.is_some() { - base.model = over.model; - } - if over.provider.is_some() { - base.provider = over.provider; - } - if over.fallbacks.is_some() { - base.fallbacks = over.fallbacks; - } - } - (None, Some(over)) => self.llm = Some(over), - _ => {} - } - - match (&mut self.setup, overlay.setup) { - (Some(base), Some(over)) => { - if over.timeout_ms.is_some() { - base.timeout_ms = over.timeout_ms; - } - } - (None, Some(over)) => self.setup = Some(over), - _ => {} - } - - match (&mut self.sandbox, overlay.sandbox) { - (Some(base), Some(over)) => { - if over.provider.is_some() { - base.provider = over.provider; - } - if over.preserve.is_some() { - base.preserve = over.preserve; - } - if over.devcontainer.is_some() { - base.devcontainer = over.devcontainer; - } - if over.local.is_some() { - base.local = over.local; - } - match (&mut base.daytona, over.daytona) { - (Some(base_d), Some(over_d)) => { - if over_d.auto_stop_interval.is_some() { - base_d.auto_stop_interval = over_d.auto_stop_interval; - } - if over_d.snapshot.is_some() { - base_d.snapshot = over_d.snapshot; - } - if let Some(over_labels) = over_d.labels { - let mut merged = base_d.labels.take().unwrap_or_default(); - merged.extend(over_labels); - base_d.labels = Some(merged); - } - if over_d.network.is_some() { - base_d.network = over_d.network; - } - } - (None, Some(over_d)) => base.daytona = Some(over_d), - _ => {} - } - #[cfg(feature = "exedev")] - match (&mut base.exe, over.exe) { - (Some(base_e), Some(over_e)) => { - if over_e.image.is_some() { - base_e.image = over_e.image; - } - } - (None, Some(over_e)) => base.exe = Some(over_e), - _ => {} - } - if let Some(over_env) = over.env { - let mut merged = base.env.take().unwrap_or_default(); - merged.extend(over_env); - base.env = Some(merged); - } - } - (None, Some(over)) => self.sandbox = Some(over), - _ => {} - } - - if let Some(overlay_vars) = overlay.vars { - let mut merged = self.vars.take().unwrap_or_default(); - merged.extend(overlay_vars); - self.vars = Some(merged); - } - - if !overlay.checkpoint.exclude_globs.is_empty() { - self.checkpoint - .exclude_globs - .append(&mut overlay.checkpoint.exclude_globs.clone()); - self.checkpoint.exclude_globs.sort(); - self.checkpoint.exclude_globs.dedup(); - } - - if overlay.pull_request.is_some() { - self.pull_request = overlay.pull_request; - } - - if overlay.assets.is_some() { - self.assets = overlay.assets; - } - - if !overlay.hooks.is_empty() { - let base = fabro_hooks::HookConfig { - hooks: std::mem::take(&mut self.hooks), - }; - let over = fabro_hooks::HookConfig { - hooks: overlay.hooks, - }; - self.hooks = base.merge(over).hooks; - } - - if !overlay.mcp_servers.is_empty() { - let mut merged = std::mem::take(&mut self.mcp_servers); - merged.extend(overlay.mcp_servers); - self.mcp_servers = merged; - } - - if overlay.github.is_some() { - self.github = overlay.github; - } - } -} - -/// Load and validate a run config from a TOML file. -/// -/// The `graph` path in the returned config is resolved relative to the -/// TOML file's parent directory. Any `dockerfile = { path = "..." }` is -/// resolved to inline content. -pub fn load_run_config(path: &Path) -> anyhow::Result { - let contents = std::fs::read_to_string(path) - .with_context(|| format!("Failed to read {}", path.display()))?; - let mut config = parse_run_config(&contents)?; - - let config_dir = path.parent().unwrap_or(Path::new(".")); - resolve_dockerfile(&mut config, config_dir)?; - resolve_sandbox_env(&mut config)?; - - Ok(config) -} - -/// Resolve `${env.VARNAME}` references in `[sandbox.env]` values. -/// -/// Only whole-value references are supported (no partial interpolation). -/// Missing host env vars produce a hard error. -fn resolve_sandbox_env(config: &mut WorkflowRunConfig) -> anyhow::Result<()> { - if let Some(env) = config.sandbox.as_mut().and_then(|s| s.env.as_mut()) { - resolve_env_refs(env)?; - } - Ok(()) -} - -/// Resolve `${env.VARNAME}` patterns in a map of env vars. -/// -/// If the entire value is `${env.VARNAME}`, it is replaced with the host -/// environment variable. Any other value is left as-is. Missing host -/// variables produce an error. -pub fn resolve_env_refs(env: &mut HashMap) -> anyhow::Result<()> { - for (key, value) in env.iter_mut() { - if let Some(var_name) = value - .strip_prefix("${env.") - .and_then(|s| s.strip_suffix('}')) - { - *value = std::env::var(var_name).with_context(|| { - format!("sandbox.env.{key}: host environment variable {var_name:?} is not set") - })?; - } - } - Ok(()) -} - -/// If the config contains a `dockerfile = { path = "..." }`, read the file -/// and replace it with `DockerfileSource::Inline(contents)`. -fn resolve_dockerfile(config: &mut WorkflowRunConfig, config_dir: &Path) -> anyhow::Result<()> { - let source = config - .sandbox - .as_mut() - .and_then(|s| s.daytona.as_mut()) - .and_then(|d| d.snapshot.as_mut()) - .and_then(|snap| snap.dockerfile.as_mut()); - - if let Some(DockerfileSource::Path { path: ref rel }) = source { - let path = config_dir.join(rel); - let contents = std::fs::read_to_string(&path) - .with_context(|| format!("Failed to read dockerfile at {}", path.display()))?; - debug!(path = %path.display(), "Resolved dockerfile from path"); - *source.unwrap() = DockerfileSource::Inline(contents); - } - - Ok(()) -} - -/// Resolve the graph path relative to the TOML file's parent directory. -pub fn resolve_graph_path(toml_path: &Path, graph: &str) -> PathBuf { - let graph_path = Path::new(graph); - if graph_path.is_absolute() { - graph_path.to_path_buf() - } else { - toml_path - .parent() - .unwrap_or(Path::new(".")) - .join(graph_path) - } -} - -fn parse_run_config(contents: &str) -> anyhow::Result { - let config: WorkflowRunConfig = - toml::from_str(contents).context("Failed to parse run config TOML")?; - - if config.version != SUPPORTED_VERSION { - bail!( - "Unsupported run config version {}. Only version {SUPPORTED_VERSION} is supported.", - config.version - ); - } - - Ok(config) -} +// Re-export all config types from fabro-config for backward compatibility. +pub use fabro_config::mcp::{McpServerConfig, McpServerEntry, McpTransport}; +pub use fabro_config::run::{ + load_run_config, parse_run_config, resolve_env_refs, resolve_graph_path, AssetsConfig, + CheckpointConfig, GitHubConfig, LlmConfig, MergeStrategy, PullRequestConfig, RunDefaults, + SetupConfig, WorkflowRunConfig, +}; +pub use fabro_config::sandbox::{ + DaytonaConfig, DaytonaNetwork, DaytonaSnapshotConfig, DockerfileSource, LocalSandboxConfig, + SandboxConfig, WorktreeMode, +}; /// Expand `$name` placeholders in `source` using the given variable map. /// @@ -623,7 +95,7 @@ pub async fn run_setup(setup: &SetupConfig, directory: &Path) -> anyhow::Result< #[cfg(test)] mod tests { use super::*; - use fabro_daytona::{DaytonaSnapshotConfig, DockerfileSource}; + use super::{DaytonaSnapshotConfig, DockerfileSource}; #[test] fn parse_toml_with_vars() { @@ -768,38 +240,19 @@ auto_stop_interval = 60 [sandbox.daytona.labels] project = "fabro" - -[sandbox.daytona.snapshot] -name = "my-snapshot" -cpu = 4 -memory = 8 -disk = 10 -dockerfile = "FROM rust:1.85-slim-bookworm\nRUN apt-get update" +environment = "ci" "#; let config = parse_run_config(toml).unwrap(); let sandbox = config.sandbox.unwrap(); - assert_eq!(sandbox.provider.as_deref(), Some("daytona")); - let daytona = sandbox.daytona.unwrap(); assert_eq!(daytona.auto_stop_interval, Some(60)); let labels = daytona.labels.unwrap(); assert_eq!(labels["project"], "fabro"); - - let snapshot = daytona.snapshot.unwrap(); - assert_eq!(snapshot.name, "my-snapshot"); - assert_eq!(snapshot.cpu, Some(4)); - assert_eq!(snapshot.memory, Some(8)); - assert_eq!(snapshot.disk, Some(10)); - assert_eq!( - snapshot.dockerfile, - Some(DockerfileSource::Inline( - "FROM rust:1.85-slim-bookworm\nRUN apt-get update".into() - )) - ); + assert_eq!(labels["environment"], "ci"); } #[test] - fn parse_toml_with_daytona_no_snapshot() { + fn parse_toml_with_daytona_snapshot() { let toml = r#" version = 1 goal = "Run tests" @@ -808,151 +261,58 @@ graph = "workflow.fabro" [sandbox] provider = "daytona" -[sandbox.daytona] -auto_stop_interval = 30 +[sandbox.daytona.snapshot] +name = "my-snapshot" +cpu = 4 +memory = 8 +disk = 32 "#; let config = parse_run_config(toml).unwrap(); - let daytona = config.sandbox.unwrap().daytona.unwrap(); - assert_eq!(daytona.auto_stop_interval, Some(30)); - assert!(daytona.snapshot.is_none()); + let snap = config.sandbox.unwrap().daytona.unwrap().snapshot.unwrap(); + assert_eq!(snap.name, "my-snapshot"); + assert_eq!(snap.cpu, Some(4)); + assert_eq!(snap.memory, Some(8)); + assert_eq!(snap.disk, Some(32)); } #[test] - fn parse_minimal_toml() { + fn parse_toml_with_inline_dockerfile() { let toml = r#" version = 1 -goal = "Run tests" +goal = "test" graph = "workflow.fabro" + +[sandbox.daytona.snapshot] +name = "custom" +dockerfile = "FROM rust:1.85-slim-bookworm" "#; let config = parse_run_config(toml).unwrap(); - assert_eq!(config.version, 1); - assert_eq!(config.goal.as_deref(), Some("Run tests")); - assert_eq!(config.graph, "workflow.fabro"); - assert!(config.work_dir.is_none()); - assert!(config.llm.is_none()); - assert!(config.setup.is_none()); - } - - #[test] - fn parse_toml_without_goal() { - let toml = r#" -version = 1 -graph = "workflow.fabro" -"#; - let config = parse_run_config(toml).unwrap(); - assert!(config.goal.is_none()); - } - - #[test] - fn parse_full_toml() { - let toml = r#" -version = 1 -goal = "Full workflow" -graph = "workflow.fabro" -directory = "/tmp/repo" - -[llm] -model = "claude-haiku" -provider = "anthropic" - -[setup] -commands = ["pip install -r requirements.txt", "npm install"] -timeout_ms = 60000 -"#; - let config = parse_run_config(toml).unwrap(); - assert_eq!(config.goal.as_deref(), Some("Full workflow")); - assert_eq!(config.work_dir.as_deref(), Some("/tmp/repo")); - - let llm = config.llm.unwrap(); - assert_eq!(llm.model.as_deref(), Some("claude-haiku")); - assert_eq!(llm.provider.as_deref(), Some("anthropic")); - - let setup = config.setup.unwrap(); - assert_eq!(setup.commands.len(), 2); - assert_eq!(setup.timeout_ms, Some(60000)); - } - - #[test] - fn parse_toml_with_work_dir() { - let toml = r#" -version = 1 -graph = "workflow.fabro" -work_dir = "/workspace" -"#; - let config = parse_run_config(toml).unwrap(); - assert_eq!(config.work_dir.as_deref(), Some("/workspace")); - } - - #[test] - fn parse_run_defaults_directory_alias() { - let toml = r#"directory = "/old""#; - let defaults: RunDefaults = toml::from_str(toml).unwrap(); - assert_eq!(defaults.work_dir.as_deref(), Some("/old")); - } - - #[test] - fn unsupported_version_rejected() { - let toml = r#" -version = 2 -goal = "x" -graph = "p.fabro" -"#; - let err = parse_run_config(toml).unwrap_err(); - assert!( - err.to_string().contains("Unsupported run config version 2"), - "unexpected error: {err}" + let snap = config.sandbox.unwrap().daytona.unwrap().snapshot.unwrap(); + assert_eq!( + snap.dockerfile, + Some(DockerfileSource::Inline( + "FROM rust:1.85-slim-bookworm".into() + )) ); } #[test] - fn graph_path_resolved_relative_to_toml() { - let toml_path = Path::new("/tmp/sub/task.toml"); - let resolved = resolve_graph_path(toml_path, "p.fabro"); - assert_eq!(resolved, PathBuf::from("/tmp/sub/p.fabro")); - } - - #[test] - fn graph_path_absolute_unchanged() { - let toml_path = Path::new("/tmp/sub/task.toml"); - let resolved = resolve_graph_path(toml_path, "/other/workflow.fabro"); - assert_eq!(resolved, PathBuf::from("/other/workflow.fabro")); - } - - #[test] - fn goal_is_optional() { - let no_goal = r#" -version = 1 -graph = "p.fabro" -"#; - let config = parse_run_config(no_goal).unwrap(); - assert!(config.goal.is_none()); - } - - #[test] - fn graph_defaults_when_omitted() { - let no_graph = r#" -version = 1 -goal = "x" -"#; - let config = parse_run_config(no_graph).unwrap(); - assert_eq!(config.graph, "workflow.fabro"); - } - - #[test] - fn parse_toml_with_dockerfile_path() { + fn parse_toml_with_path_dockerfile() { let toml = r#" version = 1 -goal = "Run tests" +goal = "test" graph = "workflow.fabro" [sandbox.daytona.snapshot] -name = "my-snapshot" -dockerfile = { path = "./Dockerfile" } +name = "custom" + +[sandbox.daytona.snapshot.dockerfile] +path = "./Dockerfile" "#; let config = parse_run_config(toml).unwrap(); - let snapshot = config.sandbox.unwrap().daytona.unwrap().snapshot.unwrap(); + let snap = config.sandbox.unwrap().daytona.unwrap().snapshot.unwrap(); assert_eq!( - snapshot.dockerfile, + snap.dockerfile, Some(DockerfileSource::Path { path: "./Dockerfile".into() }) @@ -960,455 +320,117 @@ dockerfile = { path = "./Dockerfile" } } #[test] - fn load_run_config_resolves_dockerfile_path() { + fn resolve_dockerfile_replaces_path_with_content() { let dir = tempfile::tempdir().unwrap(); let dockerfile_path = dir.path().join("Dockerfile"); - std::fs::write( - &dockerfile_path, - "FROM rust:1.85-slim-bookworm\nRUN apt-get update", - ) - .unwrap(); - - let toml_path = dir.path().join("run.toml"); + std::fs::write(&dockerfile_path, "FROM ubuntu:24.04\nRUN apt-get update").unwrap(); + let toml_path = dir.path().join("workflow.toml"); std::fs::write( &toml_path, r#" version = 1 -goal = "Run tests" +goal = "test" graph = "workflow.fabro" [sandbox.daytona.snapshot] -name = "my-snapshot" -dockerfile = { path = "Dockerfile" } +name = "custom" + +[sandbox.daytona.snapshot.dockerfile] +path = "./Dockerfile" "#, ) .unwrap(); - let config = load_run_config(&toml_path).unwrap(); - let snapshot = config.sandbox.unwrap().daytona.unwrap().snapshot.unwrap(); + let snap = config.sandbox.unwrap().daytona.unwrap().snapshot.unwrap(); assert_eq!( - snapshot.dockerfile, + snap.dockerfile, Some(DockerfileSource::Inline( - "FROM rust:1.85-slim-bookworm\nRUN apt-get update".into() + "FROM ubuntu:24.04\nRUN apt-get update".into() )) ); } #[test] - fn load_run_config_dockerfile_path_not_found() { - let dir = tempfile::tempdir().unwrap(); - let toml_path = dir.path().join("run.toml"); - std::fs::write( - &toml_path, - r#" + fn apply_defaults_fills_missing_fields() { + let defaults = RunDefaults { + work_dir: Some("/ws".into()), + llm: Some(LlmConfig { + model: Some("m".into()), + provider: Some("p".into()), + fallbacks: None, + }), + ..Default::default() + }; + let toml = r#" version = 1 -goal = "Run tests" graph = "workflow.fabro" +"#; + let mut config = parse_run_config(toml).unwrap(); + config.apply_defaults(&defaults); + assert_eq!(config.work_dir.as_deref(), Some("/ws")); + assert_eq!(config.llm.as_ref().unwrap().model.as_deref(), Some("m")); + } -[sandbox.daytona.snapshot] -name = "my-snapshot" -dockerfile = { path = "nonexistent" } -"#, - ) - .unwrap(); + #[test] + fn apply_defaults_task_wins() { + let defaults = RunDefaults { + work_dir: Some("/default".into()), + llm: Some(LlmConfig { + model: Some("default-m".into()), + provider: Some("default-p".into()), + fallbacks: None, + }), + ..Default::default() + }; + let toml = r#" +version = 1 +graph = "workflow.fabro" +work_dir = "/task" - let err = load_run_config(&toml_path).unwrap_err(); - assert!( - err.to_string().contains("Failed to read dockerfile"), - "unexpected error: {err}" +[llm] +model = "task-m" +"#; + let mut config = parse_run_config(toml).unwrap(); + config.apply_defaults(&defaults); + assert_eq!(config.work_dir.as_deref(), Some("/task")); + assert_eq!( + config.llm.as_ref().unwrap().model.as_deref(), + Some("task-m") + ); + // provider filled from defaults + assert_eq!( + config.llm.as_ref().unwrap().provider.as_deref(), + Some("default-p") ); } - #[test] - fn parse_run_defaults_with_llm() { - let toml = r#" -[llm] -model = "claude-haiku" -provider = "anthropic" -"#; - let defaults: RunDefaults = toml::from_str(toml).unwrap(); - let llm = defaults.llm.unwrap(); - assert_eq!(llm.model.as_deref(), Some("claude-haiku")); - assert_eq!(llm.provider.as_deref(), Some("anthropic")); - } - - #[test] - fn parse_run_defaults_empty() { - let defaults: RunDefaults = toml::from_str("").unwrap(); - assert!(defaults.work_dir.is_none()); - assert!(defaults.llm.is_none()); - assert!(defaults.setup.is_none()); - assert!(defaults.sandbox.is_none()); - assert!(defaults.vars.is_none()); - } - - #[test] - fn parse_run_defaults_full() { - let toml = r#" -directory = "/work" - -[llm] -model = "gpt-4" -provider = "openai" - -[setup] -commands = ["make build"] -timeout_ms = 5000 - -[sandbox] -provider = "daytona" - -[vars] -key = "value" -"#; - let defaults: RunDefaults = toml::from_str(toml).unwrap(); - assert_eq!(defaults.work_dir.as_deref(), Some("/work")); - assert!(defaults.llm.is_some()); - assert!(defaults.setup.is_some()); - assert!(defaults.sandbox.is_some()); - assert_eq!(defaults.vars.as_ref().unwrap()["key"], "value"); - } - - #[test] - fn apply_defaults_fills_missing_llm() { - let mut cfg = parse_run_config( - r#" -version = 1 -goal = "test" -graph = "w.fabro" -"#, - ) - .unwrap(); - let defaults = RunDefaults { - llm: Some(LlmConfig { - model: Some("default-model".into()), - provider: Some("anthropic".into()), - fallbacks: None, - }), - ..RunDefaults::default() - }; - cfg.apply_defaults(&defaults); - let llm = cfg.llm.unwrap(); - assert_eq!(llm.model.as_deref(), Some("default-model")); - } - - #[test] - fn apply_defaults_task_config_wins() { - let mut cfg = parse_run_config( - r#" -version = 1 -goal = "test" -graph = "w.fabro" - -[llm] -model = "task-model" -"#, - ) - .unwrap(); - let defaults = RunDefaults { - llm: Some(LlmConfig { - model: Some("default-model".into()), - provider: None, - fallbacks: None, - }), - ..RunDefaults::default() - }; - cfg.apply_defaults(&defaults); - let llm = cfg.llm.unwrap(); - assert_eq!(llm.model.as_deref(), Some("task-model")); - } - #[test] fn apply_defaults_merges_vars() { - let mut cfg = parse_run_config( - r#" -version = 1 -goal = "test" -graph = "w.fabro" - -[vars] -task_key = "task_val" -shared = "from_task" -"#, - ) - .unwrap(); let defaults = RunDefaults { vars: Some(HashMap::from([ - ("default_key".into(), "default_val".into()), - ("shared".into(), "from_default".into()), + ("a".into(), "1".into()), + ("b".into(), "2".into()), ])), - ..RunDefaults::default() + ..Default::default() }; - cfg.apply_defaults(&defaults); - let vars = cfg.vars.unwrap(); - assert_eq!(vars["default_key"], "default_val"); - assert_eq!(vars["task_key"], "task_val"); - // Task config wins on collision - assert_eq!(vars["shared"], "from_task"); - } - - #[test] - fn apply_defaults_vars_default_only() { - let mut cfg = parse_run_config( - r#" -version = 1 -goal = "test" -graph = "w.fabro" -"#, - ) - .unwrap(); - let defaults = RunDefaults { - vars: Some(HashMap::from([("key".into(), "val".into())])), - ..RunDefaults::default() - }; - cfg.apply_defaults(&defaults); - let vars = cfg.vars.unwrap(); - assert_eq!(vars["key"], "val"); - } - - #[test] - fn apply_defaults_merges_llm_fields() { - let mut cfg = parse_run_config( - r#" -version = 1 -goal = "test" -graph = "w.fabro" - -[llm] -model = "haiku" -"#, - ) - .unwrap(); - let defaults = RunDefaults { - llm: Some(LlmConfig { - model: None, - provider: Some("anthropic".into()), - fallbacks: None, - }), - ..RunDefaults::default() - }; - cfg.apply_defaults(&defaults); - let llm = cfg.llm.unwrap(); - assert_eq!(llm.model.as_deref(), Some("haiku")); - assert_eq!(llm.provider.as_deref(), Some("anthropic")); - } - - #[test] - fn apply_defaults_merges_setup_timeout() { - let mut cfg = parse_run_config( - r#" -version = 1 -goal = "test" -graph = "w.fabro" - -[setup] -commands = ["make test"] -"#, - ) - .unwrap(); - let defaults = RunDefaults { - setup: Some(SetupConfig { - commands: vec!["make build".into()], - timeout_ms: Some(60000), - }), - ..RunDefaults::default() - }; - cfg.apply_defaults(&defaults); - let setup = cfg.setup.unwrap(); - assert_eq!(setup.commands, vec!["make test"]); - assert_eq!(setup.timeout_ms, Some(60000)); - } - - #[test] - fn parse_toml_with_sandbox_preserve() { let toml = r#" version = 1 -goal = "Run tests" graph = "workflow.fabro" -[sandbox] -provider = "docker" -preserve = true +[vars] +b = "override" +c = "3" "#; - let config = parse_run_config(toml).unwrap(); - let sandbox = config.sandbox.unwrap(); - assert_eq!(sandbox.preserve, Some(true)); + let mut config = parse_run_config(toml).unwrap(); + config.apply_defaults(&defaults); + let vars = config.vars.unwrap(); + assert_eq!(vars["a"], "1"); + assert_eq!(vars["b"], "override"); + assert_eq!(vars["c"], "3"); } #[test] - fn parse_toml_sandbox_preserve_defaults_to_none() { - let toml = r#" -version = 1 -goal = "Run tests" -graph = "workflow.fabro" - -[sandbox] -provider = "docker" -"#; - let config = parse_run_config(toml).unwrap(); - let sandbox = config.sandbox.unwrap(); - assert_eq!(sandbox.preserve, None); - } - - #[test] - fn parse_toml_worktree_mode_always() { - let toml = r#" -version = 1 -goal = "test" -graph = "w.fabro" - -[sandbox.local] -worktree_mode = "always" -"#; - let config = parse_run_config(toml).unwrap(); - let local = config.sandbox.unwrap().local.unwrap(); - assert_eq!(local.worktree_mode, WorktreeMode::Always); - } - - #[test] - fn parse_toml_worktree_mode_defaults_to_clean() { - let toml = r#" -version = 1 -goal = "test" -graph = "w.fabro" - -[sandbox] -provider = "local" -"#; - let config = parse_run_config(toml).unwrap(); - let sandbox = config.sandbox.unwrap(); - assert_eq!(sandbox.local, None); - } - - #[test] - fn parse_toml_worktree_mode_empty_local_defaults_to_clean() { - let toml = r#" -version = 1 -goal = "test" -graph = "w.fabro" - -[sandbox.local] -"#; - let config = parse_run_config(toml).unwrap(); - let local = config.sandbox.unwrap().local.unwrap(); - assert_eq!(local.worktree_mode, WorktreeMode::Clean); - } - - #[test] - fn apply_defaults_merges_sandbox_preserve_task_wins() { - let mut cfg = parse_run_config( - r#" -version = 1 -goal = "test" -graph = "w.fabro" - -[sandbox] -preserve = true -"#, - ) - .unwrap(); - let defaults = RunDefaults { - sandbox: Some(SandboxConfig { - provider: None, - preserve: Some(false), - devcontainer: None, - local: None, - daytona: None, - #[cfg(feature = "exedev")] - exe: None, - ssh: None, - env: None, - }), - ..RunDefaults::default() - }; - cfg.apply_defaults(&defaults); - assert_eq!(cfg.sandbox.unwrap().preserve, Some(true)); - } - - #[test] - fn apply_defaults_merges_sandbox_preserve_from_default() { - let mut cfg = parse_run_config( - r#" -version = 1 -goal = "test" -graph = "w.fabro" - -[sandbox] -provider = "docker" -"#, - ) - .unwrap(); - let defaults = RunDefaults { - sandbox: Some(SandboxConfig { - provider: None, - preserve: Some(true), - devcontainer: None, - local: None, - daytona: None, - #[cfg(feature = "exedev")] - exe: None, - ssh: None, - env: None, - }), - ..RunDefaults::default() - }; - cfg.apply_defaults(&defaults); - assert_eq!(cfg.sandbox.unwrap().preserve, Some(true)); - } - - #[test] - fn apply_defaults_merges_sandbox_fields() { - let mut cfg = parse_run_config( - r#" -version = 1 -goal = "test" -graph = "w.fabro" - -[sandbox] -provider = "daytona" -"#, - ) - .unwrap(); - let defaults = RunDefaults { - sandbox: Some(SandboxConfig { - provider: None, - preserve: None, - devcontainer: None, - local: None, - daytona: Some(DaytonaConfig { - auto_stop_interval: Some(30), - ..DaytonaConfig::default() - }), - #[cfg(feature = "exedev")] - exe: None, - ssh: None, - env: None, - }), - ..RunDefaults::default() - }; - cfg.apply_defaults(&defaults); - let sandbox = cfg.sandbox.unwrap(); - assert_eq!(sandbox.provider.as_deref(), Some("daytona")); - let daytona = sandbox.daytona.unwrap(); - assert_eq!(daytona.auto_stop_interval, Some(30)); - } - - #[test] - fn apply_defaults_merges_daytona_fields() { - let mut cfg = parse_run_config( - r#" -version = 1 -goal = "test" -graph = "w.fabro" - -[sandbox] -provider = "daytona" - -[sandbox.daytona] -auto_stop_interval = 60 -"#, - ) - .unwrap(); + fn apply_defaults_daytona_deep_merge() { let defaults = RunDefaults { sandbox: Some(SandboxConfig { provider: Some("daytona".into()), @@ -1418,516 +440,105 @@ auto_stop_interval = 60 daytona: Some(DaytonaConfig { auto_stop_interval: Some(30), labels: Some(HashMap::from([("env".into(), "prod".into())])), - ..DaytonaConfig::default() - }), - #[cfg(feature = "exedev")] - exe: None, - ssh: None, - env: None, - }), - ..RunDefaults::default() - }; - cfg.apply_defaults(&defaults); - let daytona = cfg.sandbox.unwrap().daytona.unwrap(); - assert_eq!(daytona.auto_stop_interval, Some(60)); - assert_eq!(daytona.labels.as_ref().unwrap()["env"], "prod"); - } - - #[test] - fn apply_defaults_merges_daytona_labels() { - let mut cfg = parse_run_config( - r#" -version = 1 -goal = "test" -graph = "w.fabro" - -[sandbox.daytona.labels] -project = "fabro" -env = "from_task" -"#, - ) - .unwrap(); - let defaults = RunDefaults { - sandbox: Some(SandboxConfig { - provider: None, - preserve: None, - devcontainer: None, - local: None, - daytona: Some(DaytonaConfig { - labels: Some(HashMap::from([ - ("env".into(), "from_default".into()), - ("team".into(), "platform".into()), - ])), - ..DaytonaConfig::default() - }), - #[cfg(feature = "exedev")] - exe: None, - ssh: None, - env: None, - }), - ..RunDefaults::default() - }; - cfg.apply_defaults(&defaults); - let labels = cfg.sandbox.unwrap().daytona.unwrap().labels.unwrap(); - assert_eq!(labels["project"], "fabro"); - assert_eq!(labels["team"], "platform"); - assert_eq!(labels["env"], "from_task"); - } - - #[test] - fn apply_defaults_daytona_snapshot_whole_struct() { - let mut cfg = parse_run_config( - r#" -version = 1 -goal = "test" -graph = "w.fabro" - -[sandbox.daytona.snapshot] -name = "task-snap" -cpu = 2 -"#, - ) - .unwrap(); - let defaults = RunDefaults { - sandbox: Some(SandboxConfig { - provider: None, - preserve: None, - devcontainer: None, - local: None, - daytona: Some(DaytonaConfig { snapshot: Some(DaytonaSnapshotConfig { - name: "default-snap".into(), - cpu: Some(8), - memory: Some(16), - disk: Some(100), - dockerfile: Some(DockerfileSource::Inline("FROM ubuntu".into())), - }), - ..DaytonaConfig::default() - }), - #[cfg(feature = "exedev")] - exe: None, - ssh: None, - env: None, - }), - ..RunDefaults::default() - }; - cfg.apply_defaults(&defaults); - let snapshot = cfg.sandbox.unwrap().daytona.unwrap().snapshot.unwrap(); - assert_eq!(snapshot.name, "task-snap"); - assert_eq!(snapshot.cpu, Some(2)); - assert!(snapshot.memory.is_none()); - } - - #[test] - fn apply_defaults_daytona_snapshot_from_default() { - let mut cfg = parse_run_config( - r#" -version = 1 -goal = "test" -graph = "w.fabro" - -[sandbox.daytona] -auto_stop_interval = 60 -"#, - ) - .unwrap(); - let defaults = RunDefaults { - sandbox: Some(SandboxConfig { - provider: None, - preserve: None, - devcontainer: None, - local: None, - daytona: Some(DaytonaConfig { - snapshot: Some(DaytonaSnapshotConfig { - name: "default-snap".into(), - cpu: Some(4), - memory: Some(8), + name: "base".into(), + cpu: Some(2), + memory: None, disk: None, dockerfile: None, }), - ..DaytonaConfig::default() + network: None, }), #[cfg(feature = "exedev")] exe: None, ssh: None, env: None, }), - ..RunDefaults::default() + ..Default::default() }; - cfg.apply_defaults(&defaults); - let snapshot = cfg.sandbox.unwrap().daytona.unwrap().snapshot.unwrap(); - assert_eq!(snapshot.name, "default-snap"); - assert_eq!(snapshot.cpu, Some(4)); - assert_eq!(snapshot.memory, Some(8)); - } - - #[test] - fn parse_toml_with_fallbacks() { let toml = r#" version = 1 -goal = "Run tests" graph = "workflow.fabro" -[llm] -model = "claude-opus-4-6" -provider = "anthropic" - -[llm.fallbacks] -anthropic = ["gemini", "openai"] -gemini = ["anthropic", "openai"] -"#; - let config = parse_run_config(toml).unwrap(); - let llm = config.llm.unwrap(); - let fallbacks = llm.fallbacks.unwrap(); - assert_eq!(fallbacks["anthropic"], vec!["gemini", "openai"]); - assert_eq!(fallbacks["gemini"], vec!["anthropic", "openai"]); - } - - #[test] - fn parse_toml_without_fallbacks() { - let toml = r#" -version = 1 -goal = "Run tests" -graph = "workflow.fabro" - -[llm] -model = "claude-opus-4-6" -provider = "anthropic" -"#; - let config = parse_run_config(toml).unwrap(); - let llm = config.llm.unwrap(); - assert!(llm.fallbacks.is_none()); - } - - #[test] - fn apply_defaults_fallbacks_task_wins() { - let mut cfg = parse_run_config( - r#" -version = 1 -goal = "test" -graph = "w.fabro" - -[llm] -model = "opus" - -[llm.fallbacks] -anthropic = ["gemini"] -"#, - ) - .unwrap(); - let defaults = RunDefaults { - llm: Some(LlmConfig { - model: None, - provider: Some("anthropic".into()), - fallbacks: Some(HashMap::from([("anthropic".into(), vec!["openai".into()])])), - }), - ..RunDefaults::default() - }; - cfg.apply_defaults(&defaults); - let llm = cfg.llm.unwrap(); - assert_eq!(llm.fallbacks.unwrap()["anthropic"], vec!["gemini"]); - } - - #[test] - fn apply_defaults_fallbacks_inherited() { - let mut cfg = parse_run_config( - r#" -version = 1 -goal = "test" -graph = "w.fabro" - -[llm] -model = "opus" -"#, - ) - .unwrap(); - let defaults = RunDefaults { - llm: Some(LlmConfig { - model: None, - provider: Some("anthropic".into()), - fallbacks: Some(HashMap::from([("anthropic".into(), vec!["openai".into()])])), - }), - ..RunDefaults::default() - }; - cfg.apply_defaults(&defaults); - let llm = cfg.llm.unwrap(); - assert_eq!(llm.fallbacks.unwrap()["anthropic"], vec!["openai"]); - } - - #[tokio::test] - async fn run_setup_succeeds() { - let dir = tempfile::tempdir().unwrap(); - let setup = SetupConfig { - commands: vec!["echo hello".to_string()], - timeout_ms: None, - }; - run_setup(&setup, dir.path()).await.unwrap(); - } - - #[tokio::test] - async fn run_setup_fails_on_nonzero_exit() { - let dir = tempfile::tempdir().unwrap(); - let setup = SetupConfig { - commands: vec!["false".to_string()], - timeout_ms: None, - }; - let err = run_setup(&setup, dir.path()).await.unwrap_err(); - assert!( - err.to_string().contains("exit code"), - "unexpected error: {err}" - ); - } - - #[tokio::test] - async fn run_setup_timeout() { - let dir = tempfile::tempdir().unwrap(); - let setup = SetupConfig { - commands: vec!["sleep 10".to_string()], - timeout_ms: Some(100), - }; - let err = run_setup(&setup, dir.path()).await.unwrap_err(); - assert!( - err.to_string().contains("timed out"), - "unexpected error: {err}" - ); - } - - #[test] - fn parse_toml_with_hooks() { - let toml = r#" -version = 1 -goal = "Test hooks" -graph = "test.fabro" - -[[hooks]] -event = "stage_start" -command = "./scripts/pre-check.sh" -blocking = true -sandbox = false - -[[hooks]] -event = "run_complete" -command = "echo done" -"#; - let cfg: WorkflowRunConfig = toml::from_str(toml).unwrap(); - assert_eq!(cfg.hooks.len(), 2); - assert_eq!(cfg.hooks[0].event, fabro_hooks::HookEvent::StageStart); - assert_eq!( - cfg.hooks[0].command.as_deref(), - Some("./scripts/pre-check.sh") - ); - assert_eq!(cfg.hooks[0].blocking, Some(true)); - assert_eq!(cfg.hooks[0].sandbox, Some(false)); - assert_eq!(cfg.hooks[1].event, fabro_hooks::HookEvent::RunComplete); - } - - #[test] - fn parse_toml_without_hooks_defaults_empty() { - let toml = r#" -version = 1 -goal = "No hooks" -graph = "test.fabro" -"#; - let cfg: WorkflowRunConfig = toml::from_str(toml).unwrap(); - assert!(cfg.hooks.is_empty()); - } - - #[test] - fn parse_toml_with_daytona_network_block() { - let toml = r#" -version = 1 -goal = "test" -graph = "w.fabro" - -[sandbox.daytona] -network = "block" -"#; - let config = parse_run_config(toml).unwrap(); - let daytona = config.sandbox.unwrap().daytona.unwrap(); - assert_eq!(daytona.network, Some(fabro_daytona::DaytonaNetwork::Block)); - } - - #[test] - fn apply_defaults_network_task_wins() { - let mut cfg = parse_run_config( - r#" -version = 1 -goal = "test" -graph = "w.fabro" - -[sandbox.daytona] -network = "block" -"#, - ) - .unwrap(); - let defaults = RunDefaults { - sandbox: Some(SandboxConfig { - provider: None, - preserve: None, - devcontainer: None, - local: None, - daytona: Some(DaytonaConfig { - network: Some(fabro_daytona::DaytonaNetwork::AllowAll), - ..DaytonaConfig::default() - }), - #[cfg(feature = "exedev")] - exe: None, - ssh: None, - env: None, - }), - ..RunDefaults::default() - }; - cfg.apply_defaults(&defaults); - assert_eq!( - cfg.sandbox.unwrap().daytona.unwrap().network, - Some(fabro_daytona::DaytonaNetwork::Block) - ); - } - - #[test] - fn apply_defaults_network_inherited() { - let mut cfg = parse_run_config( - r#" -version = 1 -goal = "test" -graph = "w.fabro" - [sandbox.daytona] auto_stop_interval = 60 -"#, - ) - .unwrap(); - let defaults = RunDefaults { - sandbox: Some(SandboxConfig { + +[sandbox.daytona.labels] +team = "a" +"#; + let mut config = parse_run_config(toml).unwrap(); + config.apply_defaults(&defaults); + let d = config.sandbox.unwrap().daytona.unwrap(); + assert_eq!(d.auto_stop_interval, Some(60)); + let labels = d.labels.unwrap(); + assert_eq!(labels["env"], "prod"); + assert_eq!(labels["team"], "a"); + assert_eq!(d.snapshot.unwrap().name, "base"); + } + + #[test] + fn merge_overlay_basic() { + let mut base = RunDefaults { + work_dir: Some("/base".into()), + llm: Some(LlmConfig { + model: Some("base-m".into()), provider: None, - preserve: None, - devcontainer: None, - local: None, - daytona: Some(DaytonaConfig { - network: Some(fabro_daytona::DaytonaNetwork::AllowList(vec![ - "10.0.0.0/8".into() - ])), - ..DaytonaConfig::default() - }), - #[cfg(feature = "exedev")] - exe: None, - ssh: None, - env: None, + fallbacks: None, }), - ..RunDefaults::default() + ..Default::default() }; - cfg.apply_defaults(&defaults); + let overlay = RunDefaults { + llm: Some(LlmConfig { + model: None, + provider: Some("overlay-p".into()), + fallbacks: None, + }), + ..Default::default() + }; + base.merge_overlay(overlay); + assert_eq!(base.work_dir.as_deref(), Some("/base")); + assert_eq!(base.llm.as_ref().unwrap().model.as_deref(), Some("base-m")); assert_eq!( - cfg.sandbox.unwrap().daytona.unwrap().network, - Some(fabro_daytona::DaytonaNetwork::AllowList(vec![ - "10.0.0.0/8".into(), - ])) + base.llm.as_ref().unwrap().provider.as_deref(), + Some("overlay-p") ); } #[test] - fn parse_toml_with_checkpoint_exclude_globs() { + fn resolve_graph_path_relative() { + let toml_path = std::path::Path::new("/home/user/workflows/wf/workflow.toml"); + let dot = resolve_graph_path(toml_path, "workflow.fabro"); + assert_eq!( + dot, + std::path::PathBuf::from("/home/user/workflows/wf/workflow.fabro") + ); + } + + #[test] + fn resolve_graph_path_absolute() { + let toml_path = std::path::Path::new("/home/user/workflows/wf/workflow.toml"); + let dot = resolve_graph_path(toml_path, "/absolute/path.fabro"); + assert_eq!(dot, std::path::PathBuf::from("/absolute/path.fabro")); + } + + #[test] + fn parse_toml_with_mcp_servers() { let toml = r#" version = 1 goal = "test" -graph = "w.fabro" +graph = "workflow.fabro" -[checkpoint] -exclude_globs = ["**/node_modules/**", "**/.cache/**"] +[mcp_servers.filesystem] +type = "stdio" +command = ["npx", "-y", "@modelcontextprotocol/server-filesystem"] +startup_timeout_secs = 20 +tool_timeout_secs = 120 "#; let config = parse_run_config(toml).unwrap(); - assert_eq!( - config.checkpoint.exclude_globs, - vec!["**/node_modules/**", "**/.cache/**"] - ); - } - - #[test] - fn parse_toml_without_checkpoint_defaults_empty() { - let toml = r#" -version = 1 -goal = "test" -graph = "w.fabro" -"#; - let config = parse_run_config(toml).unwrap(); - assert!(config.checkpoint.exclude_globs.is_empty()); - } - - #[test] - fn apply_defaults_unions_checkpoint_exclude_globs() { - let mut cfg = parse_run_config( - r#" -version = 1 -goal = "test" -graph = "w.fabro" - -[checkpoint] -exclude_globs = ["**/dist/**", "**/.cache/**"] -"#, - ) - .unwrap(); - let defaults = RunDefaults { - checkpoint: CheckpointConfig { - exclude_globs: vec!["**/.cache/**".into(), "**/node_modules/**".into()], - }, - ..RunDefaults::default() - }; - cfg.apply_defaults(&defaults); - assert_eq!( - cfg.checkpoint.exclude_globs, - vec!["**/.cache/**", "**/dist/**", "**/node_modules/**"] - ); - } - - #[test] - fn apply_defaults_checkpoint_from_defaults_only() { - let mut cfg = parse_run_config( - r#" -version = 1 -goal = "test" -graph = "w.fabro" -"#, - ) - .unwrap(); - let defaults = RunDefaults { - checkpoint: CheckpointConfig { - exclude_globs: vec!["**/node_modules/**".into()], - }, - ..RunDefaults::default() - }; - cfg.apply_defaults(&defaults); - assert_eq!(cfg.checkpoint.exclude_globs, vec!["**/node_modules/**"]); - } - - #[test] - fn apply_defaults_checkpoint_from_task_only() { - let mut cfg = parse_run_config( - r#" -version = 1 -goal = "test" -graph = "w.fabro" - -[checkpoint] -exclude_globs = ["**/dist/**"] -"#, - ) - .unwrap(); - let defaults = RunDefaults::default(); - cfg.apply_defaults(&defaults); - assert_eq!(cfg.checkpoint.exclude_globs, vec!["**/dist/**"]); - } - - #[test] - fn parse_run_defaults_with_checkpoint() { - let toml = r#" -[checkpoint] -exclude_globs = ["**/node_modules/**"] -"#; - let defaults: RunDefaults = toml::from_str(toml).unwrap(); - assert_eq!( - defaults.checkpoint.exclude_globs, - vec!["**/node_modules/**"] - ); + assert_eq!(config.mcp_servers.len(), 1); + let entry = &config.mcp_servers["filesystem"]; + assert_eq!(entry.startup_timeout_secs, 20); + assert_eq!(entry.tool_timeout_secs, 120); } #[test] @@ -1935,631 +546,69 @@ exclude_globs = ["**/node_modules/**"] let toml = r#" version = 1 goal = "test" -graph = "w.fabro" +graph = "workflow.fabro" [sandbox.env] -FOO = "bar" -BAZ = "${env.HOME}" +MY_VAR = "hello" "#; let config = parse_run_config(toml).unwrap(); let env = config.sandbox.unwrap().env.unwrap(); - assert_eq!(env["FOO"], "bar"); - // Not yet resolved (parse_run_config doesn't resolve env refs) - assert_eq!(env["BAZ"], "${env.HOME}"); + assert_eq!(env["MY_VAR"], "hello"); } #[test] - fn parse_toml_without_sandbox_env() { + fn parse_run_config_with_hooks() { let toml = r#" version = 1 goal = "test" -graph = "w.fabro" - -[sandbox] -provider = "daytona" -"#; - let config = parse_run_config(toml).unwrap(); - assert!(config.sandbox.unwrap().env.is_none()); - } - - #[test] - fn resolve_env_refs_literal_passthrough() { - let mut env = HashMap::from([("FOO".into(), "bar".into())]); - resolve_env_refs(&mut env).unwrap(); - assert_eq!(env["FOO"], "bar"); - } - - #[test] - fn resolve_env_refs_host_var() { - std::env::set_var("FABRO_TEST_RESOLVE_VAR", "secret123"); - let mut env = HashMap::from([("MY_KEY".into(), "${env.FABRO_TEST_RESOLVE_VAR}".into())]); - resolve_env_refs(&mut env).unwrap(); - assert_eq!(env["MY_KEY"], "secret123"); - std::env::remove_var("FABRO_TEST_RESOLVE_VAR"); - } - - #[test] - fn resolve_env_refs_missing_var_errors() { - let mut env = HashMap::from([( - "MY_KEY".into(), - "${env.FABRO_TEST_NONEXISTENT_VAR_12345}".into(), - )]); - let err = resolve_env_refs(&mut env).unwrap_err(); - assert!( - err.to_string().contains("FABRO_TEST_NONEXISTENT_VAR_12345"), - "unexpected error: {err}" - ); - } - - #[test] - fn resolve_env_refs_partial_not_interpolated() { - let mut env = HashMap::from([("MIXED".into(), "prefix_${env.HOME}_suffix".into())]); - // Partial interpolation is not supported — value is left as-is - resolve_env_refs(&mut env).unwrap(); - assert_eq!(env["MIXED"], "prefix_${env.HOME}_suffix"); - } - - #[test] - fn apply_defaults_merges_sandbox_env() { - let mut cfg = parse_run_config( - r#" -version = 1 -goal = "test" -graph = "w.fabro" - -[sandbox.env] -TASK_KEY = "task_val" -SHARED = "from_task" -"#, - ) - .unwrap(); - let defaults = RunDefaults { - sandbox: Some(SandboxConfig { - provider: None, - preserve: None, - devcontainer: None, - local: None, - daytona: None, - #[cfg(feature = "exedev")] - exe: None, - ssh: None, - env: Some(HashMap::from([ - ("DEFAULT_KEY".into(), "default_val".into()), - ("SHARED".into(), "from_default".into()), - ])), - }), - ..RunDefaults::default() - }; - cfg.apply_defaults(&defaults); - let env = cfg.sandbox.unwrap().env.unwrap(); - assert_eq!(env["DEFAULT_KEY"], "default_val"); - assert_eq!(env["TASK_KEY"], "task_val"); - assert_eq!(env["SHARED"], "from_task"); - } - - #[test] - fn apply_defaults_sandbox_env_from_default_only() { - let mut cfg = parse_run_config( - r#" -version = 1 -goal = "test" -graph = "w.fabro" - -[sandbox] -provider = "daytona" -"#, - ) - .unwrap(); - let defaults = RunDefaults { - sandbox: Some(SandboxConfig { - provider: None, - preserve: None, - devcontainer: None, - local: None, - daytona: None, - #[cfg(feature = "exedev")] - exe: None, - ssh: None, - env: Some(HashMap::from([("KEY".into(), "val".into())])), - }), - ..RunDefaults::default() - }; - cfg.apply_defaults(&defaults); - let env = cfg.sandbox.unwrap().env.unwrap(); - assert_eq!(env["KEY"], "val"); - } - - #[test] - fn load_run_config_resolves_env_refs() { - std::env::set_var("FABRO_TEST_LOAD_ENV", "resolved_value"); - let dir = tempfile::tempdir().unwrap(); - let toml_path = dir.path().join("run.toml"); - std::fs::write( - &toml_path, - r#" -version = 1 -goal = "test" -graph = "w.fabro" - -[sandbox.env] -LITERAL = "hello" -FROM_HOST = "${env.FABRO_TEST_LOAD_ENV}" -"#, - ) - .unwrap(); - - let config = load_run_config(&toml_path).unwrap(); - let env = config.sandbox.unwrap().env.unwrap(); - assert_eq!(env["LITERAL"], "hello"); - assert_eq!(env["FROM_HOST"], "resolved_value"); - std::env::remove_var("FABRO_TEST_LOAD_ENV"); - } - - #[test] - fn load_run_config_missing_env_var_errors() { - let dir = tempfile::tempdir().unwrap(); - let toml_path = dir.path().join("run.toml"); - std::fs::write( - &toml_path, - r#" -version = 1 -goal = "test" -graph = "w.fabro" - -[sandbox.env] -MISSING = "${env.FABRO_TEST_DEFINITELY_NOT_SET_67890}" -"#, - ) - .unwrap(); - - let err = load_run_config(&toml_path).unwrap_err(); - assert!( - err.to_string() - .contains("FABRO_TEST_DEFINITELY_NOT_SET_67890"), - "unexpected error: {err}" - ); - } - - #[test] - fn parse_toml_with_pull_request() { - let toml = r#" -version = 1 -goal = "test" -graph = "w.fabro" - -[pull_request] -enabled = true -"#; - let config = parse_run_config(toml).unwrap(); - let pr = config.pull_request.unwrap(); - assert!(pr.enabled); - } - - #[test] - fn parse_toml_without_pull_request_defaults_none() { - let toml = r#" -version = 1 -goal = "test" -graph = "w.fabro" -"#; - let config = parse_run_config(toml).unwrap(); - assert!(config.pull_request.is_none()); - } - - #[test] - fn apply_defaults_pull_request_task_wins() { - let mut cfg = parse_run_config( - r#" -version = 1 -goal = "test" -graph = "w.fabro" - -[pull_request] -enabled = true -"#, - ) - .unwrap(); - let defaults = RunDefaults { - pull_request: Some(PullRequestConfig { - enabled: false, - draft: false, - auto_merge: false, - merge_strategy: MergeStrategy::Squash, - }), - ..RunDefaults::default() - }; - cfg.apply_defaults(&defaults); - assert!(cfg.pull_request.unwrap().enabled); - } - - #[test] - fn apply_defaults_pull_request_inherited() { - let mut cfg = parse_run_config( - r#" -version = 1 -goal = "test" -graph = "w.fabro" -"#, - ) - .unwrap(); - let defaults = RunDefaults { - pull_request: Some(PullRequestConfig { - enabled: true, - draft: false, - auto_merge: false, - merge_strategy: MergeStrategy::Squash, - }), - ..RunDefaults::default() - }; - cfg.apply_defaults(&defaults); - assert!(cfg.pull_request.unwrap().enabled); - } - - #[test] - fn parse_run_defaults_with_pull_request() { - let toml = r#" -[pull_request] -enabled = true -"#; - let defaults: RunDefaults = toml::from_str(toml).unwrap(); - assert!(defaults.pull_request.unwrap().enabled); - } - - #[test] - fn parse_toml_with_assets() { - let toml = r#" -version = 1 -goal = "Run tests" graph = "workflow.fabro" -[assets] -include = ["test-results/**", "playwright-report/**", "*.trace.zip"] +[[hooks]] +event = "run_start" +command = "echo starting" "#; let config = parse_run_config(toml).unwrap(); - let assets = config.assets.unwrap(); - assert_eq!( - assets.include, - vec!["test-results/**", "playwright-report/**", "*.trace.zip"] - ); + assert_eq!(config.hooks.len(), 1); } #[test] - fn parse_toml_without_assets() { + fn parse_run_config_with_github() { let toml = r#" version = 1 +goal = "test" graph = "workflow.fabro" -"#; - let config = parse_run_config(toml).unwrap(); - assert!(config.assets.is_none()); - } - - #[test] - fn apply_defaults_inherits_assets() { - let mut cfg = parse_run_config( - r#" -version = 1 -goal = "test" -graph = "w.fabro" -"#, - ) - .unwrap(); - let defaults = RunDefaults { - assets: Some(AssetsConfig { - include: vec!["test-results/**".into()], - }), - ..RunDefaults::default() - }; - cfg.apply_defaults(&defaults); - let assets = cfg.assets.unwrap(); - assert_eq!(assets.include, vec!["test-results/**"]); - } - - #[test] - fn apply_defaults_task_assets_wins() { - let mut cfg = parse_run_config( - r#" -version = 1 -goal = "test" -graph = "w.fabro" - -[assets] -include = ["playwright-report/**"] -"#, - ) - .unwrap(); - let defaults = RunDefaults { - assets: Some(AssetsConfig { - include: vec!["test-results/**".into()], - }), - ..RunDefaults::default() - }; - cfg.apply_defaults(&defaults); - let assets = cfg.assets.unwrap(); - assert_eq!(assets.include, vec!["playwright-report/**"]); - } - - #[test] - fn parse_toml_with_pull_request_draft() { - let toml = r#" -version = 1 -goal = "test" -graph = "w.fabro" - -[pull_request] -enabled = true -draft = true -"#; - let config = parse_run_config(toml).unwrap(); - let pr = config.pull_request.unwrap(); - assert!(pr.enabled); - assert!(pr.draft); - } - - #[test] - fn parse_toml_pull_request_draft_defaults_true() { - let toml = r#" -version = 1 -goal = "test" -graph = "w.fabro" - -[pull_request] -enabled = true -"#; - let config = parse_run_config(toml).unwrap(); - let pr = config.pull_request.unwrap(); - assert!(pr.enabled); - assert!(pr.draft); - } - - #[test] - fn apply_defaults_merges_mcp_servers() { - let mut cfg = parse_run_config( - r#" -version = 1 -goal = "test" -graph = "w.fabro" - -[mcp_servers.workflow_server] -type = "stdio" -command = ["npx", "wf-server"] -"#, - ) - .unwrap(); - let defaults = RunDefaults { - mcp_servers: HashMap::from([( - "default_server".into(), - McpServerEntry { - transport: McpTransport::Stdio { - command: vec!["npx".into(), "default-server".into()], - env: HashMap::new(), - }, - startup_timeout_secs: 10, - tool_timeout_secs: 60, - }, - )]), - ..RunDefaults::default() - }; - cfg.apply_defaults(&defaults); - assert_eq!(cfg.mcp_servers.len(), 2); - assert!(cfg.mcp_servers.contains_key("default_server")); - assert!(cfg.mcp_servers.contains_key("workflow_server")); - } - - #[test] - fn apply_defaults_mcp_servers_workflow_wins_on_collision() { - let mut cfg = parse_run_config( - r#" -version = 1 -goal = "test" -graph = "w.fabro" - -[mcp_servers.shared] -type = "stdio" -command = ["npx", "from-workflow"] -"#, - ) - .unwrap(); - let defaults = RunDefaults { - mcp_servers: HashMap::from([( - "shared".into(), - McpServerEntry { - transport: McpTransport::Stdio { - command: vec!["npx".into(), "from-default".into()], - env: HashMap::new(), - }, - startup_timeout_secs: 10, - tool_timeout_secs: 60, - }, - )]), - ..RunDefaults::default() - }; - cfg.apply_defaults(&defaults); - assert_eq!(cfg.mcp_servers.len(), 1); - match &cfg.mcp_servers["shared"].transport { - McpTransport::Stdio { command, .. } => { - assert_eq!(command, &["npx", "from-workflow"]); - } - _ => panic!("expected Stdio transport"), - } - } - - #[test] - fn apply_defaults_merges_hooks() { - let mut cfg = parse_run_config( - r#" -version = 1 -goal = "test" -graph = "w.fabro" - -[[hooks]] -name = "wf-hook" -event = "run_complete" -command = "echo done" -"#, - ) - .unwrap(); - let defaults = RunDefaults { - hooks: vec![fabro_hooks::HookDefinition { - name: Some("default-hook".into()), - event: fabro_hooks::HookEvent::RunStart, - command: Some("echo start".into()), - hook_type: None, - matcher: None, - blocking: None, - timeout_ms: None, - sandbox: None, - }], - ..RunDefaults::default() - }; - cfg.apply_defaults(&defaults); - assert_eq!(cfg.hooks.len(), 2); - assert_eq!(cfg.hooks[0].name.as_deref(), Some("default-hook")); - assert_eq!(cfg.hooks[1].name.as_deref(), Some("wf-hook")); - } - - #[test] - fn apply_defaults_hooks_workflow_wins_on_collision() { - let mut cfg = parse_run_config( - r#" -version = 1 -goal = "test" -graph = "w.fabro" - -[[hooks]] -name = "shared" -event = "run_complete" -command = "echo from-workflow" -"#, - ) - .unwrap(); - let defaults = RunDefaults { - hooks: vec![fabro_hooks::HookDefinition { - name: Some("shared".into()), - event: fabro_hooks::HookEvent::RunStart, - command: Some("echo from-default".into()), - hook_type: None, - matcher: None, - blocking: None, - timeout_ms: None, - sandbox: None, - }], - ..RunDefaults::default() - }; - cfg.apply_defaults(&defaults); - assert_eq!(cfg.hooks.len(), 1); - assert_eq!(cfg.hooks[0].event, fabro_hooks::HookEvent::RunComplete); - } - - #[test] - fn graph_defaults_to_workflow_fabro() { - let toml = "version = 1\n"; - let config = parse_run_config(toml).unwrap(); - assert_eq!(config.graph, "workflow.fabro"); - } - - #[test] - fn parse_toml_with_github_permissions() { - let toml = r#" -version = 1 -goal = "test" -graph = "w.fabro" [github] -permissions = { contents = "write", pull_requests = "read" } +permissions = { contents = "read" } "#; let config = parse_run_config(toml).unwrap(); let github = config.github.unwrap(); - assert_eq!(github.permissions["contents"], "write"); - assert_eq!(github.permissions["pull_requests"], "read"); - } - - #[test] - fn parse_toml_without_github_defaults_none() { - let toml = r#" -version = 1 -goal = "test" -graph = "w.fabro" -"#; - let config = parse_run_config(toml).unwrap(); - assert!(config.github.is_none()); - } - - #[test] - fn apply_defaults_github_inherited() { - let mut cfg = parse_run_config( - r#" -version = 1 -goal = "test" -graph = "w.fabro" -"#, - ) - .unwrap(); - let defaults = RunDefaults { - github: Some(GitHubConfig { - permissions: HashMap::from([("contents".into(), "read".into())]), - }), - ..RunDefaults::default() - }; - cfg.apply_defaults(&defaults); - let github = cfg.github.unwrap(); assert_eq!(github.permissions["contents"], "read"); } #[test] - fn apply_defaults_github_task_wins() { - let mut cfg = parse_run_config( - r#" + fn parse_toml_worktree_modes() { + for (mode, expected) in [ + ("always", WorktreeMode::Always), + ("clean", WorktreeMode::Clean), + ("dirty", WorktreeMode::Dirty), + ("never", WorktreeMode::Never), + ] { + let toml = format!( + r#" version = 1 -goal = "test" -graph = "w.fabro" +graph = "workflow.fabro" -[github] -permissions = { contents = "write" } -"#, - ) - .unwrap(); - let defaults = RunDefaults { - github: Some(GitHubConfig { - permissions: HashMap::from([("contents".into(), "read".into())]), - }), - ..RunDefaults::default() - }; - cfg.apply_defaults(&defaults); - let github = cfg.github.unwrap(); - assert_eq!(github.permissions["contents"], "write"); - } - - #[test] - fn merge_overlay_github_replaces() { - let mut base = RunDefaults { - github: Some(GitHubConfig { - permissions: HashMap::from([("contents".into(), "read".into())]), - }), - ..RunDefaults::default() - }; - let overlay = RunDefaults { - github: Some(GitHubConfig { - permissions: HashMap::from([("issues".into(), "write".into())]), - }), - ..RunDefaults::default() - }; - base.merge_overlay(overlay); - let github = base.github.unwrap(); - assert!(!github.permissions.contains_key("contents")); - assert_eq!(github.permissions["issues"], "write"); - } - - #[test] - fn merge_overlay_github_none_keeps_base() { - let mut base = RunDefaults { - github: Some(GitHubConfig { - permissions: HashMap::from([("contents".into(), "read".into())]), - }), - ..RunDefaults::default() - }; - let overlay = RunDefaults::default(); - base.merge_overlay(overlay); - let github = base.github.unwrap(); - assert_eq!(github.permissions["contents"], "read"); +[sandbox.local] +worktree_mode = "{mode}" +"# + ); + let config = parse_run_config(&toml).unwrap(); + assert_eq!( + config.sandbox.unwrap().local.unwrap().worktree_mode, + expected, + "failed for mode: {mode}" + ); + } } }