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) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-17 15:17:55 -04:00
parent 9e079ed2c5
commit 98e8a9b7f6
26 changed files with 1941 additions and 3605 deletions

14
Cargo.lock generated
View file

@ -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",

View file

@ -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" }

View file

@ -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.

View file

@ -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

View file

@ -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<CliConfig> {
#[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]

View file

@ -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<std::collections::HashMap<String, String>>,
#[serde(default)]
allowed_env_vars: Vec<String>,
#[serde(default)]
tls: TlsMode,
},
Prompt {
prompt: String,
model: Option<String>,
},
Agent {
prompt: String,
model: Option<String>,
max_tool_rounds: Option<u32>,
},
}
/// A single hook definition.
#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)]
pub struct HookDefinition {
pub name: Option<String>,
pub event: HookEvent,
/// Inline command shorthand — if set, implies `type = "command"`.
#[serde(default)]
pub command: Option<String>,
/// Explicit hook type (command or http). If omitted and `command` is set,
/// defaults to `Command`.
#[serde(flatten)]
pub hook_type: Option<HookType>,
/// Regex matched against node_id, handler_type, or event-specific fields.
pub matcher: Option<String>,
/// Override the event's default blocking behavior.
pub blocking: Option<bool>,
/// Timeout in milliseconds (default: 60_000).
pub timeout_ms: Option<u64>,
/// Run inside the sandbox (true, default) or on the host (false).
pub sandbox: Option<bool>,
}
impl HookDefinition {
/// Resolve the effective hook type: explicit `hook_type` wins, then `command`
/// shorthand, then error.
pub fn resolved_hook_type(&self) -> Option<Cow<'_, HookType>> {
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<HookDefinition>,
}
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<String, HookDefinition> =
std::collections::HashMap::new();
let mut order: Vec<String> = 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 }
}
}

View file

@ -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;

View file

@ -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<String>,
#[serde(default)]
env: HashMap<String, String>,
},
Http {
url: String,
#[serde(default)]
headers: HashMap<String, String>,
},
/// 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<String>,
port: u16,
#[serde(default)]
env: HashMap<String, String>,
},
}
/// 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,
}
}
}

View file

@ -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<String>,
pub llm: Option<LlmConfig>,
pub setup: Option<SetupConfig>,
pub sandbox: Option<SandboxConfig>,
pub vars: Option<HashMap<String, String>>,
#[serde(default)]
pub checkpoint: CheckpointConfig,
pub pull_request: Option<PullRequestConfig>,
pub assets: Option<AssetsConfig>,
#[serde(default)]
pub hooks: Vec<HookDefinition>,
#[serde(default)]
pub mcp_servers: HashMap<String, McpServerEntry>,
pub github: Option<GitHubConfig>,
}
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<ProjectConfig> {
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<ProjectConfig> {
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<Option<(PathBuf, ProjectConfig)>> {
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<PathBuf> {
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<PathBuf> {
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<PathBuf> {
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<PathBuf> {
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<PathBuf> {
dirs::home_dir().map(|h| h.join(".fabro").join("workflows"))
}
/// Metadata about a discovered workflow.
pub struct WorkflowInfo {
pub name: String,
pub goal: Option<String>,
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<String> {
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<String> {
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<WorkflowInfo> {
let mut infos: Vec<WorkflowInfo> = Vec::new();
let mut seen: Vec<String> = 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<String> {
let mut names: Vec<String> = 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<String> {
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<crate::run::WorkflowRunConfig>)> {
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<crate::run::WorkflowRunConfig>)> {
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");
}
}

View file

@ -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<String>,
}
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<String>,
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
pub struct GitHubConfig {
#[serde(default)]
pub permissions: HashMap<String, String>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct WorkflowRunConfig {
pub version: u32,
pub goal: Option<String>,
#[serde(default = "default_graph")]
pub graph: String,
#[serde(alias = "directory")]
pub work_dir: Option<String>,
pub llm: Option<LlmConfig>,
pub setup: Option<SetupConfig>,
pub sandbox: Option<SandboxConfig>,
pub vars: Option<HashMap<String, String>>,
#[serde(default)]
pub hooks: Vec<HookDefinition>,
#[serde(default)]
pub checkpoint: CheckpointConfig,
pub pull_request: Option<PullRequestConfig>,
pub assets: Option<AssetsConfig>,
#[serde(default)]
pub mcp_servers: HashMap<String, McpServerEntry>,
pub github: Option<GitHubConfig>,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct LlmConfig {
pub model: Option<String>,
pub provider: Option<String>,
#[serde(default)]
pub fallbacks: Option<HashMap<String, Vec<String>>>,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct SetupConfig {
pub commands: Vec<String>,
pub timeout_ms: Option<u64>,
}
/// 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<String>,
pub llm: Option<LlmConfig>,
pub setup: Option<SetupConfig>,
pub sandbox: Option<SandboxConfig>,
pub vars: Option<HashMap<String, String>>,
#[serde(default)]
pub checkpoint: CheckpointConfig,
pub pull_request: Option<PullRequestConfig>,
pub assets: Option<AssetsConfig>,
#[serde(default)]
pub hooks: Vec<HookDefinition>,
#[serde(default)]
pub mcp_servers: HashMap<String, McpServerEntry>,
pub github: Option<GitHubConfig>,
}
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<WorkflowRunConfig> {
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<String, String>) -> 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<WorkflowRunConfig> {
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)
}

View file

@ -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<i32>,
pub labels: Option<HashMap<String, String>>,
pub snapshot: Option<DaytonaSnapshotConfig>,
pub network: Option<DaytonaNetwork>,
}
/// 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<String>),
}
impl Serialize for DaytonaNetwork {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
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<D>(deserializer: D) -> Result<Self, D::Error>
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<E: de::Error>(self, value: &str) -> Result<DaytonaNetwork, E> {
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<M: MapAccess<'de>>(self, mut map: M) -> Result<DaytonaNetwork, M::Error> {
let Some(key) = map.next_key::<String>()? 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<String> = 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::<String>()? {
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<i32>,
pub memory: Option<i32>,
pub disk: Option<i32>,
pub dockerfile: Option<DockerfileSource>,
}
/// 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<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<String>,
/// 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<String>,
}
#[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<String>,
pub preserve: Option<bool>,
#[serde(default)]
pub devcontainer: Option<bool>,
pub local: Option<LocalSandboxConfig>,
pub daytona: Option<DaytonaConfig>,
#[cfg(feature = "exedev")]
pub exe: Option<ExeConfig>,
pub ssh: Option<SshConfig>,
pub env: Option<HashMap<String, String>>,
}

View file

@ -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(),

View file

@ -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

View file

@ -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<i32>,
pub labels: Option<HashMap<String, String>>,
pub snapshot: Option<DaytonaSnapshotConfig>,
pub network: Option<DaytonaNetwork>,
}
/// 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<String>),
}
impl Serialize for DaytonaNetwork {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
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<D>(deserializer: D) -> Result<Self, D::Error>
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<E: de::Error>(self, value: &str) -> Result<DaytonaNetwork, E> {
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<M: MapAccess<'de>>(self, mut map: M) -> Result<DaytonaNetwork, M::Error> {
let Some(key) = map.next_key::<String>()? 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<String> = 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::<String>()? {
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<i32>,
pub memory: Option<i32>,
pub disk: Option<i32>,
pub dockerfile: Option<DockerfileSource>,
}
pub use fabro_config::sandbox::{
DaytonaConfig, DaytonaNetwork, DaytonaSnapshotConfig, DockerfileSource,
};
/// Sandbox that runs all operations inside a Daytona cloud sandbox.
pub struct DaytonaSandbox {

View file

@ -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

View file

@ -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<Vec<u8>, 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<String>,
}
pub use fabro_config::sandbox::ExeConfig;
/// Parameters for cloning a git repo into the sandbox during initialization.
#[derive(Clone, Debug)]

View file

@ -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

View file

@ -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<std::collections::HashMap<String, String>>,
#[serde(default)]
allowed_env_vars: Vec<String>,
#[serde(default)]
tls: TlsMode,
},
Prompt {
prompt: String,
model: Option<String>,
},
Agent {
prompt: String,
model: Option<String>,
max_tool_rounds: Option<u32>,
},
}
/// A single hook definition.
#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)]
pub struct HookDefinition {
pub name: Option<String>,
pub event: HookEvent,
/// Inline command shorthand — if set, implies `type = "command"`.
#[serde(default)]
pub command: Option<String>,
/// Explicit hook type (command or http). If omitted and `command` is set,
/// defaults to `Command`.
#[serde(flatten)]
pub hook_type: Option<HookType>,
/// Regex matched against node_id, handler_type, or event-specific fields.
pub matcher: Option<String>,
/// Override the event's default blocking behavior.
pub blocking: Option<bool>,
/// Timeout in milliseconds (default: 60_000).
pub timeout_ms: Option<u64>,
/// Run inside the sandbox (true, default) or on the host (false).
pub sandbox: Option<bool>,
}
impl HookDefinition {
/// Resolve the effective hook type: explicit `hook_type` wins, then `command`
/// shorthand, then error.
pub fn resolved_hook_type(&self) -> Option<Cow<'_, HookType>> {
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<HookDefinition>,
}
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<String, HookDefinition> =
std::collections::HashMap::new();
let mut order: Vec<String> = 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 {

View file

@ -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 {

View file

@ -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

View file

@ -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<String>,
#[serde(default)]
env: HashMap<String, String>,
},
Http {
url: String,
#[serde(default)]
headers: HashMap<String, String>,
},
/// 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<String>,
port: u16,
#[serde(default)]
env: HashMap<String, String>,
},
}
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() {

View file

@ -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

View file

@ -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<Vec<u8>, 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<String>,
/// 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<String>,
}
pub use fabro_config::sandbox::SshConfig;
/// Parameters for cloning a git repo into the sandbox during initialization.
#[derive(Clone, Debug)]

View file

@ -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"] }

View file

@ -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<String>,
pub llm: Option<LlmConfig>,
pub setup: Option<SetupConfig>,
pub sandbox: Option<SandboxConfig>,
pub vars: Option<HashMap<String, String>>,
#[serde(default)]
pub checkpoint: CheckpointConfig,
pub pull_request: Option<PullRequestConfig>,
pub assets: Option<AssetsConfig>,
#[serde(default)]
pub hooks: Vec<HookDefinition>,
#[serde(default)]
pub mcp_servers: HashMap<String, McpServerEntry>,
pub github: Option<GitHubConfig>,
}
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<ProjectConfig> {
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<ProjectConfig> {
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<Option<(PathBuf, ProjectConfig)>> {
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<PathBuf> {
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<PathBuf> {
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<PathBuf> {
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<PathBuf> {
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<PathBuf> {
dirs::home_dir().map(|h| h.join(".fabro").join("workflows"))
}
/// Metadata about a discovered workflow.
pub struct WorkflowInfo {
pub name: String,
pub goal: Option<String>,
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<String> {
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<String> {
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<WorkflowInfo> {
let mut infos: Vec<WorkflowInfo> = Vec::new();
let mut seen: Vec<String> = 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<String> {
let mut names: Vec<String> = 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<String> {
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<super::run_config::WorkflowRunConfig>)> {
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<super::run_config::WorkflowRunConfig>)> {
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::*;

File diff suppressed because it is too large Load diff