Refactor config resolution around ConfigLayer

This commit is contained in:
Bryan Helmkamp 2026-03-29 13:46:43 -04:00
parent 392add851a
commit ed651dcd57
No known key found for this signature in database
48 changed files with 238 additions and 193 deletions

View file

@ -37,7 +37,7 @@ CLI flags always override `cli.toml` values, which override hardcoded defaults.
## `fabro config show`
Print the merged `FabroConfig` as YAML.
Print the merged resolved configuration as YAML.
```bash
fabro config show

View file

@ -19,7 +19,7 @@ The naming format is `YYYYMMDD-{run_id}`, where `run_id` is the ULID assigned to
| File | Format | When written | Description |
|---|---|---|---|
| `run.json` | JSON | Run create | Run metadata — `run_id`, `created_at`, `config` (FabroConfig), `graph` (Graph), `workflow_slug`, `working_directory`, `host_repo_path`, `base_branch`, `labels` |
| `run.json` | JSON | Run create | Run metadata — `run_id`, `created_at`, `config` (resolved configuration), `graph` (Graph), `workflow_slug`, `working_directory`, `host_repo_path`, `base_branch`, `labels` |
| `start.json` | JSON | Run start | Start metadata — `run_id`, `start_time`, `run_branch`, `base_sha` |
| `workflow.fabro` | Graphviz | Run create | Copy of the original workflow graph when the raw DOT source is available |
| `run.pid` | Text | Legacy only | Legacy process ID file from older runs. Current detached launches use launcher records instead, and current attach/resume no longer read `run.pid`. |

View file

@ -1,16 +1,14 @@
#[allow(unused_imports)]
pub(crate) use fabro_config::cli::*;
use std::path::Path;
use fabro_config::ConfigLayer;
use fabro_config::FabroSettings;
use fabro_config::cli::load_cli_config;
#[cfg(feature = "server")]
use tracing::debug;
pub(crate) fn load_cli_settings(path: Option<&Path>) -> anyhow::Result<FabroSettings> {
load_cli_config(path)?.try_into()
pub(crate) fn load_cli_settings() -> anyhow::Result<FabroSettings> {
ConfigLayer::cli()?.resolve()
}
#[cfg(feature = "server")]

View file

@ -11,7 +11,7 @@ use crate::cli_config::load_cli_settings;
use crate::shared::split_run_path;
pub(super) fn cp_command(args: &AssetCpArgs) -> Result<()> {
let cli_settings = load_cli_settings(None)?;
let cli_settings = load_cli_settings()?;
let base = runs_base(&cli_settings.storage_dir());
let (run_id, asset_path) = parse_source(&args.source);
let run = resolve_run(&base, run_id)?;

View file

@ -9,7 +9,7 @@ use crate::cli_config::load_cli_settings;
use crate::shared::format_size;
pub(super) fn list_command(args: &AssetListArgs) -> Result<()> {
let cli_settings = load_cli_settings(None)?;
let cli_settings = load_cli_settings()?;
let base = runs_base(&cli_settings.storage_dir());
let run = resolve_run(&base, &args.run_id)?;
let runtime_state = RuntimeState::new(&run.path);

View file

@ -2,9 +2,7 @@ use std::io::Write;
use std::path::Path;
use crate::args::{ConfigCommand, ConfigNamespace, ConfigShowArgs};
use fabro_config::cli::load_cli_config;
use fabro_config::project::{ResolveSettingsInput, discover_project_config, resolve_settings};
use fabro_config::{FabroConfig, FabroSettings};
use fabro_config::{ConfigLayer, FabroSettings};
pub(crate) fn dispatch(ns: ConfigNamespace) -> anyhow::Result<()> {
match ns.command {
@ -13,24 +11,13 @@ pub(crate) fn dispatch(ns: ConfigNamespace) -> anyhow::Result<()> {
}
fn merged_config(workflow: Option<&Path>) -> anyhow::Result<FabroSettings> {
if let Some(workflow) = workflow {
let cli_config = load_cli_config(None)?;
let cwd = std::env::current_dir()?;
return resolve_settings(ResolveSettingsInput {
workflow_path: workflow.to_path_buf(),
cwd,
defaults: cli_config,
overrides: FabroConfig::default(),
apply_project_config: true,
});
}
let cwd = std::env::current_dir()?;
let project_config = discover_project_config(&cwd)?
.map(|(_, config)| config)
.unwrap_or_default();
let cli_config = load_cli_config(None)?;
FabroConfig::combine(project_config, cli_config).try_into()
let base = match workflow {
Some(path) => ConfigLayer::for_workflow(path, &cwd)?,
None => ConfigLayer::project(&cwd)?,
};
base.combine(ConfigLayer::cli()?).resolve()
}
pub(crate) fn show_command(args: &ConfigShowArgs) -> anyhow::Result<()> {

View file

@ -938,7 +938,7 @@ pub(crate) async fn run_doctor(verbose: bool, live: bool) -> i32 {
spinner.enable_steady_tick(std::time::Duration::from_millis(80));
// Gather state
let cli_settings = load_cli_settings(None).unwrap_or_default();
let cli_settings = load_cli_settings().unwrap_or_default();
let config_path = dirs::home_dir().map(|h| h.join(".fabro").join("cli.toml"));
let config_exists = config_path.as_ref().is_some_and(|p| p.exists());

View file

@ -9,7 +9,7 @@ use crate::args::GlobalArgs;
use crate::cli_config;
pub(crate) async fn execute(mut args: AgentArgs, globals: &GlobalArgs) -> Result<()> {
let cli_settings = cli_config::load_cli_settings(None)?;
let cli_settings = cli_config::load_cli_settings()?;
#[cfg(feature = "sleep_inhibitor")]
let _sleep_guard = crate::sleep_inhibitor::guard(cli_settings.prevent_idle_sleep_enabled());
let exec_defaults = cli_settings.exec.as_ref();

View file

@ -3,8 +3,8 @@ use std::io::Write;
use std::sync::LazyLock;
use anyhow::bail;
use fabro_config::cli::load_cli_config;
use fabro_config::project::{ResolveSettingsInput, resolve_settings, resolve_workflow_path};
use fabro_config::ConfigLayer;
use fabro_config::project::resolve_workflow_path;
use fabro_graphviz::render::render_dot;
use fabro_util::terminal::Styles;
use fabro_validate::Severity;
@ -19,14 +19,9 @@ static RANKDIR_RE: LazyLock<regex::Regex> =
pub(crate) fn run(args: &GraphArgs, styles: &Styles) -> anyhow::Result<()> {
let cwd = std::env::current_dir()?;
let cli_defaults = load_cli_config(None)?;
let settings = resolve_settings(ResolveSettingsInput {
workflow_path: args.workflow.clone(),
cwd: cwd.clone(),
defaults: cli_defaults,
overrides: fabro_config::FabroConfig::default(),
apply_project_config: true,
})?;
let settings = ConfigLayer::for_workflow(&args.workflow, &cwd)?
.combine(ConfigLayer::cli()?)
.resolve()?;
let resolution = resolve_workflow_path(&args.workflow, &cwd)?;
let validated = validate(ValidateInput {
workflow: WorkflowInput::Path(args.workflow.clone()),

View file

@ -7,7 +7,7 @@ use crate::args::{GlobalArgs, LlmCommand, LlmNamespace};
use crate::cli_config::load_cli_settings;
pub(crate) async fn dispatch(ns: LlmNamespace, globals: &GlobalArgs) -> Result<()> {
let cli_settings = load_cli_settings(None)?;
let cli_settings = load_cli_settings()?;
match ns.command {
LlmCommand::Prompt(args) => prompt::execute(args, &cli_settings, globals).await,

View file

@ -11,7 +11,7 @@ pub(crate) async fn execute(command: Option<ModelsCommand>, globals: &GlobalArgs
let server = {
#[cfg(feature = "server")]
{
let cli_settings = cli_config::load_cli_settings(None)?;
let cli_settings = cli_config::load_cli_settings()?;
let resolved = cli_config::resolve_mode(
globals.mode.clone(),
globals.server_url.as_deref(),

View file

@ -12,7 +12,7 @@ pub(super) async fn close_command(
args: PrCloseArgs,
github_app: Option<fabro_github::GitHubAppCredentials>,
) -> Result<()> {
let cli_settings = load_cli_settings(None)?;
let cli_settings = load_cli_settings()?;
let base = runs_base(&cli_settings.storage_dir());
close_from(&base, args, github_app).await
}

View file

@ -19,7 +19,7 @@ pub(super) async fn create_command(
args: PrCreateArgs,
github_app: Option<fabro_github::GitHubAppCredentials>,
) -> Result<()> {
let cli_settings = load_cli_settings(None)?;
let cli_settings = load_cli_settings()?;
let base = runs_base(&cli_settings.storage_dir());
create_from(&base, args, github_app).await
}

View file

@ -14,7 +14,7 @@ pub(super) async fn list_command(
args: PrListArgs,
github_app: Option<fabro_github::GitHubAppCredentials>,
) -> Result<()> {
let cli_settings = load_cli_settings(None)?;
let cli_settings = load_cli_settings()?;
let base = runs_base(&cli_settings.storage_dir());
let store = crate::store::build_store(&cli_settings.storage_dir())?;
list_from(store.as_ref(), &base, args, github_app).await

View file

@ -13,7 +13,7 @@ pub(super) async fn merge_command(
args: PrMergeArgs,
github_app: Option<fabro_github::GitHubAppCredentials>,
) -> Result<()> {
let cli_settings = load_cli_settings(None)?;
let cli_settings = load_cli_settings()?;
let base = runs_base(&cli_settings.storage_dir());
merge_from(&base, args, github_app).await
}

View file

@ -16,7 +16,7 @@ use crate::cli_config::load_cli_settings;
use crate::shared::github::build_github_app_credentials;
pub(crate) async fn dispatch(ns: PrNamespace) -> Result<()> {
let cli_settings = load_cli_settings(None)?;
let cli_settings = load_cli_settings()?;
let github_app = build_github_app_credentials(cli_settings.app_id());
match ns.command {

View file

@ -13,7 +13,7 @@ pub(super) async fn view_command(
args: PrViewArgs,
github_app: Option<fabro_github::GitHubAppCredentials>,
) -> Result<()> {
let cli_settings = load_cli_settings(None)?;
let cli_settings = load_cli_settings()?;
let base = runs_base(&cli_settings.storage_dir());
view_from(&base, args, github_app).await
}

View file

@ -2,11 +2,8 @@ use std::path::Path;
use std::sync::Arc;
use anyhow::bail;
use fabro_config::cli::load_cli_config;
use fabro_config::project::{
ResolveSettingsInput, resolve_settings, resolve_workflow_path, resolve_working_directory,
};
use fabro_config::{FabroConfig, FabroSettings};
use fabro_config::project::{resolve_workflow_path, resolve_working_directory};
use fabro_config::{ConfigLayer, FabroSettings};
use fabro_graphviz::graph::{Graph, is_llm_handler_type};
use fabro_llm::client::Client as LlmClient;
use fabro_model::{Catalog, Provider};
@ -24,20 +21,17 @@ use crate::shared::github::build_github_app_credentials;
pub(crate) async fn execute(mut args: PreflightArgs) -> anyhow::Result<()> {
let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr()));
let cli_defaults = load_cli_config(None)?;
let cli_settings: FabroSettings = cli_defaults.clone().try_into()?;
let cli = ConfigLayer::cli()?;
let cli_settings: FabroSettings = cli.clone().resolve()?;
args.verbose = args.verbose || cli_settings.verbose_enabled();
let github_app = build_github_app_credentials(cli_settings.app_id());
let cli_args_config = FabroConfig::try_from(&args)?;
let cli_args_config = ConfigLayer::try_from(&args)?;
let cwd = std::env::current_dir()?;
let settings = resolve_settings(ResolveSettingsInput {
workflow_path: args.workflow.clone(),
cwd: cwd.clone(),
defaults: cli_defaults,
overrides: cli_args_config,
apply_project_config: true,
})?;
let settings = cli_args_config
.combine(ConfigLayer::for_workflow(&args.workflow, &cwd)?)
.combine(cli)
.resolve()?;
let resolution = resolve_workflow_path(&args.workflow, &cwd)?;
let working_directory = resolve_working_directory(&settings, &cwd);

View file

@ -153,7 +153,7 @@ async fn check_github_app_installation() {
};
// Load CLI config to get app_id and slug
let Ok(cli_settings) = load_cli_settings(None) else {
let Ok(cli_settings) = load_cli_settings() else {
return;
};

View file

@ -1,18 +1,17 @@
use anyhow::Result;
use fabro_config::cli::load_cli_config;
use fabro_config::ConfigLayer;
use fabro_util::terminal::Styles;
use crate::args::{GlobalArgs, RunArgs};
pub(crate) async fn execute(mut args: RunArgs, _globals: &GlobalArgs) -> Result<()> {
let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr()));
let cli_defaults = load_cli_config(None)?;
let cli_settings: fabro_config::FabroSettings = cli_defaults.clone().try_into()?;
let cli_settings = ConfigLayer::cli()?.resolve()?;
args.verbose = args.verbose || cli_settings.verbose_enabled();
let quiet = args.detach;
let prevent_idle_sleep = cli_settings.prevent_idle_sleep_enabled();
let (run_id, run_dir) = super::create::create_run(&args, cli_defaults, styles, quiet)?;
let (run_id, run_dir) = super::create::create_run(&args, styles, quiet)?;
#[cfg(feature = "sleep_inhibitor")]
let _sleep_guard = crate::sleep_inhibitor::guard(prevent_idle_sleep);

View file

@ -28,7 +28,7 @@ enum CopyDirection {
pub(crate) async fn cp_command(args: CpArgs) -> Result<()> {
let direction = parse_direction(&args.src, &args.dst)?;
let cli_settings = load_cli_settings(None)?;
let cli_settings = load_cli_settings()?;
let base = runs_base(&cli_settings.storage_dir());
match direction {

View file

@ -1,8 +1,7 @@
use std::path::PathBuf;
use crate::args::RunArgs;
use fabro_config::project::{ResolveSettingsInput, resolve_settings};
use fabro_config::{FabroConfig, FabroSettings};
use fabro_config::{ConfigLayer, FabroSettings};
use fabro_util::terminal::Styles;
use fabro_workflows::error::FabroError;
use fabro_workflows::operations::{CreateRunInput, WorkflowInput, create};
@ -14,7 +13,6 @@ use super::output::{print_diagnostics_from_error, print_workflow_report_from_per
/// This does NOT execute the workflow — it only prepares the run directory.
pub(crate) fn create_run(
args: &RunArgs,
cli_defaults: FabroConfig,
styles: &Styles,
quiet: bool,
) -> anyhow::Result<(String, PathBuf)> {
@ -22,15 +20,12 @@ pub(crate) fn create_run(
.workflow
.as_ref()
.ok_or_else(|| anyhow::anyhow!("--workflow is required"))?;
let cli_args_config = FabroConfig::try_from(args)?;
let cli_args_config = ConfigLayer::try_from(args)?;
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let settings: FabroSettings = resolve_settings(ResolveSettingsInput {
workflow_path: workflow_path.clone(),
cwd: cwd.clone(),
defaults: cli_defaults,
overrides: cli_args_config,
apply_project_config: true,
})?;
let settings: FabroSettings = cli_args_config
.combine(ConfigLayer::for_workflow(workflow_path, &cwd)?)
.combine(ConfigLayer::cli()?)
.resolve()?;
let created = match create(CreateRunInput {
workflow: WorkflowInput::Path(workflow_path.clone()),

View file

@ -23,7 +23,7 @@ pub(crate) async fn execute(run_dir: PathBuf, launcher_path: PathBuf, resume: bo
});
let run_record = RunRecord::load(&run_dir)?;
let cli_settings = cli_config::load_cli_settings(None)?;
let cli_settings = cli_config::load_cli_settings()?;
let on_node: fabro_workflows::OnNodeCallback = Some({
let short_id = super::short_run_id(&run_record.run_id).to_string();
fabro_proctitle::set(&format!("fabro: {short_id}"));

View file

@ -15,7 +15,7 @@ use crate::cli_config::load_cli_settings;
pub(crate) async fn run(args: DiffArgs) -> Result<()> {
info!(run_id = %args.run, "Showing diff");
let cli_settings = load_cli_settings(None)?;
let cli_settings = load_cli_settings()?;
let base = runs_base(&cli_settings.storage_dir());
let store = crate::store::build_store(&cli_settings.storage_dir())?;
let run = resolve_run_combined(store.as_ref(), &base, &args.run).await?;

View file

@ -14,7 +14,7 @@ use crate::store::{build_store, open_run_reader};
pub(crate) async fn run(args: &ForkArgs, styles: &Styles) -> Result<()> {
let repo = Repository::discover(".").context("not in a git repository")?;
let cli_settings = load_cli_settings(None)?;
let cli_settings = load_cli_settings()?;
let durable_store = build_store(&cli_settings.storage_dir())?;
let run_id =
find_run_id_by_prefix_or_store(&repo, durable_store.as_ref(), &args.run_id).await?;

View file

@ -15,7 +15,7 @@ use crate::args::LogsArgs;
use crate::cli_config::load_cli_settings;
pub(crate) async fn run(args: &LogsArgs, styles: &Styles) -> Result<()> {
let cli_settings = load_cli_settings(None)?;
let cli_settings = load_cli_settings()?;
let base = runs_base(&cli_settings.storage_dir());
let store = crate::store::build_store(&cli_settings.storage_dir())?;
let run = resolve_run_combined(store.as_ref(), &base, &args.run).await?;

View file

@ -1,6 +1,5 @@
use anyhow::Result;
use fabro_config::FabroSettingsExt;
use fabro_config::cli::load_cli_config;
use fabro_util::terminal::Styles;
use fabro_workflows::run_lookup::{resolve_run_combined, runs_base};
@ -35,13 +34,12 @@ pub(crate) async fn dispatch(cmd: RunCommands, globals: &GlobalArgs) -> Result<(
RunCommands::Run(args) => command::execute(args, globals).await,
RunCommands::Create(args) => {
let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr()));
let cli_defaults = load_cli_config(None)?;
let (run_id, _run_dir) = create::create_run(&args, cli_defaults, styles, true)?;
let (run_id, _run_dir) = create::create_run(&args, styles, true)?;
println!("{run_id}");
Ok(())
}
RunCommands::Start { run } => {
let cli_settings = load_cli_settings(None)?;
let cli_settings = load_cli_settings()?;
let base = runs_base(&cli_settings.storage_dir());
let store = crate::store::build_store(&cli_settings.storage_dir())?;
let run_info = resolve_run_combined(store.as_ref(), &base, &run).await?;
@ -51,7 +49,7 @@ pub(crate) async fn dispatch(cmd: RunCommands, globals: &GlobalArgs) -> Result<(
}
RunCommands::Attach { run } => {
let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr()));
let cli_settings = load_cli_settings(None)?;
let cli_settings = load_cli_settings()?;
let base = runs_base(&cli_settings.storage_dir());
let store = crate::store::build_store(&cli_settings.storage_dir())?;
let run_info = resolve_run_combined(store.as_ref(), &base, &run).await?;
@ -80,7 +78,7 @@ pub(crate) async fn dispatch(cmd: RunCommands, globals: &GlobalArgs) -> Result<(
let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr()));
#[cfg(feature = "sleep_inhibitor")]
let _sleep_guard = {
let cli_settings = load_cli_settings(None)?;
let cli_settings = load_cli_settings()?;
crate::sleep_inhibitor::guard(cli_settings.prevent_idle_sleep_enabled())
};
resume::resume_command(args, styles).await

View file

@ -2,7 +2,7 @@ use std::collections::HashMap;
use anyhow::Result;
use fabro_config::run::LlmConfig;
use fabro_config::{FabroConfig, sandbox as sandbox_config};
use fabro_config::{ConfigLayer, sandbox as sandbox_config};
use fabro_sandbox::SandboxProvider;
use crate::args::{PreflightArgs, RunArgs};
@ -19,7 +19,7 @@ pub(crate) fn parse_labels(labels: &[String]) -> HashMap<String, String> {
.collect()
}
impl TryFrom<&RunArgs> for FabroConfig {
impl TryFrom<&RunArgs> for ConfigLayer {
type Error = anyhow::Error;
fn try_from(args: &RunArgs) -> Result<Self, Self::Error> {
@ -61,7 +61,7 @@ impl TryFrom<&RunArgs> for FabroConfig {
}
}
impl TryFrom<&PreflightArgs> for FabroConfig {
impl TryFrom<&PreflightArgs> for ConfigLayer {
type Error = anyhow::Error;
fn try_from(args: &PreflightArgs) -> Result<Self, Self::Error> {

View file

@ -10,7 +10,7 @@ use crate::cli_config::load_cli_settings;
use crate::shared::validate_daytona_provider;
pub(crate) async fn run(args: PreviewArgs) -> Result<()> {
let cli_settings = load_cli_settings(None)?;
let cli_settings = load_cli_settings()?;
let base = runs_base(&cli_settings.storage_dir());
let store = crate::store::build_store(&cli_settings.storage_dir())?;
let run = resolve_run_combined(store.as_ref(), &base, &args.run).await?;

View file

@ -16,7 +16,7 @@ pub(crate) async fn resume_command(
args: ResumeArgs,
styles: &'static Styles,
) -> anyhow::Result<()> {
let cli_settings = load_cli_settings(None)?;
let cli_settings = load_cli_settings()?;
let base = runs_base(&cli_settings.storage_dir());
let store = crate::store::build_store(&cli_settings.storage_dir())?;
let run = resolve_run_combined(store.as_ref(), &base, &args.run).await?;

View file

@ -18,7 +18,7 @@ use crate::store::{build_store, open_run_reader};
pub(crate) async fn run(args: &RewindArgs, styles: &Styles) -> Result<()> {
let repo = Repository::discover(".").context("not in a git repository")?;
let cli_settings = load_cli_settings(None)?;
let cli_settings = load_cli_settings()?;
let durable_store = build_store(&cli_settings.storage_dir())?;
let run_id =
find_run_id_by_prefix_or_store(&repo, durable_store.as_ref(), &args.run_id).await?;

View file

@ -10,7 +10,7 @@ use crate::cli_config::load_cli_settings;
use crate::shared::validate_daytona_provider;
pub(crate) async fn run(args: SshArgs) -> Result<()> {
let cli_settings = load_cli_settings(None)?;
let cli_settings = load_cli_settings()?;
let base = runs_base(&cli_settings.storage_dir());
let store = crate::store::build_store(&cli_settings.storage_dir())?;
let run = resolve_run_combined(store.as_ref(), &base, &args.run).await?;

View file

@ -13,7 +13,7 @@ use crate::cli_config::load_cli_settings;
use crate::shared::format_duration_ms;
pub(crate) async fn run(args: &WaitArgs, styles: &Styles) -> Result<()> {
let cli_settings = load_cli_settings(None)?;
let cli_settings = load_cli_settings()?;
let base = runs_base(&cli_settings.storage_dir());
let store = crate::store::build_store(&cli_settings.storage_dir())?;
let run_info = resolve_run_combined(store.as_ref(), &base, &args.run).await?;

View file

@ -26,7 +26,7 @@ pub(crate) struct InspectOutput {
}
pub(crate) async fn run(args: &InspectArgs) -> Result<()> {
let cli_settings = load_cli_settings(None)?;
let cli_settings = load_cli_settings()?;
let base = runs_base(&cli_settings.storage_dir());
let store = crate::store::build_store(&cli_settings.storage_dir())?;
let run = resolve_run_combined(store.as_ref(), &base, &args.run).await?;

View file

@ -18,7 +18,7 @@ use crate::shared::{color_if, format_duration_ms, tilde_path};
use super::short_run_id;
pub(crate) async fn list_command(args: &RunsListArgs, styles: &Styles) -> Result<()> {
let cli_settings = load_cli_settings(None)?;
let cli_settings = load_cli_settings()?;
let base = runs_base(&cli_settings.storage_dir());
let store = crate::store::build_store(&cli_settings.storage_dir())?;
let runs = scan_runs_combined(store.as_ref(), &base).await?;

View file

@ -16,7 +16,7 @@ use crate::cli_config::load_cli_settings;
use super::short_run_id;
pub(crate) async fn remove_command(args: &RunsRemoveArgs) -> Result<()> {
let cli_settings = load_cli_settings(None)?;
let cli_settings = load_cli_settings()?;
let base = runs_base(&cli_settings.storage_dir());
let store = crate::store::build_store(&cli_settings.storage_dir())?;
remove_from(args, store.as_ref(), &base).await

View file

@ -12,7 +12,7 @@ use crate::cli_config::load_cli_settings;
use crate::store;
pub(crate) async fn dump_command(args: &StoreDumpArgs) -> Result<()> {
let cli_settings = load_cli_settings(None)?;
let cli_settings = load_cli_settings()?;
let base = runs_base(&cli_settings.storage_dir());
let store = store::build_store(&cli_settings.storage_dir())?;
let run = resolve_run_combined(store.as_ref(), &base, &args.run).await?;

View file

@ -14,7 +14,7 @@ use crate::cli_config::load_cli_settings;
use crate::shared::format_size;
pub(super) async fn df_command(args: &DfArgs) -> Result<()> {
let cli_settings = load_cli_settings(None)?;
let cli_settings = load_cli_settings()?;
let data_dir = cli_settings.storage_dir();
let runs_base_dir = runs_base(&data_dir);
let logs_base_dir = logs_base(&data_dir);

View file

@ -13,7 +13,7 @@ use crate::cli_config::load_cli_settings;
use crate::shared::format_size;
pub(super) async fn prune_command(args: &RunsPruneArgs) -> Result<()> {
let cli_settings = load_cli_settings(None)?;
let cli_settings = load_cli_settings()?;
let base = runs_base(&cli_settings.storage_dir());
let store = crate::store::build_store(&cli_settings.storage_dir())?;
prune_from(args, store.as_ref(), &base).await

View file

@ -1,7 +1,6 @@
use anyhow::bail;
use fabro_config::FabroConfig;
use fabro_config::cli::load_cli_config;
use fabro_config::project::{ResolveSettingsInput, resolve_settings, resolve_workflow_path};
use fabro_config::ConfigLayer;
use fabro_config::project::resolve_workflow_path;
use fabro_util::terminal::Styles;
use fabro_validate::Severity;
use fabro_workflows::operations::{ValidateInput, WorkflowInput, validate};
@ -11,14 +10,9 @@ use crate::shared::{print_diagnostics, relative_path};
pub(crate) fn run(args: &ValidateArgs, styles: &Styles) -> anyhow::Result<()> {
let cwd = std::env::current_dir()?;
let cli_defaults = load_cli_config(None)?;
let settings = resolve_settings(ResolveSettingsInput {
workflow_path: args.workflow.clone(),
cwd: cwd.clone(),
defaults: cli_defaults,
overrides: FabroConfig::default(),
apply_project_config: true,
})?;
let settings = ConfigLayer::for_workflow(&args.workflow, &cwd)?
.combine(ConfigLayer::cli()?)
.resolve()?;
let resolution = resolve_workflow_path(&args.workflow, &cwd)?;
let validated = validate(ValidateInput {
workflow: WorkflowInput::Path(args.workflow.clone()),

View file

@ -115,7 +115,7 @@ async fn main_inner() -> (String, Result<()>) {
Err(err) => return (command_name, Err(err)),
}
} else {
match cli_config::load_cli_settings(None) {
match cli_config::load_cli_settings() {
Ok(cli_settings) => (
cli_settings.log.as_ref().and_then(|l| l.level.clone()),
cli_settings.upgrade_check_enabled(),
@ -126,7 +126,7 @@ async fn main_inner() -> (String, Result<()>) {
}
#[cfg(not(feature = "server"))]
{
match cli_config::load_cli_settings(None) {
match cli_config::load_cli_settings() {
Ok(cli_settings) => (
cli_settings.log.as_ref().and_then(|l| l.level.clone()),
cli_settings.upgrade_check_enabled(),
@ -188,7 +188,7 @@ async fn main_inner() -> (String, Result<()>) {
fabro_server::serve::serve_command(args, styles).await?;
}
Commands::Doctor { verbose, dry_run } => {
let cli_settings = cli_config::load_cli_settings(None)?;
let cli_settings = cli_config::load_cli_settings()?;
let verbose = verbose || cli_settings.verbose_enabled();
let exit_code = commands::doctor::run_doctor(verbose, !dry_run).await;
std::process::exit(exit_code);

View file

@ -3,7 +3,7 @@ use std::path::{Path, PathBuf};
use anyhow::anyhow;
use serde::{Deserialize, Serialize};
use crate::config::FabroConfig;
use crate::config::ConfigLayer;
pub use fabro_types::settings::cli::{
ClientTlsSettings, ExecSettings, ExecutionMode, OutputFormat, PermissionLevel, ServerSettings,
@ -72,6 +72,6 @@ impl From<ExecConfig> for ExecSettings {
/// Load CLI config from an explicit path or `~/.fabro/cli.toml`, returning defaults if the
/// default file doesn't exist. An explicit path that doesn't exist is an error.
pub fn load_cli_config(path: Option<&Path>) -> anyhow::Result<FabroConfig> {
pub fn load_cli_config(path: Option<&Path>) -> anyhow::Result<ConfigLayer> {
crate::load_config_file(path, "cli.toml")
}

View file

@ -1,18 +1,18 @@
use std::collections::HashMap;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::cli::{ExecConfig, ExecutionMode, ServerConfig};
use crate::cli::{self, ExecConfig, ExecutionMode, ServerConfig};
use crate::combine::Combine;
use crate::hook::{HookConfig, HookDefinition};
use crate::mcp::McpServerEntry;
use crate::project::ProjectFabroConfig;
use crate::project::{self, ProjectFabroConfig};
use crate::run::{
AssetsConfig, CheckpointConfig, GitHubConfig, LlmConfig, PullRequestConfig, SetupConfig,
};
use crate::sandbox::SandboxConfig;
use crate::server::{ApiConfig, Features, GitConfig, LogConfig, WebConfig};
use crate::server::{self, ApiConfig, Features, GitConfig, LogConfig, WebConfig};
use crate::settings::FabroSettings;
fn is_default_checkpoint(c: &CheckpointConfig) -> bool {
@ -25,7 +25,7 @@ fn is_default_checkpoint(c: &CheckpointConfig) -> bool {
/// `parse_project_config`) all return this type. Fields irrelevant to a
/// particular source are left unset (`None` / empty).
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
pub struct FabroConfig {
pub struct ConfigLayer {
// --- Workflow run config fields ---
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<u32>,
@ -132,7 +132,7 @@ pub struct FabroConfig {
pub fabro: Option<ProjectFabroConfig>,
}
impl Combine for FabroConfig {
impl Combine for ConfigLayer {
fn combine(self, other: Self) -> Self {
let hooks = if self.hooks.is_empty() {
other.hooks
@ -182,13 +182,58 @@ impl Combine for FabroConfig {
}
}
impl FabroConfig {
impl ConfigLayer {
#[must_use]
pub fn combine(self, other: Self) -> Self {
Combine::combine(self, other)
}
pub fn try_into_settings(self) -> anyhow::Result<FabroSettings> {
/// Load workflow config + project config for a workflow path.
///
/// Resolves the workflow path, loads its config, discovers project config
/// (`fabro.toml`) from the resolved workflow's parent directory, and combines
/// them (workflow takes precedence over project).
pub fn for_workflow(path: &Path, cwd: &Path) -> anyhow::Result<Self> {
let resolution = project::resolve_workflow_path(path, cwd)?;
if resolution.workflow_config.is_none() && !resolution.resolved_workflow_path.is_file() {
anyhow::bail!(
"Workflow not found: {}",
resolution.resolved_workflow_path.display()
);
}
let workflow_config = resolution.workflow_config.unwrap_or_default();
let project_config = project::discover_project_config(
resolution
.resolved_workflow_path
.parent()
.unwrap_or_else(|| Path::new(".")),
)?
.map(|(_, config)| config)
.unwrap_or_default();
Ok(workflow_config.combine(project_config))
}
/// Discover project config (`fabro.toml`) by walking ancestors from `start`.
pub fn project(start: &Path) -> anyhow::Result<Self> {
Ok(project::discover_project_config(start)?
.map(|(_, config)| config)
.unwrap_or_default())
}
/// Load CLI defaults from `~/.fabro/cli.toml`.
pub fn cli() -> anyhow::Result<Self> {
cli::load_cli_config(None)
}
/// Load server defaults from `~/.fabro/server.toml`.
pub fn server() -> anyhow::Result<Self> {
server::load_server_config(None)
}
/// Convert this combined config layer into final resolved settings.
pub fn resolve(self) -> anyhow::Result<FabroSettings> {
self.try_into()
}
}

View file

@ -12,7 +12,7 @@ pub mod sandbox;
pub mod server;
pub mod settings;
pub use config::FabroConfig;
pub use config::ConfigLayer;
pub use fabro_types::Combine;
pub use fabro_util::path::expand_tilde;
pub use settings::{FabroSettings, FabroSettingsExt};

View file

@ -5,7 +5,7 @@ use anyhow::{Context, bail};
use serde::{Deserialize, Serialize};
use crate::FabroSettings;
use crate::config::FabroConfig;
use crate::config::ConfigLayer;
use crate::run;
pub use fabro_types::settings::project::ProjectFabroSettings;
@ -23,20 +23,11 @@ pub struct ProjectFabroConfig {
pub struct WorkflowPathResolution {
pub resolved_workflow_path: PathBuf,
pub dot_path: PathBuf,
pub workflow_config: Option<FabroConfig>,
pub workflow_config: Option<ConfigLayer>,
pub workflow_toml_path: Option<PathBuf>,
pub workflow_slug: Option<String>,
}
#[derive(Clone, Debug)]
pub struct ResolveSettingsInput {
pub workflow_path: PathBuf,
pub cwd: PathBuf,
pub defaults: FabroConfig,
pub overrides: FabroConfig,
pub apply_project_config: bool,
}
fn default_root() -> String {
".".to_string()
}
@ -50,8 +41,8 @@ impl From<ProjectFabroConfig> for ProjectFabroSettings {
}
/// Parse a project config from a TOML string.
pub fn parse_project_config(content: &str) -> anyhow::Result<FabroConfig> {
let config: FabroConfig = toml::from_str(content).context("Failed to parse project config")?;
pub fn parse_project_config(content: &str) -> anyhow::Result<ConfigLayer> {
let config: ConfigLayer = toml::from_str(content).context("Failed to parse project config")?;
let version = config.version.unwrap_or(0);
if version != SUPPORTED_VERSION {
bail!(
@ -62,7 +53,7 @@ pub fn parse_project_config(content: &str) -> anyhow::Result<FabroConfig> {
}
/// Load a project config from a file path.
pub fn load_project_config(path: &Path) -> anyhow::Result<FabroConfig> {
pub fn load_project_config(path: &Path) -> anyhow::Result<ConfigLayer> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read {}", path.display()))?;
let config = parse_project_config(&content)?;
@ -77,7 +68,7 @@ pub fn load_project_config(path: &Path) -> anyhow::Result<FabroConfig> {
/// 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, FabroConfig)>> {
pub fn discover_project_config(start: &Path) -> anyhow::Result<Option<(PathBuf, ConfigLayer)>> {
for ancestor in start.ancestors() {
let candidate = ancestor.join(CONFIG_FILENAME);
if candidate.is_file() {
@ -192,36 +183,6 @@ pub fn resolve_working_directory(settings: &FabroSettings, caller_cwd: &Path) ->
}
}
pub fn resolve_settings(input: ResolveSettingsInput) -> anyhow::Result<FabroSettings> {
let resolution = resolve_workflow_path(&input.workflow_path, &input.cwd)?;
if resolution.workflow_config.is_none() && !resolution.resolved_workflow_path.is_file() {
anyhow::bail!(
"Workflow not found: {}",
resolution.resolved_workflow_path.display()
);
}
let project_config = if input.apply_project_config {
discover_project_config(
resolution
.resolved_workflow_path
.parent()
.unwrap_or_else(|| Path::new(".")),
)?
.map(|(_, config)| config)
.unwrap_or_default()
} else {
FabroConfig::default()
};
input
.overrides
.combine(resolution.workflow_config.unwrap_or_default())
.combine(project_config)
.combine(input.defaults)
.try_into()
}
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())
}
@ -414,7 +375,7 @@ fn find_closest_match(input: &str, candidates: &[String]) -> Option<String> {
///
/// 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<FabroConfig>)> {
pub fn resolve_workflow(arg: &Path) -> anyhow::Result<(PathBuf, Option<ConfigLayer>)> {
let start = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let resolution = resolve_workflow_path(arg, &start)?;
Ok((resolution.dot_path, resolution.workflow_config))
@ -437,7 +398,7 @@ pub fn is_retro_enabled() -> bool {
/// 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: &FabroConfig) -> PathBuf {
pub fn resolve_fabro_root(config_path: &Path, config: &ConfigLayer) -> PathBuf {
let project_dir = config_path
.parent()
.expect("config_path should have a parent directory");
@ -596,7 +557,7 @@ model = "claude-sonnet-4-6"
#[test]
fn resolve_fabro_root_with_subdirectory() {
let config_path = Path::new("/repo/fabro.toml");
let config = FabroConfig {
let config = ConfigLayer {
version: Some(1),
fabro: Some(ProjectFabroConfig {
root: Some("fabro/".to_string()),
@ -613,7 +574,7 @@ model = "claude-sonnet-4-6"
#[test]
fn resolve_fabro_root_with_dot() {
let config_path = Path::new("/repo/fabro.toml");
let config = FabroConfig {
let config = ConfigLayer {
version: Some(1),
fabro: Some(ProjectFabroConfig {
root: Some(".".to_string()),
@ -630,13 +591,92 @@ model = "claude-sonnet-4-6"
#[test]
fn resolve_fabro_root_without_fabro_section() {
let config_path = Path::new("/repo/fabro.toml");
let config = FabroConfig::default();
let config = ConfigLayer::default();
assert_eq!(
resolve_fabro_root(config_path, &config),
Path::new("/repo/.")
);
}
#[test]
fn for_workflow_discovers_project_from_workflow_location() {
let tmp = TempDir::new().unwrap();
let project_dir = tmp.path().join("project");
let other_dir = tmp.path().join("other");
let workflow_dir = project_dir.join("workflows").join("demo");
fs::create_dir_all(&workflow_dir).unwrap();
fs::create_dir_all(&other_dir).unwrap();
fs::write(
project_dir.join("fabro.toml"),
"version = 1\nverbose = true\n",
)
.unwrap();
fs::write(
other_dir.join("fabro.toml"),
"version = 1\nverbose = false\n",
)
.unwrap();
fs::write(workflow_dir.join("workflow.toml"), "version = 1\n").unwrap();
let layer =
ConfigLayer::for_workflow(&workflow_dir.join("workflow.toml"), &other_dir).unwrap();
assert_eq!(layer.verbose, Some(true));
}
#[test]
fn chained_resolve_preserves_precedence_order() {
let tmp = TempDir::new().unwrap();
let project_dir = tmp.path().join("project");
let workflow_dir = project_dir.join("workflows").join("demo");
fs::create_dir_all(&workflow_dir).unwrap();
fs::write(
project_dir.join("fabro.toml"),
"version = 1\nverbose = true\n[llm]\nmodel = \"project-model\"\n",
)
.unwrap();
fs::write(
workflow_dir.join("workflow.toml"),
"version = 1\ndry_run = true\n[llm]\nmodel = \"workflow-model\"\n",
)
.unwrap();
let cli_defaults = ConfigLayer {
verbose: Some(false),
llm: Some(crate::run::LlmConfig {
model: Some("cli-model".to_string()),
provider: None,
fallbacks: None,
}),
..Default::default()
};
let overrides = ConfigLayer {
dry_run: Some(false),
..Default::default()
};
let settings = overrides
.combine(
ConfigLayer::for_workflow(
&workflow_dir.join("workflow.toml"),
project_dir.as_path(),
)
.unwrap(),
)
.combine(cli_defaults)
.resolve()
.unwrap();
assert_eq!(
settings.llm.as_ref().and_then(|llm| llm.model.as_deref()),
Some("workflow-model")
);
assert_eq!(settings.dry_run, Some(false));
assert_eq!(settings.verbose, Some(true));
}
#[test]
fn resolve_workflow_arg_toml_extension_returned_as_is() {
let tmp = TempDir::new().unwrap();

View file

@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize};
use tracing::debug;
use crate::combine::Combine;
use crate::config::FabroConfig;
use crate::config::ConfigLayer;
use crate::sandbox::DockerfileSource;
pub use fabro_types::settings::run::{
AssetsSettings, CheckpointSettings, GitHubSettings, LlmSettings, MergeStrategy,
@ -129,7 +129,7 @@ impl From<SetupConfig> for SetupSettings {
/// `${env.VARNAME}` references in `[sandbox.env]` are NOT resolved here —
/// call [`resolve_sandbox_env`] separately after snapshotting, so that
/// plaintext secrets are never written to disk.
pub fn load_run_config(path: &Path) -> anyhow::Result<FabroConfig> {
pub fn load_run_config(path: &Path) -> anyhow::Result<ConfigLayer> {
let contents = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read {}", path.display()))?;
let mut config = parse_run_config(&contents)?;
@ -144,7 +144,7 @@ pub fn load_run_config(path: &Path) -> anyhow::Result<FabroConfig> {
///
/// Only whole-value references are supported (no partial interpolation).
/// Missing host env vars produce a hard error.
pub fn resolve_sandbox_env(config: &mut FabroConfig) -> anyhow::Result<()> {
pub fn resolve_sandbox_env(config: &mut ConfigLayer) -> anyhow::Result<()> {
if let Some(env) = config.sandbox.as_mut().and_then(|s| s.env.as_mut()) {
resolve_env_refs(env)?;
}
@ -172,7 +172,7 @@ pub fn resolve_env_refs(env: &mut HashMap<String, String>) -> anyhow::Result<()>
/// If the config contains a `dockerfile = { path = "..." }`, read the file
/// and replace it with `DockerfileSource::Inline(contents)`.
fn resolve_dockerfile(config: &mut FabroConfig, config_dir: &Path) -> anyhow::Result<()> {
fn resolve_dockerfile(config: &mut ConfigLayer, config_dir: &Path) -> anyhow::Result<()> {
let source = config
.sandbox
.as_mut()
@ -204,8 +204,8 @@ pub fn resolve_graph_path(toml_path: &Path, graph: &str) -> PathBuf {
}
}
pub fn parse_run_config(contents: &str) -> anyhow::Result<FabroConfig> {
let mut config: FabroConfig =
pub fn parse_run_config(contents: &str) -> anyhow::Result<ConfigLayer> {
let mut config: ConfigLayer =
toml::from_str(contents).context("Failed to parse run config TOML")?;
if config.graph.is_none() {

View file

@ -3,7 +3,7 @@ use std::path::{Path, PathBuf};
use anyhow::anyhow;
use serde::{Deserialize, Serialize};
use crate::config::FabroConfig;
use crate::config::ConfigLayer;
use crate::settings::{FabroSettings, FabroSettingsExt};
pub use fabro_types::settings::server::{
ApiAuthStrategy, ApiSettings, AuthProvider, AuthSettings, FeaturesSettings, GitAuthorSettings,
@ -181,7 +181,7 @@ impl From<LogConfig> for LogSettings {
/// Load server config from an explicit path or `~/.fabro/server.toml`, returning defaults if the
/// default file doesn't exist. An explicit path that doesn't exist is an error.
pub fn load_server_config(path: Option<&Path>) -> anyhow::Result<FabroConfig> {
pub fn load_server_config(path: Option<&Path>) -> anyhow::Result<ConfigLayer> {
crate::load_config_file(path, "server.toml")
}

View file

@ -2,7 +2,7 @@ use std::path::PathBuf;
pub use fabro_types::settings::FabroSettings;
use crate::config::FabroConfig;
use crate::config::ConfigLayer;
pub trait FabroSettingsExt {
fn storage_dir(&self) -> PathBuf;
@ -18,10 +18,10 @@ impl FabroSettingsExt for FabroSettings {
}
}
impl TryFrom<FabroConfig> for FabroSettings {
impl TryFrom<ConfigLayer> for FabroSettings {
type Error = anyhow::Error;
fn try_from(value: FabroConfig) -> Result<Self, Self::Error> {
fn try_from(value: ConfigLayer) -> Result<Self, Self::Error> {
Ok(Self {
version: value.version,
goal: value.goal,
@ -60,10 +60,10 @@ impl TryFrom<FabroConfig> for FabroSettings {
}
}
impl TryFrom<&FabroConfig> for FabroSettings {
impl TryFrom<&ConfigLayer> for FabroSettings {
type Error = anyhow::Error;
fn try_from(value: &FabroConfig) -> Result<Self, Self::Error> {
fn try_from(value: &ConfigLayer) -> Result<Self, Self::Error> {
value.clone().try_into()
}
}