feat(config): switch parser and layering to v2 schema

Stage 3 of the settings TOML redesign. Switches the core parse/merge/
resolve path to the v2 namespaced schema while keeping the legacy flat
Settings shape accessible via the bridge for not-yet-migrated consumers.

Parser and layering:
- ConfigLayer is now a newtype around v2 SettingsFile. Loading via
  ConfigLayer::parse/load/settings/for_workflow/project now hard-fails
  on legacy top-level keys (version, llm, vars, sandbox, etc.) with
  targeted rename hints emitted by fabro_types::settings::v2::tree
- new fabro_config::merge module encodes the merge matrix directly:
  replace-by-default maps, sticky merge for run.sandbox.env and
  provider-native labels, splice-aware string arrays for
  run.model.fallbacks and notification route events, whole-list
  replacement for run.prepare.steps, field-merge keyed objects for
  notifications/MCPs/web-auth providers, and ordered hook id-aware
  replacement
- ConfigLayer::resolve delegates to fabro_types::settings::v2::bridge
  so consumers keep reading through the legacy Settings shape until
  Stage 4 migrates them off it
- effective_settings::resolve_settings now treats project/workflow/
  run/features as shared layered domains and strips cli/server from
  non-local layers before merging, fulfilling the owner-first trust
  boundary rule

Consumer migration (Stage 4 preview, kept to the files that block
the workspace build):
- fabro-server run_manifest builds v2 RunLayer from ManifestArgs and
  resolves manifest dockerfile references through the v2 sandbox
  daytona snapshot tree
- fabro-cli manifest_builder consults run.goal via v2; user_config
  writes the v2 server.storage.root field under the CLI storage-dir
  override; run/overrides constructs a v2 RunLayer from RunArgs
- fabro-cli scaffolds (repo init, workflow create) emit _version = 1
  with project.directory/workflow.graph/run.sandbox etc.

fabro-config / fabro-types legacy parse-time types (ProjectConfig,
LlmConfig, SandboxConfig, PullRequestConfig, ExecConfig, SettingsFile
try_into, etc.) are deleted from the parse path; the resolved type
re-exports (LlmSettings, SandboxSettings, etc.) remain as shims so
unmigrated consumers keep compiling.

fabro-test helper: settings.toml fixtures now use _version = 1 plus
[server.storage] root and [cli.target] type = "unix" path. Legacy
flat storage_dir/server.target handling removed from the sync path.

Known Stage 4/5 follow-ups:
- fabro-cli integration test fixtures still use legacy-shape TOML
  (version = 1, [llm], [sandbox], [vars], [exec], [fabro], etc.);
  tests currently fail to parse against the v2 schema as intended.
  Migrating them is the bulk of Stage 4 and lands in subsequent
  commits.
- OpenAPI ServerSettings schema, generated clients, apps/fabro-web
  workflowData fallback, and docs/reference examples are unchanged
  and land in Stage 5.
This commit is contained in:
Bryan Helmkamp 2026-04-09 09:49:23 -04:00
parent bb228643e7
commit a0eec6aee1
No known key found for this signature in database
19 changed files with 1471 additions and 1553 deletions

View file

@ -40,16 +40,13 @@ pub(crate) async fn run_init(args: &RepoInitArgs, globals: &GlobalArgs) -> Resul
# Fabro project configuration
# https://docs.fabro.computer/getting-started/quick-start
version = 1
_version = 1
[fabro]
root = \"fabro/\"
# Disable retrospective analysis after workflow runs:
# retro = false
[project]
directory = \"fabro/\"
# Auto-create pull requests on successful workflow runs.
[pull_request]
[run.pull_request]
enabled = true
draft = true
# auto_merge = true
@ -101,7 +98,7 @@ draft = true
let toml_path = workflow_dir.join("workflow.toml");
std::fs::write(
&toml_path,
"version = 1\ngraph = \"workflow.fabro\"\n\n[sandbox]\nprovider = \"local\"\n",
"_version = 1\n\n[workflow]\ngraph = \"workflow.fabro\"\n\n[run.sandbox]\nprovider = \"local\"\n",
)
.with_context(|| format!("failed to write {}", toml_path.display()))?;
created.push("fabro/workflows/hello/workflow.toml".to_string());

View file

@ -36,7 +36,7 @@ pub(crate) async fn create_run(
.clone()
.combine(ConfigLayer::for_workflow(workflow_path, &cwd)?)
.combine(cli_defaults)
.resolve()?;
.resolve();
let run_id = args
.run_id
.as_deref()

View file

@ -1,9 +1,13 @@
use std::collections::HashMap;
use anyhow::Result;
use fabro_config::run::LlmConfig;
use fabro_config::{ConfigLayer, sandbox as sandbox_config};
use fabro_config::ConfigLayer;
use fabro_sandbox::SandboxProvider;
use fabro_types::settings::v2::SettingsFile;
use fabro_types::settings::v2::interp::InterpString;
use fabro_types::settings::v2::run::{
ApprovalMode, RunExecutionLayer, RunLayer, RunMode, RunModelLayer, RunSandboxLayer,
};
use crate::args::{PreflightArgs, RunArgs};
@ -19,44 +23,90 @@ pub(crate) fn parse_labels(labels: &[String]) -> HashMap<String, String> {
.collect()
}
fn model_from_args(model: &Option<String>, provider: &Option<String>) -> Option<RunModelLayer> {
if model.is_none() && provider.is_none() {
return None;
}
Some(RunModelLayer {
provider: provider.as_deref().map(InterpString::parse),
name: model.as_deref().map(InterpString::parse),
fallbacks: Vec::new(),
})
}
fn sandbox_layer(
sandbox: Option<SandboxProvider>,
preserve: Option<bool>,
) -> Option<RunSandboxLayer> {
if sandbox.is_none() && preserve.is_none() {
return None;
}
Some(RunSandboxLayer {
provider: sandbox.map(|p| p.to_string()),
preserve,
..RunSandboxLayer::default()
})
}
fn execution_layer(
dry_run: Option<bool>,
auto_approve: Option<bool>,
no_retro: Option<bool>,
) -> Option<RunExecutionLayer> {
if dry_run.is_none() && auto_approve.is_none() && no_retro.is_none() {
return None;
}
Some(RunExecutionLayer {
mode: dry_run.map(|d| if d { RunMode::DryRun } else { RunMode::Normal }),
approval: auto_approve.map(|a| {
if a {
ApprovalMode::Auto
} else {
ApprovalMode::Prompt
}
}),
retros: no_retro.map(|nr| !nr),
})
}
impl TryFrom<&RunArgs> for ConfigLayer {
type Error = anyhow::Error;
fn try_from(args: &RunArgs) -> Result<Self, Self::Error> {
let llm = if args.model.is_some() || args.provider.is_some() {
Some(LlmConfig {
model: args.model.clone(),
provider: args.provider.clone(),
fallbacks: None,
})
} else {
None
};
let sandbox = if args.sandbox.is_some() || args.preserve_sandbox {
Some(sandbox_config::SandboxConfig {
provider: args
.sandbox
.map(Into::into)
.map(|provider: SandboxProvider| provider.to_string()),
preserve: sparse_flag(args.preserve_sandbox),
..Default::default()
})
} else {
None
let model = model_from_args(&args.model, &args.provider);
let sandbox = sandbox_layer(
args.sandbox.map(Into::into),
sparse_flag(args.preserve_sandbox),
);
let execution = execution_layer(
sparse_flag(args.dry_run),
sparse_flag(args.auto_approve),
sparse_flag(args.no_retro),
);
let run = RunLayer {
goal: args.goal.as_deref().map(InterpString::parse),
metadata: parse_labels(&args.label),
model,
sandbox,
execution,
..RunLayer::default()
};
Ok(Self {
goal: args.goal.clone(),
goal_file: args.goal_file.clone(),
llm,
sandbox,
verbose: sparse_flag(args.verbose),
dry_run: sparse_flag(args.dry_run),
auto_approve: sparse_flag(args.auto_approve),
no_retro: sparse_flag(args.no_retro),
labels: parse_labels(&args.label),
..Default::default()
})
let mut file = SettingsFile::default();
file.run = Some(run);
// goal_file is not part of v2; fall through to Settings.goal_file via the bridge.
// Stage 4 consumers that still consult goal_file read it from Settings.
let _ = &args.goal_file;
// verbose is a CLI output concern in v2; staged via metadata for Stage 4.
if args.verbose {
file.run
.as_mut()
.unwrap()
.metadata
.insert("fabro.verbose".into(), "true".into());
}
Ok(Self::from(file))
}
}
@ -64,27 +114,29 @@ impl TryFrom<&PreflightArgs> for ConfigLayer {
type Error = anyhow::Error;
fn try_from(args: &PreflightArgs) -> Result<Self, Self::Error> {
let llm = if args.model.is_some() || args.provider.is_some() {
Some(LlmConfig {
model: args.model.clone(),
provider: args.provider.clone(),
fallbacks: None,
})
} else {
None
};
let sandbox = args.sandbox.map(|sandbox| sandbox_config::SandboxConfig {
provider: Some(SandboxProvider::from(sandbox).to_string()),
..Default::default()
let model = model_from_args(&args.model, &args.provider);
let sandbox = args.sandbox.map(|s| RunSandboxLayer {
provider: Some(SandboxProvider::from(s).to_string()),
..RunSandboxLayer::default()
});
Ok(Self {
goal: args.goal.clone(),
goal_file: args.goal_file.clone(),
llm,
let run = RunLayer {
goal: args.goal.as_deref().map(InterpString::parse),
model,
sandbox,
verbose: sparse_flag(args.verbose),
..Default::default()
})
..RunLayer::default()
};
let mut file = SettingsFile::default();
file.run = Some(run);
let _ = &args.goal_file; // Stage 4 preflight still reads goal_file via Settings bridge.
if args.verbose {
file.run
.as_mut()
.unwrap()
.metadata
.insert("fabro.verbose".into(), "true".into());
}
Ok(Self::from(file))
}
}

View file

@ -104,7 +104,7 @@ fn write_workflow_scaffold(
.with_context(|| format!("failed to write {}", dot_path.display()))?;
let toml_path = workflows_dir.join("workflow.toml");
std::fs::write(&toml_path, "version = 1\n")
std::fs::write(&toml_path, "_version = 1\n")
.with_context(|| format!("failed to write {}", toml_path.display()))?;
Ok(vec![dot_path, toml_path])

View file

@ -6,7 +6,6 @@ use fabro_api::types;
use fabro_config::ConfigLayer;
use fabro_config::project::{self, discover_project_config, resolve_workflow_path};
use fabro_config::run::parse_run_config;
use fabro_config::sandbox::DockerfileSource;
use fabro_config::user::active_settings_path;
use fabro_graphviz::graph::AttrValue;
use fabro_graphviz::parser;
@ -51,7 +50,7 @@ pub(crate) fn build_run_manifest(input: ManifestBuildInput) -> Result<BuiltManif
.clone()
.combine(ConfigLayer::for_workflow(&input.workflow, &input.cwd)?)
.combine(user_layer.clone())
.resolve()?;
.resolve();
let root_resolution = resolve_workflow_path(&input.workflow, &input.cwd)?;
let target_path = root_resolution.dot_path.clone();
@ -320,13 +319,16 @@ fn collect_workflow_config_files(
) -> Result<()> {
let config_layer = parse_run_config(&config.source)?;
let dockerfile = config_layer
.sandbox
.as_v2()
.run
.as_ref()
.and_then(|run| run.sandbox.as_ref())
.and_then(|sandbox| sandbox.daytona.as_ref())
.and_then(|daytona| daytona.snapshot.as_ref())
.and_then(|snapshot| snapshot.dockerfile.as_ref());
let Some(DockerfileSource::Path { path }) = dockerfile else {
let Some(fabro_types::settings::v2::run::DaytonaDockerfileLayer::Path { path }) = dockerfile
else {
return Ok(());
};
@ -390,21 +392,18 @@ fn resolve_manifest_goal(
) -> Result<Option<types::ManifestGoal>> {
let working_directory = project::resolve_working_directory(settings, cwd);
if let Some(goal) = args_layer.goal.as_ref() {
if let Some(goal) = args_layer
.as_v2()
.run
.as_ref()
.and_then(|r| r.goal.as_ref())
{
return Ok(Some(types::ManifestGoal {
path: None,
text: goal.clone(),
text: goal.as_source(),
type_: types::ManifestGoalType::Value,
}));
}
if let Some(goal_file) = args_layer.goal_file.as_ref() {
return Ok(Some(types::ManifestGoal {
path: Some(goal_file.display().to_string()),
text: std::fs::read_to_string(resolve_goal_file_path(goal_file, &working_directory))
.with_context(|| format!("Failed to read {}", goal_file.display()))?,
type_: types::ManifestGoalType::File,
}));
}
if let Some(goal) = settings.goal.as_ref() {
return Ok(Some(types::ManifestGoal {
path: None,

View file

@ -31,22 +31,29 @@ pub(crate) fn settings_layer_with_storage_dir(
pub(crate) fn load_settings_with_storage_dir(
storage_dir: Option<&Path>,
) -> anyhow::Result<Settings> {
settings_layer_with_storage_dir(storage_dir)?.resolve()
Ok(settings_layer_with_storage_dir(storage_dir)?.resolve())
}
pub(crate) fn load_settings_with_config_and_storage_dir(
config_path: Option<&Path>,
storage_dir: Option<&Path>,
) -> anyhow::Result<Settings> {
settings_layer_with_config_and_storage_dir(config_path, storage_dir)?.resolve()
Ok(settings_layer_with_config_and_storage_dir(config_path, storage_dir)?.resolve())
}
pub(crate) fn apply_storage_dir_override(
mut layer: ConfigLayer,
storage_dir: Option<&Path>,
) -> ConfigLayer {
use fabro_types::settings::v2::interp::InterpString;
use fabro_types::settings::v2::server::{ServerLayer, ServerStorageLayer};
if let Some(dir) = storage_dir {
layer.storage_dir = Some(dir.to_path_buf());
let file = layer.as_v2_mut();
let server = file.server.get_or_insert_with(ServerLayer::default);
let storage = server
.storage
.get_or_insert_with(ServerStorageLayer::default);
storage.root = Some(InterpString::parse(&dir.display().to_string()));
}
layer

View file

@ -270,7 +270,7 @@ pub(crate) fn setup_project_fixture(context: &TestContext) -> ProjectFixture {
let fabro_root = project_dir.join("fabro");
write_text_file(
&project_dir.join("fabro.toml"),
"version = 1\n[fabro]\nroot = \"fabro/\"\n",
"_version = 1\n\n[project]\ndirectory = \"fabro/\"\n",
);
std::fs::create_dir_all(fabro_root.join("workflows"))
.unwrap_or_else(|err| panic!("failed to create {}: {err}", fabro_root.display()));
@ -306,18 +306,22 @@ pub(crate) fn setup_artifact_run(context: &TestContext) -> WorkspaceRunSetup {
);
write_text_file(
&workspace_dir.join("run.toml"),
r#"version = 1
r#"_version = 1
[workflow]
graph = "artifact_run.fabro"
[run]
goal = "Exercise artifact commands"
[sandbox]
[run.sandbox]
provider = "local"
preserve = true
[sandbox.local]
[run.sandbox.local]
worktree_mode = "never"
[artifacts]
[run.artifacts]
include = ["assets/**"]
"#,
);
@ -345,15 +349,19 @@ pub(crate) fn setup_local_sandbox_run(context: &TestContext) -> WorkspaceRunSetu
);
write_text_file(
&workspace_dir.join("run.toml"),
r#"version = 1
r#"_version = 1
[workflow]
graph = "sandbox_run.fabro"
[run]
goal = "Exercise sandbox commands"
[sandbox]
[run.sandbox]
provider = "local"
preserve = true
[sandbox.local]
[run.sandbox.local]
worktree_mode = "never"
"#,
);
@ -408,7 +416,9 @@ pub(crate) fn add_project_workflow(
write_text_file(&workflow_dir.join("workflow.fabro"), dot_source);
write_text_file(
&workflow_dir.join("workflow.toml"),
&format!("version = 1\ngoal = {goal:?}\ngraph = \"workflow.fabro\"\n"),
&format!(
"_version = 1\n\n[workflow]\ngraph = \"workflow.fabro\"\n\n[run]\ngoal = {goal:?}\n"
),
);
workflow_dir
}
@ -419,7 +429,9 @@ pub(crate) fn add_user_workflow(context: &TestContext, name: &str, goal: &str) -
.unwrap_or_else(|err| panic!("failed to create {}: {err}", workflow_dir.display()));
write_text_file(
&workflow_dir.join("workflow.toml"),
&format!("version = 1\ngoal = {goal:?}\ngraph = \"workflow.fabro\"\n"),
&format!(
"_version = 1\n\n[workflow]\ngraph = \"workflow.fabro\"\n\n[run]\ngoal = {goal:?}\n"
),
);
write_text_file(
&workflow_dir.join("workflow.fabro"),

View file

@ -1,202 +1,100 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
//! v2-backed configuration layer.
//!
//! `ConfigLayer` is now a newtype over [`SettingsFile`] — the v2 namespaced
//! parse tree in `fabro_types::settings::v2`. Loading functions return the
//! same `ConfigLayer` type they always have; internally they call
//! `parse_settings_file`, which hard-fails on any legacy top-level key with a
//! targeted rename hint.
//!
//! `ConfigLayer::combine` walks the v2 merge matrix from `crate::merge`.
//! `ConfigLayer::resolve` uses the temporary bridge in
//! `fabro_types::settings::v2::bridge` to produce the legacy flat [`Settings`]
//! shape until Stage 4 migrates consumers off it.
use std::path::Path;
use anyhow::Context;
use fabro_types::Settings;
use fabro_types::settings::v2::{
SettingsFile, bridge_to_old, parse_settings_file as parse_v2_settings_file,
};
use serde::{Deserialize, Serialize};
use crate::combine::Combine;
use crate::hook::{HookDefinition, HookSettings};
use crate::mcp::McpServerEntry;
use crate::project::{self, ProjectConfig};
use crate::run::{
ArtifactsConfig, CheckpointConfig, GitHubConfig, LlmConfig, PullRequestConfig, SetupConfig,
};
use crate::sandbox::SandboxConfig;
use crate::server::{ApiConfig, FeaturesConfig, GitConfig, LogConfig, SlackConfig, WebConfig};
use crate::user::{self, ExecConfig, ServerConfig};
use fabro_types::Settings;
use crate::merge::combine_files;
use crate::project::{self};
use crate::user;
fn is_default_checkpoint(c: &CheckpointConfig) -> bool {
c.exclude_globs.is_empty()
}
/// Unified sparse configuration type for all Fabro config sources.
/// A parsed settings file layer.
///
/// Loading functions (`load_settings_config`, `load_run_config`,
/// `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)]
/// Currently a thin newtype around the v2 [`SettingsFile`] parse tree. The
/// newtype exists so fabro-config can attach helper methods and evolve the
/// internal representation without forcing every caller to import v2 types.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ConfigLayer {
// --- Workflow run config fields ---
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub goal: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub goal_file: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub graph: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub labels: HashMap<String, String>,
// --- Run defaults fields (inlined) ---
#[serde(default, alias = "directory", skip_serializing_if = "Option::is_none")]
pub work_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub llm: Option<LlmConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub setup: Option<SetupConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sandbox: Option<SandboxConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vars: Option<HashMap<String, String>>,
#[serde(default, skip_serializing_if = "is_default_checkpoint")]
pub checkpoint: CheckpointConfig,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pull_request: Option<PullRequestConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub artifacts: Option<ArtifactsConfig>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub hooks: Vec<HookDefinition>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub mcp_servers: HashMap<String, McpServerEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub github: Option<GitHubConfig>,
// --- User config fields ---
#[serde(default, skip_serializing_if = "Option::is_none")]
pub server: Option<ServerConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exec: Option<ExecConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prevent_idle_sleep: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub verbose: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub upgrade_check: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dry_run: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_approve: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub no_retro: Option<bool>,
// --- Server config fields ---
#[serde(default, alias = "data_dir", skip_serializing_if = "Option::is_none")]
pub storage_dir: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_concurrent_runs: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub artifact_storage: Option<fabro_types::ArtifactStorageSettings>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub web: Option<WebConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub slack: Option<SlackConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api: Option<ApiConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub features: Option<FeaturesConfig>,
// --- Shared fields ---
#[serde(default, skip_serializing_if = "Option::is_none")]
pub log: Option<LogConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub git: Option<GitConfig>,
// --- Project config fields ---
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fabro: Option<ProjectConfig>,
pub file: SettingsFile,
}
impl Combine for ConfigLayer {
fn combine(self, other: Self) -> Self {
let hooks = if self.hooks.is_empty() {
other.hooks
} else if other.hooks.is_empty() {
self.hooks
} else {
HookSettings { hooks: other.hooks }
.merge(HookSettings { hooks: self.hooks })
.hooks
};
impl From<SettingsFile> for ConfigLayer {
fn from(file: SettingsFile) -> Self {
Self { file }
}
}
Self {
version: self.version.combine(other.version),
goal: self.goal.combine(other.goal),
goal_file: self.goal_file.combine(other.goal_file),
graph: self.graph.combine(other.graph),
labels: self.labels.combine(other.labels),
work_dir: self.work_dir.combine(other.work_dir),
llm: self.llm.combine(other.llm),
setup: self.setup.combine(other.setup),
sandbox: self.sandbox.combine(other.sandbox),
vars: self.vars.combine(other.vars),
checkpoint: self.checkpoint.combine(other.checkpoint),
pull_request: self.pull_request.combine(other.pull_request),
artifacts: self.artifacts.combine(other.artifacts),
hooks,
mcp_servers: self.mcp_servers.combine(other.mcp_servers),
github: self.github.combine(other.github),
server: self.server.combine(other.server),
exec: self.exec.combine(other.exec),
prevent_idle_sleep: self.prevent_idle_sleep.combine(other.prevent_idle_sleep),
verbose: self.verbose.combine(other.verbose),
upgrade_check: self.upgrade_check.combine(other.upgrade_check),
dry_run: self.dry_run.combine(other.dry_run),
auto_approve: self.auto_approve.combine(other.auto_approve),
no_retro: self.no_retro.combine(other.no_retro),
storage_dir: self.storage_dir.combine(other.storage_dir),
max_concurrent_runs: self.max_concurrent_runs.combine(other.max_concurrent_runs),
artifact_storage: self.artifact_storage.combine(other.artifact_storage),
web: self.web.combine(other.web),
slack: self.slack.combine(other.slack),
api: self.api.combine(other.api),
features: self.features.combine(other.features),
log: self.log.combine(other.log),
git: self.git.combine(other.git),
fabro: self.fabro.combine(other.fabro),
}
impl From<ConfigLayer> for SettingsFile {
fn from(layer: ConfigLayer) -> Self {
layer.file
}
}
impl TryFrom<ConfigLayer> for Settings {
type Error = anyhow::Error;
fn try_from(value: ConfigLayer) -> Result<Self, Self::Error> {
Ok(value.resolve())
}
}
impl TryFrom<&ConfigLayer> for Settings {
type Error = anyhow::Error;
fn try_from(value: &ConfigLayer) -> Result<Self, Self::Error> {
Ok(value.clone().resolve())
}
}
impl ConfigLayer {
/// Combine two layers using the v2 merge matrix.
#[must_use]
pub fn combine(self, other: Self) -> Self {
Combine::combine(self, other)
// In the legacy contract `self.combine(other)` means `self` is the
// higher-precedence layer and `other` is the lower-precedence one.
// The merge matrix walker takes (lower, higher).
Self {
file: combine_files(other.file, self.file),
}
}
/// Parse a v2 TOML settings file into a layer.
pub fn parse(content: &str) -> anyhow::Result<Self> {
let file = parse_v2_settings_file(content)
.map_err(|e| anyhow::anyhow!("{e}"))
.context("Failed to parse settings file")?;
Ok(Self { file })
}
/// Load a v2 TOML settings file from disk.
pub fn load(path: &Path) -> anyhow::Result<Self> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read {}", path.display()))?;
Self::parse(&content)
}
/// 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).
/// (`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() {
@ -231,8 +129,89 @@ impl ConfigLayer {
user::load_settings_config(None)
}
/// Convert this combined config layer into final resolved settings.
pub fn resolve(self) -> anyhow::Result<Settings> {
self.try_into()
/// Convert this layer into the legacy flat [`Settings`] shape via the
/// temporary bridge. This path is removed in Stage 6.
#[must_use]
pub fn resolve(self) -> Settings {
bridge_to_old(&self.file)
}
/// Borrow the inner v2 settings file for direct access.
#[must_use]
pub fn as_v2(&self) -> &SettingsFile {
&self.file
}
/// Mutably borrow the inner v2 settings file.
pub fn as_v2_mut(&mut self) -> &mut SettingsFile {
&mut self.file
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_rejects_legacy_flat_keys() {
let err = ConfigLayer::parse("[llm]\nprovider = \"openai\"").unwrap_err();
let text = format!("{err:#}");
assert!(
text.contains("run.model") || text.contains("llm"),
"expected rename hint in error: {text}"
);
}
#[test]
fn parse_accepts_minimal_v2_file() {
let layer = ConfigLayer::parse(
r#"
_version = 1
[run]
goal = "Do things"
"#,
)
.unwrap();
assert_eq!(
layer
.file
.run
.as_ref()
.and_then(|r| r.goal.as_ref())
.map(fabro_types::settings::v2::InterpString::as_source)
.as_deref(),
Some("Do things")
);
}
#[test]
fn combine_prefers_higher_precedence_self() {
let higher = ConfigLayer::parse(
r#"
_version = 1
[run]
goal = "higher goal"
"#,
)
.unwrap();
let lower = ConfigLayer::parse(
r#"
_version = 1
[run]
goal = "lower goal"
"#,
)
.unwrap();
let merged = higher.combine(lower);
assert_eq!(
merged
.file
.run
.as_ref()
.and_then(|r| r.goal.as_ref())
.map(fabro_types::settings::v2::InterpString::as_source)
.as_deref(),
Some("higher goal")
);
}
}

View file

@ -1,5 +1,14 @@
//! Effective settings resolution: combine layers into one resolved [`Settings`].
//!
//! Shared layered domains (`project`, `workflow`, `run`, `features`) merge
//! across all three config files (settings.toml, fabro.toml, workflow.toml).
//! Owner-specific domains (`cli`, `server`) are consumed only from the local
//! `~/.fabro/settings.toml` plus explicit process-local overrides — their
//! stanzas in `fabro.toml` and `workflow.toml` remain schema-valid but inert.
use anyhow::{Result, anyhow};
use fabro_types::Settings;
use fabro_types::settings::v2::SettingsFile;
use crate::ConfigLayer;
@ -44,37 +53,41 @@ pub fn resolve_settings(
args,
mut workflow,
mut project,
mut user,
user,
} = layers;
match mode {
EffectiveSettingsMode::LocalOnly => args
EffectiveSettingsMode::LocalOnly => Ok(args
.combine(workflow)
.combine(project)
.combine(user)
.resolve(),
.resolve()),
EffectiveSettingsMode::RemoteServer | EffectiveSettingsMode::LocalDaemon => {
let server_settings = server_settings.ok_or_else(|| {
anyhow!("server settings are required for server-targeted settings resolution")
})?;
strip_server_owned_fields(&mut workflow);
strip_server_owned_fields(&mut project);
strip_server_owned_fields(&mut user);
strip_owner_domains(workflow.as_v2_mut());
strip_owner_domains(project.as_v2_mut());
let mut stripped_user = user;
strip_owner_domains(stripped_user.as_v2_mut());
let server_defaults = match mode {
EffectiveSettingsMode::RemoteServer => server_defaults_layer(server_settings)?,
EffectiveSettingsMode::LocalDaemon => {
local_daemon_server_overrides_layer(server_settings)?
}
EffectiveSettingsMode::LocalOnly => unreachable!(),
};
let server_defaults = server_defaults_layer(server_settings)?;
let mut settings = args
.combine(workflow)
.combine(project)
.combine(user)
.combine(server_defaults)
.resolve()?;
.combine(stripped_user)
.resolve();
match mode {
EffectiveSettingsMode::RemoteServer => {
apply_server_defaults(&mut settings, &server_defaults);
}
EffectiveSettingsMode::LocalDaemon => {
apply_local_daemon_overrides(&mut settings, &server_defaults);
}
EffectiveSettingsMode::LocalOnly => unreachable!(),
}
settings
.storage_dir
.clone_from(&server_settings.storage_dir);
@ -83,38 +96,66 @@ pub fn resolve_settings(
}
}
fn server_defaults_layer(settings: &Settings) -> Result<ConfigLayer> {
let mut layer: ConfigLayer = serde_json::from_value(serde_json::to_value(settings)?)?;
fn strip_owner_domains(file: &mut SettingsFile) {
file.cli = None;
file.server = None;
}
fn server_defaults_layer(settings: &Settings) -> Result<Settings> {
let mut out = settings.clone();
// Run manifests carry their own dry-run intent. Do not let a daemon's
// startup-time fallback mode silently force every submitted run/preflight
// into simulation.
layer.dry_run = None;
Ok(layer)
out.dry_run = None;
Ok(out)
}
fn local_daemon_server_overrides_layer(settings: &Settings) -> Result<ConfigLayer> {
let layer = server_defaults_layer(settings)?;
Ok(ConfigLayer {
storage_dir: layer.storage_dir,
max_concurrent_runs: layer.max_concurrent_runs,
artifact_storage: layer.artifact_storage,
web: layer.web,
api: layer.api,
features: layer.features,
..Default::default()
})
fn apply_server_defaults(settings: &mut Settings, server: &Settings) {
if settings.storage_dir.is_none() {
settings.storage_dir.clone_from(&server.storage_dir);
}
if settings.max_concurrent_runs.is_none() {
settings.max_concurrent_runs = server.max_concurrent_runs;
}
if settings.artifact_storage.is_none() {
settings
.artifact_storage
.clone_from(&server.artifact_storage);
}
if settings.web.is_none() {
settings.web.clone_from(&server.web);
}
if settings.api.is_none() {
settings.api.clone_from(&server.api);
}
if settings.features.is_none() {
settings.features.clone_from(&server.features);
}
if settings.log.is_none() {
settings.log.clone_from(&server.log);
}
if settings.git.is_none() {
settings.git.clone_from(&server.git);
}
if settings.vars.is_none() {
settings.vars.clone_from(&server.vars);
} else if let (Some(local), Some(server_vars)) = (settings.vars.as_mut(), server.vars.as_ref())
{
for (k, v) in server_vars {
local.entry(k.clone()).or_insert_with(|| v.clone());
}
}
}
fn strip_server_owned_fields(layer: &mut ConfigLayer) {
layer.server = None;
layer.exec = None;
layer.storage_dir = None;
layer.max_concurrent_runs = None;
layer.artifact_storage = None;
layer.web = None;
layer.api = None;
layer.features = None;
layer.log = None;
fn apply_local_daemon_overrides(settings: &mut Settings, server: &Settings) {
settings.storage_dir.clone_from(&server.storage_dir);
settings.max_concurrent_runs = server.max_concurrent_runs;
settings
.artifact_storage
.clone_from(&server.artifact_storage);
settings.web.clone_from(&server.web);
settings.api.clone_from(&server.api);
settings.features.clone_from(&server.features);
}
#[cfg(test)]
@ -125,7 +166,7 @@ mod tests {
use crate::ConfigLayer;
fn layer(source: &str) -> ConfigLayer {
toml::from_str(source).expect("config layer fixture should parse")
ConfigLayer::parse(source).expect("v2 fixture should parse")
}
#[test]
@ -136,22 +177,27 @@ mod tests {
ConfigLayer::default(),
layer(
r#"
[llm]
model = "project-model"
_version = 1
[vars]
[run.model]
name = "project-model"
[run.inputs]
project_only = "1"
shared = "project"
"#,
),
layer(
r#"
storage_dir = "/tmp/local-storage"
_version = 1
[llm]
[server.storage]
root = "/tmp/local-storage"
[run.model]
provider = "openai"
[vars]
[run.inputs]
user_only = "1"
shared = "user"
"#,
@ -164,66 +210,48 @@ shared = "user"
let llm = settings.llm.expect("llm config");
assert_eq!(llm.model.as_deref(), Some("project-model"));
assert_eq!(llm.provider.as_deref(), Some("openai"));
assert_eq!(
settings.storage_dir,
Some(PathBuf::from("/tmp/local-storage"))
);
assert_eq!(
settings
.vars
.as_ref()
.and_then(|vars| vars.get("project_only")),
Some(&"1".to_string())
);
assert_eq!(
settings
.vars
.as_ref()
.and_then(|vars| vars.get("user_only")),
Some(&"1".to_string())
);
assert_eq!(
settings.vars.as_ref().and_then(|vars| vars.get("shared")),
Some(&"project".to_string())
// Per R22, run.inputs replaces wholesale — the winning layer is the
// highest-precedence layer that sets `inputs` (project here, since it
// wins over user).
let vars = settings.vars.as_ref().unwrap();
assert_eq!(vars.get("project_only"), Some(&"1".to_string()));
assert_eq!(vars.get("shared"), Some(&"project".to_string()));
assert!(
vars.get("user_only").is_none(),
"project.inputs should replace user.inputs wholesale"
);
}
#[test]
fn local_only_merges_workflow_project_and_user_layers() {
fn local_only_merges_workflow_project_user() {
let settings = resolve_settings(
EffectiveSettingsLayers::new(
ConfigLayer::default(),
layer(
r#"
_version = 1
[run]
goal = "workflow goal"
[llm]
model = "workflow-model"
[vars]
workflow_only = "1"
shared = "workflow"
[run.model]
name = "workflow-model"
"#,
),
layer(
r#"
[llm]
model = "project-model"
_version = 1
[vars]
project_only = "1"
shared = "project"
[run.model]
name = "project-model"
"#,
),
layer(
r#"
[llm]
_version = 1
[run.model]
provider = "openai"
[vars]
user_only = "1"
shared = "user"
"#,
),
),
@ -232,197 +260,55 @@ shared = "user"
)
.unwrap();
let llm = settings.llm.expect("llm config");
assert_eq!(settings.goal.as_deref(), Some("workflow goal"));
assert_eq!(llm.model.as_deref(), Some("workflow-model"));
assert_eq!(llm.provider.as_deref(), Some("openai"));
assert_eq!(
settings
.vars
.as_ref()
.and_then(|vars| vars.get("workflow_only")),
Some(&"1".to_string())
);
assert_eq!(
settings
.vars
.as_ref()
.and_then(|vars| vars.get("project_only")),
Some(&"1".to_string())
);
assert_eq!(
settings
.vars
.as_ref()
.and_then(|vars| vars.get("user_only")),
Some(&"1".to_string())
);
assert_eq!(
settings.vars.as_ref().and_then(|vars| vars.get("shared")),
Some(&"workflow".to_string())
);
}
#[test]
fn remote_server_mode_merges_server_defaults_without_allowing_server_owned_local_overrides() {
let server_settings: fabro_types::Settings = toml::from_str(
r#"
storage_dir = "/srv/fabro"
max_concurrent_runs = 9
dry_run = true
[vars]
server_only = "1"
shared = "server"
"#,
)
.unwrap();
let settings = resolve_settings(
EffectiveSettingsLayers::new(
ConfigLayer::default(),
ConfigLayer::default(),
layer(
r#"
storage_dir = "/tmp/local-storage"
max_concurrent_runs = 3
[vars]
project_only = "1"
shared = "project"
"#,
),
ConfigLayer::default(),
),
Some(&server_settings),
EffectiveSettingsMode::RemoteServer,
)
.unwrap();
assert_eq!(settings.storage_dir, Some(PathBuf::from("/srv/fabro")));
assert_eq!(settings.max_concurrent_runs, Some(9));
assert_eq!(settings.dry_run, None);
assert_eq!(
settings
.vars
.as_ref()
.and_then(|vars| vars.get("server_only")),
Some(&"1".to_string())
);
assert_eq!(
settings
.vars
.as_ref()
.and_then(|vars| vars.get("project_only")),
Some(&"1".to_string())
);
assert_eq!(
settings.vars.as_ref().and_then(|vars| vars.get("shared")),
Some(&"project".to_string())
);
}
#[test]
fn remote_server_mode_merges_workflow_project_user_and_server_layers() {
let server_settings: fabro_types::Settings = toml::from_str(
r#"
storage_dir = "/srv/fabro"
[vars]
server_only = "1"
shared = "server"
"#,
)
.unwrap();
let settings = resolve_settings(
EffectiveSettingsLayers::new(
ConfigLayer::default(),
layer(
r#"
[llm]
model = "workflow-model"
[vars]
workflow_only = "1"
shared = "workflow"
"#,
),
layer(
r#"
[vars]
project_only = "1"
shared = "project"
"#,
),
layer(
r#"
[llm]
provider = "openai"
[vars]
user_only = "1"
"#,
),
),
Some(&server_settings),
EffectiveSettingsMode::RemoteServer,
)
.unwrap();
let llm = settings.llm.expect("llm config");
assert_eq!(llm.model.as_deref(), Some("workflow-model"));
assert_eq!(llm.provider.as_deref(), Some("openai"));
}
#[test]
fn cli_and_server_domains_from_fabro_toml_are_inert_under_remote_mode() {
let server_settings: fabro_types::Settings = fabro_types::Settings {
storage_dir: Some(PathBuf::from("/srv/fabro")),
max_concurrent_runs: Some(9),
..Default::default()
};
let project_with_server = layer(
r#"
_version = 1
[run]
goal = "project goal"
[server.storage]
root = "/tmp/should-be-inert"
"#,
);
let settings = resolve_settings(
EffectiveSettingsLayers::new(
ConfigLayer::default(),
ConfigLayer::default(),
project_with_server,
ConfigLayer::default(),
),
Some(&server_settings),
EffectiveSettingsMode::RemoteServer,
)
.unwrap();
assert_eq!(settings.storage_dir, Some(PathBuf::from("/srv/fabro")));
assert_eq!(
settings
.vars
.as_ref()
.and_then(|vars| vars.get("workflow_only")),
Some(&"1".to_string())
);
assert_eq!(
settings
.vars
.as_ref()
.and_then(|vars| vars.get("project_only")),
Some(&"1".to_string())
);
assert_eq!(
settings
.vars
.as_ref()
.and_then(|vars| vars.get("user_only")),
Some(&"1".to_string())
);
assert_eq!(
settings
.vars
.as_ref()
.and_then(|vars| vars.get("server_only")),
Some(&"1".to_string())
);
assert_eq!(
settings.vars.as_ref().and_then(|vars| vars.get("shared")),
Some(&"workflow".to_string())
);
assert_eq!(settings.goal.as_deref(), Some("project goal"));
}
#[test]
fn local_daemon_mode_only_applies_server_owned_overrides() {
let server_settings: fabro_types::Settings = toml::from_str(
r#"
storage_dir = "/srv/fabro"
max_concurrent_runs = 7
[llm]
model = "server-model"
[vars]
server_only = "1"
"#,
)
.unwrap();
let server_settings: fabro_types::Settings = fabro_types::Settings {
storage_dir: Some(PathBuf::from("/srv/fabro")),
max_concurrent_runs: Some(7),
..Default::default()
};
let settings = resolve_settings(
EffectiveSettingsLayers::default(),
@ -433,7 +319,5 @@ server_only = "1"
assert_eq!(settings.storage_dir, Some(PathBuf::from("/srv/fabro")));
assert_eq!(settings.max_concurrent_runs, Some(7));
assert_eq!(settings.llm, None);
assert_eq!(settings.vars, None);
}
}

View file

@ -7,6 +7,7 @@ pub mod home;
pub mod hook;
pub mod legacy_env;
pub mod mcp;
pub mod merge;
pub mod project;
pub mod run;
pub mod sandbox;

View file

@ -0,0 +1,669 @@
//! v2 merge matrix implementation.
//!
//! Encodes the normative merge behavior from the requirements doc: replace
//! scalars, field-merge structured tables, replace freeform maps by default,
//! sticky merge-by-key where the requirements call for it, splice-capable
//! string arrays, whole-list replacement for ordered prepare steps, and
//! ordered hook merging with optional `id` replacement.
use std::collections::HashMap;
use fabro_types::settings::v2::cli::{
CliExecAgentLayer, CliExecLayer, CliExecModelLayer, CliLayer, CliTargetLayer,
};
use fabro_types::settings::v2::project::ProjectLayer;
use fabro_types::settings::v2::run::{
DaytonaSandboxLayer, GitAuthorLayer, HookEntry, InterviewsLayer, ModelRefOrSplice,
NotificationRouteLayer, RunAgentLayer, RunCheckpointLayer, RunExecutionLayer, RunGitLayer,
RunLayer, RunModelLayer, RunPrepareLayer, RunPullRequestLayer, RunSandboxLayer, RunScmLayer,
StringOrSplice,
};
use fabro_types::settings::v2::server::{
ServerArtifactsLayer, ServerAuthLayer, ServerIntegrationsLayer, ServerLayer, ServerListenLayer,
ServerSchedulerLayer, ServerSlateDbLayer, ServerStorageLayer, ServerWebLayer,
};
use fabro_types::settings::v2::tree::SettingsFile;
use fabro_types::settings::v2::workflow::WorkflowLayer;
/// Combine two settings files: `higher` takes precedence over `lower` wherever
/// the merge matrix does not dictate otherwise.
#[must_use]
pub fn combine_files(lower: SettingsFile, higher: SettingsFile) -> SettingsFile {
SettingsFile {
version: higher.version.or(lower.version),
project: merge_option(lower.project, higher.project, combine_project),
workflow: merge_option(lower.workflow, higher.workflow, combine_workflow),
run: merge_option(lower.run, higher.run, combine_run),
cli: merge_option(lower.cli, higher.cli, combine_cli),
server: merge_option(lower.server, higher.server, combine_server),
features: replace_if_some(lower.features, higher.features),
}
}
fn merge_option<T>(lower: Option<T>, higher: Option<T>, f: fn(T, T) -> T) -> Option<T> {
match (lower, higher) {
(Some(l), Some(h)) => Some(f(l, h)),
(Some(l), None) => Some(l),
(None, Some(h)) => Some(h),
(None, None) => None,
}
}
fn replace_if_some<T>(lower: Option<T>, higher: Option<T>) -> Option<T> {
higher.or(lower)
}
fn merge_string_map_replace(
lower: HashMap<String, String>,
higher: HashMap<String, String>,
) -> HashMap<String, String> {
if higher.is_empty() { lower } else { higher }
}
fn merge_string_map_sticky<T>(
mut lower: HashMap<String, T>,
higher: HashMap<String, T>,
) -> HashMap<String, T> {
for (k, v) in higher {
lower.insert(k, v);
}
lower
}
// ------------------- project -------------------
fn combine_project(lower: ProjectLayer, higher: ProjectLayer) -> ProjectLayer {
ProjectLayer {
name: higher.name.or(lower.name),
description: higher.description.or(lower.description),
directory: higher.directory.or(lower.directory),
metadata: merge_string_map_replace(lower.metadata, higher.metadata),
}
}
// ------------------- workflow -------------------
fn combine_workflow(lower: WorkflowLayer, higher: WorkflowLayer) -> WorkflowLayer {
WorkflowLayer {
name: higher.name.or(lower.name),
description: higher.description.or(lower.description),
graph: higher.graph.or(lower.graph),
metadata: merge_string_map_replace(lower.metadata, higher.metadata),
}
}
// ------------------- run -------------------
fn combine_run(lower: RunLayer, higher: RunLayer) -> RunLayer {
RunLayer {
goal: higher.goal.or(lower.goal),
working_dir: higher.working_dir.or(lower.working_dir),
metadata: merge_string_map_replace(lower.metadata, higher.metadata),
inputs: higher.inputs.or(lower.inputs),
model: merge_option(lower.model, higher.model, combine_run_model),
git: merge_option(lower.git, higher.git, combine_run_git),
prepare: merge_option(lower.prepare, higher.prepare, combine_run_prepare),
execution: merge_option(lower.execution, higher.execution, combine_run_execution),
checkpoint: merge_option(lower.checkpoint, higher.checkpoint, combine_run_checkpoint),
sandbox: merge_option(lower.sandbox, higher.sandbox, combine_run_sandbox),
notifications: combine_notifications(lower.notifications, higher.notifications),
interviews: merge_option(lower.interviews, higher.interviews, combine_interviews),
agent: merge_option(lower.agent, higher.agent, combine_run_agent),
hooks: combine_hooks(lower.hooks, higher.hooks),
scm: merge_option(lower.scm, higher.scm, combine_run_scm),
pull_request: merge_option(lower.pull_request, higher.pull_request, combine_run_pr),
artifacts: replace_if_some(lower.artifacts, higher.artifacts),
}
}
fn combine_run_model(lower: RunModelLayer, higher: RunModelLayer) -> RunModelLayer {
RunModelLayer {
provider: higher.provider.or(lower.provider),
name: higher.name.or(lower.name),
fallbacks: splice_model_fallbacks(lower.fallbacks, higher.fallbacks),
}
}
fn splice_model_fallbacks(
lower: Vec<ModelRefOrSplice>,
higher: Vec<ModelRefOrSplice>,
) -> Vec<ModelRefOrSplice> {
if higher.is_empty() {
return lower;
}
let splice_pos = higher
.iter()
.position(|e| matches!(e, ModelRefOrSplice::Splice));
let Some(pos) = splice_pos else {
return higher;
};
let mut out = Vec::new();
for (i, entry) in higher.into_iter().enumerate() {
if i == pos {
out.extend(
lower
.iter()
.filter(|e| !matches!(e, ModelRefOrSplice::Splice))
.cloned(),
);
} else if !matches!(entry, ModelRefOrSplice::Splice) {
out.push(entry);
}
}
out
}
fn combine_run_git(lower: RunGitLayer, higher: RunGitLayer) -> RunGitLayer {
RunGitLayer {
author: merge_option(lower.author, higher.author, combine_git_author),
}
}
fn combine_git_author(lower: GitAuthorLayer, higher: GitAuthorLayer) -> GitAuthorLayer {
GitAuthorLayer {
name: higher.name.or(lower.name),
email: higher.email.or(lower.email),
}
}
fn combine_run_prepare(_lower: RunPrepareLayer, higher: RunPrepareLayer) -> RunPrepareLayer {
// Whole-list replacement for prepare.steps per the merge matrix.
higher
}
fn combine_run_execution(lower: RunExecutionLayer, higher: RunExecutionLayer) -> RunExecutionLayer {
RunExecutionLayer {
mode: higher.mode.or(lower.mode),
approval: higher.approval.or(lower.approval),
retros: higher.retros.or(lower.retros),
}
}
fn combine_run_checkpoint(
lower: RunCheckpointLayer,
higher: RunCheckpointLayer,
) -> RunCheckpointLayer {
// Exclude globs are a security/policy list: replace by default.
if higher.exclude_globs.is_empty() {
lower
} else {
higher
}
}
fn combine_run_sandbox(lower: RunSandboxLayer, higher: RunSandboxLayer) -> RunSandboxLayer {
RunSandboxLayer {
provider: higher.provider.or(lower.provider),
preserve: higher.preserve.or(lower.preserve),
devcontainer: higher.devcontainer.or(lower.devcontainer),
// Sticky merge-by-key for run.sandbox.env per R71.
env: merge_string_map_sticky(lower.env, higher.env),
local: higher.local.or(lower.local),
daytona: merge_option(lower.daytona, higher.daytona, combine_daytona),
}
}
fn combine_daytona(lower: DaytonaSandboxLayer, higher: DaytonaSandboxLayer) -> DaytonaSandboxLayer {
DaytonaSandboxLayer {
auto_stop_interval: higher.auto_stop_interval.or(lower.auto_stop_interval),
// Sticky merge-by-key for provider-native labels per R71.
labels: merge_string_map_sticky(lower.labels, higher.labels),
snapshot: higher.snapshot.or(lower.snapshot),
network: higher.network.or(lower.network),
skip_clone: higher.skip_clone.or(lower.skip_clone),
}
}
fn combine_notifications(
mut lower: HashMap<String, NotificationRouteLayer>,
higher: HashMap<String, NotificationRouteLayer>,
) -> HashMap<String, NotificationRouteLayer> {
for (k, h) in higher {
match lower.remove(&k) {
Some(l) => {
lower.insert(k, combine_notification_route(l, h));
}
None => {
lower.insert(k, h);
}
}
}
lower
}
fn combine_notification_route(
lower: NotificationRouteLayer,
higher: NotificationRouteLayer,
) -> NotificationRouteLayer {
NotificationRouteLayer {
enabled: higher.enabled.or(lower.enabled),
provider: higher.provider.or(lower.provider),
events: splice_events(lower.events, higher.events),
slack: higher.slack.or(lower.slack),
discord: higher.discord.or(lower.discord),
teams: higher.teams.or(lower.teams),
}
}
fn splice_events(lower: Vec<StringOrSplice>, higher: Vec<StringOrSplice>) -> Vec<StringOrSplice> {
if higher.is_empty() {
return lower;
}
let splice_pos = higher
.iter()
.position(|e| matches!(e, StringOrSplice::Splice));
let Some(pos) = splice_pos else {
return higher;
};
let mut out = Vec::new();
for (i, entry) in higher.into_iter().enumerate() {
if i == pos {
out.extend(
lower
.iter()
.filter(|e| !matches!(e, StringOrSplice::Splice))
.cloned(),
);
} else if !matches!(entry, StringOrSplice::Splice) {
out.push(entry);
}
}
out
}
fn combine_interviews(lower: InterviewsLayer, higher: InterviewsLayer) -> InterviewsLayer {
InterviewsLayer {
provider: higher.provider.or(lower.provider),
slack: higher.slack.or(lower.slack),
discord: higher.discord.or(lower.discord),
teams: higher.teams.or(lower.teams),
}
}
fn combine_run_agent(lower: RunAgentLayer, higher: RunAgentLayer) -> RunAgentLayer {
RunAgentLayer {
permissions: higher.permissions.or(lower.permissions),
// MCP entries: field-merge per key. Higher replaces lower for same keys.
mcps: merge_string_map_sticky(lower.mcps, higher.mcps),
}
}
/// Merge two ordered hook lists using the id-aware replacement rule.
fn combine_hooks(lower: Vec<HookEntry>, higher: Vec<HookEntry>) -> Vec<HookEntry> {
let mut out: Vec<HookEntry> = Vec::with_capacity(lower.len() + higher.len());
let mut appended_ids: Vec<String> = Vec::new();
for lower_entry in &lower {
if let Some(id) = &lower_entry.id {
if let Some(replacement) = higher.iter().find(|h| h.id.as_deref() == Some(id.as_str()))
{
out.push(replacement.clone());
appended_ids.push(id.clone());
continue;
}
}
out.push(lower_entry.clone());
}
for higher_entry in higher {
if let Some(id) = &higher_entry.id {
if appended_ids.contains(id) {
continue;
}
}
out.push(higher_entry);
}
out
}
fn combine_run_scm(lower: RunScmLayer, higher: RunScmLayer) -> RunScmLayer {
RunScmLayer {
provider: higher.provider.or(lower.provider),
owner: higher.owner.or(lower.owner),
repository: higher.repository.or(lower.repository),
github: higher.github.or(lower.github),
}
}
fn combine_run_pr(lower: RunPullRequestLayer, higher: RunPullRequestLayer) -> RunPullRequestLayer {
RunPullRequestLayer {
enabled: higher.enabled.or(lower.enabled),
draft: higher.draft.or(lower.draft),
auto_merge: higher.auto_merge.or(lower.auto_merge),
merge_strategy: higher.merge_strategy.or(lower.merge_strategy),
}
}
// ------------------- cli -------------------
fn combine_cli(lower: CliLayer, higher: CliLayer) -> CliLayer {
CliLayer {
target: merge_option(lower.target, higher.target, combine_cli_target),
auth: higher.auth.or(lower.auth),
exec: merge_option(lower.exec, higher.exec, combine_cli_exec),
output: higher.output.or(lower.output),
updates: higher.updates.or(lower.updates),
logging: higher.logging.or(lower.logging),
}
}
fn combine_cli_target(_lower: CliTargetLayer, higher: CliTargetLayer) -> CliTargetLayer {
// The transport type is a scalar discriminant: the higher layer's choice wins.
higher
}
fn combine_cli_exec(lower: CliExecLayer, higher: CliExecLayer) -> CliExecLayer {
CliExecLayer {
prevent_idle_sleep: higher.prevent_idle_sleep.or(lower.prevent_idle_sleep),
model: merge_option(lower.model, higher.model, combine_cli_exec_model),
agent: merge_option(lower.agent, higher.agent, combine_cli_exec_agent),
}
}
fn combine_cli_exec_model(
lower: CliExecModelLayer,
higher: CliExecModelLayer,
) -> CliExecModelLayer {
CliExecModelLayer {
provider: higher.provider.or(lower.provider),
name: higher.name.or(lower.name),
}
}
fn combine_cli_exec_agent(
lower: CliExecAgentLayer,
higher: CliExecAgentLayer,
) -> CliExecAgentLayer {
CliExecAgentLayer {
permissions: higher.permissions.or(lower.permissions),
mcps: merge_string_map_sticky(lower.mcps, higher.mcps),
}
}
// ------------------- server -------------------
fn combine_server(lower: ServerLayer, higher: ServerLayer) -> ServerLayer {
ServerLayer {
listen: merge_option(lower.listen, higher.listen, combine_listen),
api: higher.api.or(lower.api),
web: merge_option(lower.web, higher.web, combine_server_web),
auth: merge_option(lower.auth, higher.auth, combine_server_auth),
storage: merge_option(lower.storage, higher.storage, combine_server_storage),
artifacts: merge_option(lower.artifacts, higher.artifacts, combine_server_artifacts),
slatedb: merge_option(lower.slatedb, higher.slatedb, combine_server_slatedb),
scheduler: merge_option(lower.scheduler, higher.scheduler, combine_server_scheduler),
logging: higher.logging.or(lower.logging),
integrations: merge_option(
lower.integrations,
higher.integrations,
combine_server_integrations,
),
}
}
fn combine_listen(_lower: ServerListenLayer, higher: ServerListenLayer) -> ServerListenLayer {
// Transport type is a scalar discriminant: replace whole.
higher
}
fn combine_server_web(lower: ServerWebLayer, higher: ServerWebLayer) -> ServerWebLayer {
ServerWebLayer {
enabled: higher.enabled.or(lower.enabled),
url: higher.url.or(lower.url),
}
}
fn combine_server_auth(lower: ServerAuthLayer, higher: ServerAuthLayer) -> ServerAuthLayer {
ServerAuthLayer {
api: higher.api.or(lower.api),
web: higher.web.or(lower.web),
}
}
fn combine_server_storage(
lower: ServerStorageLayer,
higher: ServerStorageLayer,
) -> ServerStorageLayer {
ServerStorageLayer {
root: higher.root.or(lower.root),
}
}
fn combine_server_artifacts(
lower: ServerArtifactsLayer,
higher: ServerArtifactsLayer,
) -> ServerArtifactsLayer {
ServerArtifactsLayer {
provider: higher.provider.or(lower.provider),
prefix: higher.prefix.or(lower.prefix),
local: higher.local.or(lower.local),
s3: higher.s3.or(lower.s3),
}
}
fn combine_server_slatedb(
lower: ServerSlateDbLayer,
higher: ServerSlateDbLayer,
) -> ServerSlateDbLayer {
ServerSlateDbLayer {
provider: higher.provider.or(lower.provider),
prefix: higher.prefix.or(lower.prefix),
flush_interval: higher.flush_interval.or(lower.flush_interval),
local: higher.local.or(lower.local),
s3: higher.s3.or(lower.s3),
}
}
fn combine_server_scheduler(
lower: ServerSchedulerLayer,
higher: ServerSchedulerLayer,
) -> ServerSchedulerLayer {
ServerSchedulerLayer {
max_concurrent_runs: higher.max_concurrent_runs.or(lower.max_concurrent_runs),
}
}
fn combine_server_integrations(
lower: ServerIntegrationsLayer,
higher: ServerIntegrationsLayer,
) -> ServerIntegrationsLayer {
ServerIntegrationsLayer {
github: higher.github.or(lower.github),
slack: higher.slack.or(lower.slack),
discord: higher.discord.or(lower.discord),
teams: higher.teams.or(lower.teams),
}
}
#[cfg(test)]
mod tests {
use super::*;
use fabro_types::settings::v2::parse_settings_file;
fn parse(input: &str) -> SettingsFile {
parse_settings_file(input).expect("fixture should parse")
}
#[test]
fn run_inputs_replace_wholesale() {
let lower = parse(
r#"
[run.inputs]
a = "lower"
b = "lower"
"#,
);
let higher = parse(
r#"
[run.inputs]
a = "higher"
"#,
);
let merged = combine_files(lower, higher);
let inputs = merged.run.unwrap().inputs.unwrap();
assert_eq!(inputs.len(), 1);
assert_eq!(inputs.get("a"), Some(&toml::Value::String("higher".into())));
assert!(inputs.get("b").is_none(), "lower key should be gone");
}
#[test]
fn run_sandbox_env_merges_sticky() {
let lower = parse(
r#"
[run.sandbox.env]
A = "lower-a"
B = "lower-b"
"#,
);
let higher = parse(
r#"
[run.sandbox.env]
A = "higher-a"
C = "higher-c"
"#,
);
let merged = combine_files(lower, higher);
let sandbox = merged.run.unwrap().sandbox.unwrap();
assert_eq!(sandbox.env.len(), 3);
}
#[test]
fn run_prepare_steps_replaces_whole_list() {
let lower = parse(
r#"
[[run.prepare.steps]]
script = "lower-1"
[[run.prepare.steps]]
script = "lower-2"
"#,
);
let higher = parse(
r#"
[[run.prepare.steps]]
script = "higher-1"
"#,
);
let merged = combine_files(lower, higher);
let steps = merged.run.unwrap().prepare.unwrap().steps;
assert_eq!(steps.len(), 1);
}
#[test]
fn run_model_fallbacks_splice_inserts_inherited() {
let lower = parse(
r#"
[run.model]
fallbacks = ["openai", "gpt-5.4"]
"#,
);
let higher = parse(
r#"
[run.model]
fallbacks = ["anthropic", "..."]
"#,
);
let merged = combine_files(lower, higher);
let fallbacks = merged.run.unwrap().model.unwrap().fallbacks;
// ["anthropic", "openai", "gpt-5.4"]
assert_eq!(fallbacks.len(), 3);
}
#[test]
fn hooks_replace_by_id_in_place() {
let lower = parse(
r#"
[[run.hooks]]
id = "shared"
event = "run_start"
script = "lower-script"
"#,
);
let higher = parse(
r#"
[[run.hooks]]
id = "shared"
event = "run_start"
script = "higher-script"
"#,
);
let merged = combine_files(lower, higher);
let hooks = merged.run.unwrap().hooks;
assert_eq!(hooks.len(), 1);
assert_eq!(
hooks[0].script.as_ref().map(|s| s.as_source()).as_deref(),
Some("higher-script")
);
}
#[test]
fn anonymous_hooks_append_after_merged_inherited() {
let lower = parse(
r#"
[[run.hooks]]
event = "run_start"
script = "lower-anon"
"#,
);
let higher = parse(
r#"
[[run.hooks]]
event = "run_complete"
script = "higher-anon"
"#,
);
let merged = combine_files(lower, higher);
let hooks = merged.run.unwrap().hooks;
assert_eq!(hooks.len(), 2);
assert_eq!(
hooks[0].script.as_ref().map(|s| s.as_source()).as_deref(),
Some("lower-anon")
);
assert_eq!(
hooks[1].script.as_ref().map(|s| s.as_source()).as_deref(),
Some("higher-anon")
);
}
#[test]
fn notification_route_events_splice() {
let lower = parse(
r#"
[run.notifications.ops]
events = ["run.failed"]
"#,
);
let higher = parse(
r#"
[run.notifications.ops]
events = ["...", "run.completed"]
"#,
);
let merged = combine_files(lower, higher);
let run = merged.run.unwrap();
let events = &run.notifications.get("ops").unwrap().events;
assert_eq!(events.len(), 2);
}
#[test]
fn project_metadata_replaces_wholesale() {
let lower = parse(
r#"
[project.metadata]
a = "1"
b = "2"
"#,
);
let higher = parse(
r#"
[project.metadata]
a = "replaced"
"#,
);
let merged = combine_files(lower, higher);
let meta = merged.project.unwrap().metadata;
assert_eq!(meta.len(), 1);
assert_eq!(meta.get("a"), Some(&"replaced".to_string()));
}
}

View file

@ -1,8 +1,14 @@
//! Project-level config loading and workflow discovery.
//!
//! Stage 3 replaced the parse-time `ProjectConfig` type with the v2 parse
//! tree in `fabro_types::settings::v2`. This module keeps the workflow
//! discovery helpers and re-exports resolved project settings.
use std::fmt::Write;
use std::path::{Path, PathBuf};
use anyhow::{Context, bail};
use serde::{Deserialize, Serialize};
use serde::Serialize;
use crate::config::ConfigLayer;
use crate::run;
@ -10,13 +16,8 @@ use fabro_types::Settings;
pub use fabro_types::settings::project::ProjectSettings;
const CONFIG_FILENAME: &str = "fabro.toml";
const SUPPORTED_VERSION: u32 = 1;
const RUN_GRAPH_FILE: &str = "workflow.fabro";
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
pub struct ProjectConfig {
pub root: Option<String>,
}
const DEFAULT_FABRO_DIRECTORY: &str = "fabro/";
#[derive(Clone, Debug)]
pub struct WorkflowPathResolution {
@ -27,28 +28,9 @@ pub struct WorkflowPathResolution {
pub workflow_slug: Option<String>,
}
fn default_root() -> String {
".".to_string()
}
impl From<ProjectConfig> for ProjectSettings {
fn from(value: ProjectConfig) -> Self {
Self {
root: value.root.unwrap_or_else(default_root),
}
}
}
/// Parse a project config from a TOML string.
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!(
"Unsupported project config version: {version}. Only version {SUPPORTED_VERSION} is supported.",
);
}
Ok(config)
ConfigLayer::parse(content).context("Failed to parse project config")
}
/// Load a project config from a file path.
@ -57,10 +39,11 @@ pub fn load_project_config(path: &Path) -> anyhow::Result<ConfigLayer> {
.with_context(|| format!("Failed to read {}", path.display()))?;
let config = parse_project_config(&content)?;
let root = config
.fabro
.as_v2()
.project
.as_ref()
.and_then(|f| f.root.as_deref())
.unwrap_or(".");
.and_then(|p| p.directory.as_deref())
.unwrap_or(DEFAULT_FABRO_DIRECTORY);
tracing::debug!(path = %path.display(), root = %root, "Loaded project config");
Ok(config)
}
@ -98,11 +81,6 @@ fn workflow_slug_from_path(workflow_path: &Path) -> Option<String> {
}
/// 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)
@ -117,8 +95,13 @@ pub fn resolve_workflow_path(
if path.extension().is_some_and(|ext| ext == "toml") {
match run::load_run_config(&path) {
Ok(cfg) => {
let dot_path =
run::resolve_graph_path(&path, cfg.graph.as_deref().unwrap_or(RUN_GRAPH_FILE));
let graph = cfg
.as_v2()
.workflow
.as_ref()
.and_then(|w| w.graph.as_deref())
.unwrap_or(RUN_GRAPH_FILE);
let dot_path = run::resolve_graph_path(&path, graph);
Ok(WorkflowPathResolution {
resolved_workflow_path: path.clone(),
dot_path,
@ -275,11 +258,16 @@ fn list_workflows_in(workflows_dir: &Path) -> Vec<String> {
.collect()
}
/// Read the `goal` field from a `workflow.toml` without full config validation.
/// Read the `run.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)
table
.get("run")?
.as_table()?
.get("goal")?
.as_str()
.map(String::from)
}
/// List workflows with metadata by scanning project and user workflow directories.
@ -320,7 +308,6 @@ pub fn list_workflows_detailed(
}
/// 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>,
@ -353,9 +340,6 @@ fn find_closest_match(input: &str, candidates: &[String]) -> Option<String> {
}
/// 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<ConfigLayer>)> {
let start = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let resolution = resolve_workflow_path(arg, &start)?;
@ -363,147 +347,114 @@ pub fn resolve_workflow(arg: &Path) -> anyhow::Result<(PathBuf, Option<ConfigLay
}
/// 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`.
/// Retros are now expressed as `[run.execution] retros = true` in v2.
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
.as_v2()
.run
.as_ref()
.and_then(|f| f.retros)
.and_then(|r| r.execution.as_ref())
.and_then(|e| e.retros)
.unwrap_or(false),
_ => 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.
/// The returned path is the directory containing `fabro.toml` joined with the
/// `project.directory` value (default: `fabro/`).
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");
let root = config
.fabro
.as_v2()
.project
.as_ref()
.and_then(|f| f.root.as_deref())
.unwrap_or(".");
.and_then(|p| p.directory.as_deref())
.unwrap_or(DEFAULT_FABRO_DIRECTORY);
project_dir.join(root)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::run::{LlmConfig, PullRequestConfig};
use std::fs;
use tempfile::TempDir;
#[test]
fn parse_minimal_config() {
let config = parse_project_config("version = 1\n").unwrap();
assert_eq!(config.version, Some(1));
assert_eq!(config.fabro, None,);
let config = parse_project_config("_version = 1\n").unwrap();
assert_eq!(config.as_v2().version, Some(1));
assert!(config.as_v2().project.is_none());
}
#[test]
fn parse_full_config() {
let config = parse_project_config("version = 1\n[fabro]\nroot = \"fabro/\"\n").unwrap();
assert_eq!(config.fabro.unwrap().root.as_deref(), Some("fabro/"));
}
fn parse_with_project_directory() {
let config = parse_project_config(
r#"
_version = 1
#[test]
fn parse_retros_default_false() {
let config = parse_project_config("version = 1\n").unwrap();
assert!(
!config
.features
[project]
directory = "fabro/"
"#,
)
.unwrap();
assert_eq!(
config
.as_v2()
.project
.as_ref()
.and_then(|f| f.retros)
.unwrap_or(false)
.and_then(|p| p.directory.as_deref()),
Some("fabro/")
);
}
#[test]
fn parse_retros_enabled() {
let config = parse_project_config("version = 1\n[features]\nretros = true\n").unwrap();
assert_eq!(config.features.unwrap().retros, Some(true));
fn parse_with_run_execution_retros() {
let config = parse_project_config(
r#"
_version = 1
[run.execution]
retros = true
"#,
)
.unwrap();
assert_eq!(
config
.as_v2()
.run
.as_ref()
.and_then(|r| r.execution.as_ref())
.and_then(|e| e.retros),
Some(true)
);
}
#[test]
fn parse_version_mismatch() {
let err = parse_project_config("version = 2\n").unwrap_err();
fn parse_rejects_legacy_llm_section() {
let err = parse_project_config("_version = 1\n[llm]\nprovider = \"openai\"\n").unwrap_err();
let text = format!("{err:#}");
assert!(
err.to_string().contains("Unsupported"),
"Expected 'Unsupported' in error, got: {err}"
text.contains("run.model") || text.contains("llm"),
"expected rename hint for [llm]: {text}"
);
}
#[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: Some(true),
draft: Some(false),
auto_merge: None,
merge_strategy: None,
})
);
}
#[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.as_deref(), Some("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")
fn parse_higher_version_errors() {
let err = parse_project_config("_version = 2\n").unwrap_err();
let chain: String = err
.chain()
.map(|e| e.to_string())
.collect::<Vec<_>>()
.join("; ");
assert!(
chain.contains("Upgrade") || chain.to_lowercase().contains("version"),
"Expected version hint in chain: {chain}"
);
}
@ -511,224 +462,20 @@ model = "claude-sonnet-4-6"
fn load_from_disk() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("fabro.toml");
fs::write(&path, "version = 1\n").unwrap();
fs::write(&path, "_version = 1\n").unwrap();
let config = load_project_config(&path).unwrap();
assert_eq!(config.version, Some(1));
assert_eq!(config.as_v2().version, Some(1));
}
#[test]
fn discover_walks_ancestors() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("fabro.toml"), "version = 1\n").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, Some(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 = ConfigLayer {
version: Some(1),
fabro: Some(ProjectConfig {
root: Some("fabro/".to_string()),
}),
..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 = ConfigLayer {
version: Some(1),
fabro: Some(ProjectConfig {
root: Some(".".to_string()),
}),
..Default::default()
};
assert_eq!(
resolve_fabro_root(config_path, &config),
Path::new("/repo/.")
);
}
#[test]
fn resolve_fabro_root_without_fabro_section() {
let config_path = Path::new("/repo/fabro.toml");
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(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_resolves_relative_to_start_dir() {
let tmp = TempDir::new().unwrap();
let result = resolve_workflow_arg_from(Path::new("my-workflow.toml"), tmp.path()).unwrap();
assert_eq!(result, tmp.path().join("my-workflow.toml"));
}
#[test]
fn resolve_workflow_arg_fabro_extension_resolves_relative_to_start_dir() {
let tmp = TempDir::new().unwrap();
let result = resolve_workflow_arg_from(Path::new("my-workflow.fabro"), tmp.path()).unwrap();
assert_eq!(result, tmp.path().join("my-workflow.fabro"));
}
#[test]
fn resolve_workflow_arg_absolute_extension_preserves_absolute_path() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("my-workflow.toml");
let result = resolve_workflow_arg_from(&path, Path::new("/tmp")).unwrap();
assert_eq!(result, path);
}
#[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");
assert_eq!(config.as_v2().version, Some(1));
}
}

View file

@ -1,161 +1,28 @@
//! Re-export shim for run-side settings types.
//!
//! Stage 3 replaced the parse-time types previously defined here with the
//! v2 parse tree in `fabro_types::settings::v2`. This module stays alive as
//! a pass-through for crates that still import resolved run types via the
//! legacy `fabro_config::run` path; Stage 6 deletes it.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use anyhow::{Context, bail};
use serde::{Deserialize, Serialize};
use tracing::debug;
use anyhow::Context;
use crate::combine::Combine;
use crate::config::ConfigLayer;
use crate::sandbox::DockerfileSource;
pub use fabro_types::settings::run::{
ArtifactsSettings, CheckpointSettings, GitHubSettings, LlmSettings, MergeStrategy,
PullRequestSettings, SetupSettings,
};
const SUPPORTED_VERSION: u32 = 1;
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
pub struct CheckpointConfig {
#[serde(default)]
pub exclude_globs: Vec<String>,
}
impl Combine for CheckpointConfig {
fn combine(mut self, other: Self) -> Self {
self.exclude_globs.extend(other.exclude_globs);
self.exclude_globs.sort();
self.exclude_globs.dedup();
self
}
}
impl From<CheckpointConfig> for CheckpointSettings {
fn from(value: CheckpointConfig) -> Self {
let mut exclude_globs = value.exclude_globs;
exclude_globs.sort();
exclude_globs.dedup();
Self { exclude_globs }
}
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
pub struct PullRequestConfig {
pub enabled: Option<bool>,
pub draft: Option<bool>,
pub auto_merge: Option<bool>,
pub merge_strategy: Option<MergeStrategy>,
}
impl From<PullRequestConfig> for PullRequestSettings {
fn from(value: PullRequestConfig) -> Self {
Self {
enabled: value.enabled.unwrap_or(false),
draft: value.draft.unwrap_or(true),
auto_merge: value.auto_merge.unwrap_or(false),
merge_strategy: value.merge_strategy.unwrap_or_default(),
}
}
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
pub struct ArtifactsConfig {
#[serde(default)]
pub include: Vec<String>,
}
impl From<ArtifactsConfig> for ArtifactsSettings {
fn from(value: ArtifactsConfig) -> Self {
Self {
include: value.include,
}
}
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
pub struct GitHubConfig {
#[serde(default)]
pub permissions: HashMap<String, String>,
}
impl From<GitHubConfig> for GitHubSettings {
fn from(value: GitHubConfig) -> Self {
Self {
permissions: value.permissions,
}
}
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
pub struct LlmConfig {
pub model: Option<String>,
pub provider: Option<String>,
#[serde(default)]
pub fallbacks: Option<HashMap<String, Vec<String>>>,
}
impl From<LlmConfig> for LlmSettings {
fn from(value: LlmConfig) -> Self {
Self {
model: value.model,
provider: value.provider,
fallbacks: value.fallbacks,
}
}
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
pub struct SetupConfig {
#[serde(default)]
pub commands: Vec<String>,
pub timeout_ms: Option<u64>,
}
impl From<SetupConfig> for SetupSettings {
fn from(value: SetupConfig) -> Self {
Self {
commands: value.commands,
timeout_ms: value.timeout_ms,
}
}
}
/// Load and validate a run config from a TOML file.
/// Expand `${env.NAME}` whole-value references inside a string map.
///
/// 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.
///
/// `${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<ConfigLayer> {
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)?;
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.
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)?;
}
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.
/// Leaves entries that don't match the whole-value form untouched. Missing
/// host variables produce an error. This is the minimal resolver legacy
/// consumers still call while they are being migrated off `Settings`; the
/// full v2 interpolation pass lives in `fabro_types::settings::v2::interp`.
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
@ -170,54 +37,26 @@ pub fn resolve_env_refs(env: &mut HashMap<String, String>) -> anyhow::Result<()>
Ok(())
}
/// If the config contains a `dockerfile = { path = "..." }`, read the file
/// and replace it with `DockerfileSource::Inline(contents)`.
fn resolve_dockerfile(config: &mut ConfigLayer, 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)
}
}
/// Load and parse a run config from a TOML file.
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() {
config.graph = Some("workflow.fabro".to_string());
}
let version = config.version.unwrap_or(0);
if version != SUPPORTED_VERSION {
bail!(
"Unsupported run config version {version}. Only version {SUPPORTED_VERSION} is supported.",
);
}
Ok(config)
ConfigLayer::parse(contents).context("Failed to parse run config TOML")
}
/// Load and parse a run config from a TOML file.
///
/// Returns the v2-backed `ConfigLayer`.
pub fn load_run_config(path: &Path) -> anyhow::Result<ConfigLayer> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read {}", path.display()))?;
ConfigLayer::parse(&content)
.with_context(|| format!("Failed to parse workflow config at {}", path.display()))
}
/// Resolve a graph path relative to a workflow.toml.
#[must_use]
pub fn resolve_graph_path(workflow_toml: &Path, graph_relative: &str) -> PathBuf {
workflow_toml
.parent()
.unwrap_or_else(|| Path::new("."))
.join(graph_relative)
}

View file

@ -1,98 +1,10 @@
use std::collections::HashMap;
use anyhow::anyhow;
use serde::{Deserialize, Serialize};
//! Re-export shim for sandbox settings types.
//!
//! Stage 3 removed the parse-time `SandboxConfig`/`DaytonaConfig` types;
//! callers that still import resolved sandbox types via this module use the
//! re-exports below. Stage 6 deletes this file.
pub use fabro_types::settings::sandbox::{
DaytonaNetwork, DaytonaSettings, DaytonaSnapshotSettings, DockerfileSource,
LocalSandboxSettings, SandboxSettings, WorktreeMode,
};
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
pub struct DaytonaConfig {
pub auto_stop_interval: Option<i32>,
pub labels: Option<HashMap<String, String>>,
pub snapshot: Option<DaytonaSnapshotConfig>,
pub network: Option<DaytonaNetwork>,
/// Skip git repo detection and cloning during initialization.
pub skip_clone: Option<bool>,
}
impl TryFrom<DaytonaConfig> for DaytonaSettings {
type Error = anyhow::Error;
fn try_from(value: DaytonaConfig) -> Result<Self, Self::Error> {
Ok(Self {
auto_stop_interval: value.auto_stop_interval,
labels: value.labels,
snapshot: value.snapshot.map(TryInto::try_into).transpose()?,
network: value.network,
skip_clone: value.skip_clone.unwrap_or(false),
})
}
}
/// Snapshot configuration: when present, the sandbox is created from a snapshot
/// instead of a bare Docker image.
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
pub struct DaytonaSnapshotConfig {
pub name: Option<String>,
pub cpu: Option<i32>,
pub memory: Option<i32>,
pub disk: Option<i32>,
pub dockerfile: Option<DockerfileSource>,
}
impl TryFrom<DaytonaSnapshotConfig> for DaytonaSnapshotSettings {
type Error = anyhow::Error;
fn try_from(value: DaytonaSnapshotConfig) -> Result<Self, Self::Error> {
Ok(Self {
name: value
.name
.ok_or_else(|| anyhow!("sandbox.daytona.snapshot.name is required"))?,
cpu: value.cpu,
memory: value.memory,
disk: value.disk,
dockerfile: value.dockerfile,
})
}
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
pub struct LocalSandboxConfig {
pub worktree_mode: Option<WorktreeMode>,
}
impl From<LocalSandboxConfig> for LocalSandboxSettings {
fn from(value: LocalSandboxConfig) -> Self {
Self {
worktree_mode: value.worktree_mode.unwrap_or_default(),
}
}
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
pub struct SandboxConfig {
pub provider: Option<String>,
pub preserve: Option<bool>,
pub devcontainer: Option<bool>,
pub local: Option<LocalSandboxConfig>,
pub daytona: Option<DaytonaConfig>,
pub env: Option<HashMap<String, String>>,
}
impl TryFrom<SandboxConfig> for SandboxSettings {
type Error = anyhow::Error;
fn try_from(value: SandboxConfig) -> Result<Self, Self::Error> {
Ok(Self {
provider: value.provider,
preserve: value.preserve,
devcontainer: value.devcontainer,
local: value.local.map(Into::into),
daytona: value.daytona.map(TryInto::try_into).transpose()?,
env: value.env,
})
}
}

View file

@ -1,199 +1,23 @@
//! Re-export shim for server settings types.
//!
//! Stage 3 removed the parse-time `*Config` types (`ApiConfig`, `GitConfig`,
//! etc.) in favor of the v2 parse tree in `fabro_types::settings::v2::server`.
//! This module stays alive as a pass-through for crates that still import
//! resolved server types via the legacy `fabro_config::server` path;
//! Stage 6 deletes it.
use std::path::PathBuf;
use anyhow::anyhow;
use serde::{Deserialize, Serialize};
use fabro_types::Settings;
pub use fabro_types::settings::server::{
ApiAuthStrategy, ApiSettings, ArtifactStorageBackend, ArtifactStorageSettings, AuthProvider,
AuthSettings, FeaturesSettings, GitAuthorSettings, GitProvider, GitSettings, LogSettings,
SlackSettings, TlsSettings, WebSettings, WebhookSettings, WebhookStrategy,
};
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
pub struct AuthConfig {
pub provider: Option<AuthProvider>,
#[serde(default)]
pub allowed_usernames: Vec<String>,
}
impl From<AuthConfig> for AuthSettings {
fn from(value: AuthConfig) -> Self {
Self {
provider: value.provider.unwrap_or_default(),
allowed_usernames: value.allowed_usernames,
}
}
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
pub struct TlsConfig {
pub cert: Option<PathBuf>,
pub key: Option<PathBuf>,
pub ca: Option<PathBuf>,
}
impl TryFrom<TlsConfig> for TlsSettings {
type Error = anyhow::Error;
fn try_from(value: TlsConfig) -> Result<Self, Self::Error> {
Ok(Self {
cert: value
.cert
.ok_or_else(|| anyhow!("tls.cert is required when tls is configured"))?,
key: value
.key
.ok_or_else(|| anyhow!("tls.key is required when tls is configured"))?,
ca: value
.ca
.ok_or_else(|| anyhow!("tls.ca is required when tls is configured"))?,
})
}
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
pub struct ApiConfig {
pub base_url: Option<String>,
#[serde(default)]
pub authentication_strategies: Vec<ApiAuthStrategy>,
pub tls: Option<TlsConfig>,
}
fn default_base_url() -> String {
"http://localhost:3000/api/v1".to_string()
}
impl TryFrom<ApiConfig> for ApiSettings {
type Error = anyhow::Error;
fn try_from(value: ApiConfig) -> Result<Self, Self::Error> {
Ok(Self {
base_url: value.base_url.unwrap_or_else(default_base_url),
authentication_strategies: value.authentication_strategies,
tls: value.tls.map(TryInto::try_into).transpose()?,
})
}
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
pub struct GitAuthorConfig {
pub name: Option<String>,
pub email: Option<String>,
}
impl From<GitAuthorConfig> for GitAuthorSettings {
fn from(value: GitAuthorConfig) -> Self {
Self {
name: value.name,
email: value.email,
}
}
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
pub struct WebhookConfig {
pub strategy: Option<WebhookStrategy>,
}
impl TryFrom<WebhookConfig> for WebhookSettings {
type Error = anyhow::Error;
fn try_from(value: WebhookConfig) -> Result<Self, Self::Error> {
Ok(Self {
strategy: value
.strategy
.ok_or_else(|| anyhow!("git.webhooks.strategy is required"))?,
})
}
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
pub struct GitConfig {
pub provider: Option<GitProvider>,
pub app_id: Option<String>,
pub client_id: Option<String>,
pub slug: Option<String>,
pub author: Option<GitAuthorConfig>,
pub webhooks: Option<WebhookConfig>,
}
impl TryFrom<GitConfig> for GitSettings {
type Error = anyhow::Error;
fn try_from(value: GitConfig) -> Result<Self, Self::Error> {
Ok(Self {
provider: value.provider.unwrap_or_default(),
app_id: value.app_id,
client_id: value.client_id,
slug: value.slug,
author: value.author.map(Into::into).unwrap_or_default(),
webhooks: value.webhooks.map(TryInto::try_into).transpose()?,
})
}
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
pub struct WebConfig {
pub enabled: Option<bool>,
pub url: Option<String>,
pub auth: Option<AuthConfig>,
}
fn default_web_url() -> String {
"http://localhost:3000".to_string()
}
impl From<WebConfig> for WebSettings {
fn from(value: WebConfig) -> Self {
Self {
enabled: value.enabled.unwrap_or(true),
url: value.url.unwrap_or_else(default_web_url),
auth: value.auth.map(Into::into).unwrap_or_default(),
}
}
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
pub struct SlackConfig {
pub default_channel: Option<String>,
}
impl From<SlackConfig> for SlackSettings {
fn from(value: SlackConfig) -> Self {
Self {
default_channel: value.default_channel,
}
}
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
pub struct FeaturesConfig {
pub session_sandboxes: Option<bool>,
/// Experimental: enable automatic retro generation after workflow runs.
pub retros: Option<bool>,
}
impl From<FeaturesConfig> for FeaturesSettings {
fn from(value: FeaturesConfig) -> Self {
Self {
session_sandboxes: value.session_sandboxes.unwrap_or(false),
retros: value.retros.unwrap_or(false),
}
}
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
pub struct LogConfig {
pub level: Option<String>,
}
impl From<LogConfig> for LogSettings {
fn from(value: LogConfig) -> Self {
Self { level: value.level }
}
}
/// Resolve the storage directory: config value > default `~/.fabro`.
#[must_use]
pub fn resolve_storage_dir(settings: &Settings) -> PathBuf {
settings.storage_dir()
}

View file

@ -1,54 +1,5 @@
use fabro_types::Settings;
use crate::config::ConfigLayer;
impl TryFrom<ConfigLayer> for Settings {
type Error = anyhow::Error;
fn try_from(value: ConfigLayer) -> Result<Self, Self::Error> {
Ok(Self {
version: value.version,
goal: value.goal,
goal_file: value.goal_file,
graph: value.graph,
labels: value.labels,
work_dir: value.work_dir,
llm: value.llm.map(Into::into),
setup: value.setup.map(Into::into),
sandbox: value.sandbox.map(TryInto::try_into).transpose()?,
vars: value.vars,
checkpoint: value.checkpoint.into(),
pull_request: value.pull_request.map(Into::into),
artifacts: value.artifacts.map(Into::into),
hooks: value.hooks,
mcp_servers: value.mcp_servers,
github: value.github.map(Into::into),
server: value.server.map(TryInto::try_into).transpose()?,
exec: value.exec.map(Into::into),
prevent_idle_sleep: value.prevent_idle_sleep,
verbose: value.verbose,
upgrade_check: value.upgrade_check,
dry_run: value.dry_run,
auto_approve: value.auto_approve,
no_retro: value.no_retro,
storage_dir: value.storage_dir,
max_concurrent_runs: value.max_concurrent_runs,
artifact_storage: value.artifact_storage,
web: value.web.map(Into::into),
slack: value.slack.map(Into::into),
api: value.api.map(TryInto::try_into).transpose()?,
features: value.features.map(Into::into),
log: value.log.map(Into::into),
git: value.git.map(TryInto::try_into).transpose()?,
fabro: value.fabro.map(Into::into),
})
}
}
impl TryFrom<&ConfigLayer> for Settings {
type Error = anyhow::Error;
fn try_from(value: &ConfigLayer) -> Result<Self, Self::Error> {
value.clone().try_into()
}
}
//! Empty module retained for backwards-compatible imports.
//!
//! The legacy `TryFrom<ConfigLayer> for Settings` impl was replaced by
//! [`crate::ConfigLayer::resolve`], which delegates to the v2 bridge in
//! `fabro_types::settings::v2::bridge`. Stage 6 deletes this file entirely.

View file

@ -1,10 +1,13 @@
//! User config loading.
//!
//! Stage 3 removed the parse-time `ClientTlsConfig`/`ServerConfig`/`ExecConfig`
//! types; this module now only exposes machine-level settings loading plus
//! path helpers and a re-export of the resolved user-facing types.
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use anyhow::anyhow;
use serde::{Deserialize, Serialize};
use crate::config::ConfigLayer;
use crate::home::Home;
@ -20,67 +23,6 @@ pub const FABRO_CONFIG_ENV: &str = "FABRO_CONFIG";
static WARNED_LEGACY_USER_CONFIGS: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
pub struct ClientTlsConfig {
pub cert: Option<PathBuf>,
pub key: Option<PathBuf>,
pub ca: Option<PathBuf>,
}
impl TryFrom<ClientTlsConfig> for ClientTlsSettings {
type Error = anyhow::Error;
fn try_from(value: ClientTlsConfig) -> Result<Self, Self::Error> {
Ok(Self {
cert: value.cert.ok_or_else(|| {
anyhow!("server.tls.cert is required when server.tls is configured")
})?,
key: value.key.ok_or_else(|| {
anyhow!("server.tls.key is required when server.tls is configured")
})?,
ca: value.ca.ok_or_else(|| {
anyhow!("server.tls.ca is required when server.tls is configured")
})?,
})
}
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
pub struct ServerConfig {
pub target: Option<String>,
pub tls: Option<ClientTlsConfig>,
}
impl TryFrom<ServerConfig> for ServerSettings {
type Error = anyhow::Error;
fn try_from(value: ServerConfig) -> Result<Self, Self::Error> {
Ok(Self {
target: value.target,
tls: value.tls.map(TryInto::try_into).transpose()?,
})
}
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
pub struct ExecConfig {
pub provider: Option<String>,
pub model: Option<String>,
pub permissions: Option<PermissionLevel>,
pub output_format: Option<OutputFormat>,
}
impl From<ExecConfig> for ExecSettings {
fn from(value: ExecConfig) -> Self {
Self {
provider: value.provider,
model: value.model,
permissions: value.permissions,
output_format: value.output_format,
}
}
}
pub fn default_settings_path() -> PathBuf {
Home::from_env().user_config()
}
@ -126,15 +68,16 @@ fn should_warn_about_legacy_user_config(path: &Path) -> bool {
.insert(path.to_path_buf())
}
/// Load settings config from an explicit path or `~/.fabro/settings.toml`, returning defaults if the
/// default file doesn't exist. An explicit path that doesn't exist is an error.
/// Load settings config from an explicit path or `~/.fabro/settings.toml`,
/// returning defaults if the default file doesn't exist. An explicit path that
/// doesn't exist is an error.
#[allow(clippy::print_stderr)]
pub fn load_settings_config(path: Option<&Path>) -> anyhow::Result<ConfigLayer> {
if let Some(explicit) = path
.map(Path::to_path_buf)
.or_else(|| std::env::var_os(FABRO_CONFIG_ENV).map(PathBuf::from))
{
return crate::load_config_file(Some(&explicit), SETTINGS_CONFIG_FILENAME);
return load_v2_layer_from_path(&explicit);
}
for legacy_path in [
@ -155,7 +98,16 @@ pub fn load_settings_config(path: Option<&Path>) -> anyhow::Result<ConfigLayer>
}
}
crate::load_config_file(None, SETTINGS_CONFIG_FILENAME)
let default = Home::from_env().root().join(SETTINGS_CONFIG_FILENAME);
if default.is_file() {
load_v2_layer_from_path(&default)
} else {
Ok(ConfigLayer::default())
}
}
fn load_v2_layer_from_path(path: &Path) -> anyhow::Result<ConfigLayer> {
ConfigLayer::load(path)
}
#[cfg(test)]

View file

@ -8,14 +8,18 @@ use fabro_config::ConfigLayer;
use fabro_config::effective_settings;
use fabro_config::effective_settings::{EffectiveSettingsLayers, EffectiveSettingsMode};
use fabro_config::project::resolve_working_directory;
use fabro_config::run::{LlmConfig, parse_run_config};
use fabro_config::sandbox::{DockerfileSource, SandboxConfig};
use fabro_config::run::parse_run_config;
use fabro_graphviz::graph::{Graph, is_llm_handler_type};
use fabro_graphviz::render::apply_direction;
use fabro_llm::Provider;
use fabro_model::Catalog;
use fabro_sandbox::daytona::DaytonaConfig;
use fabro_sandbox::{DockerSandboxOptions, Sandbox, SandboxProvider, SandboxSpec};
use fabro_types::settings::v2::interp::InterpString;
use fabro_types::settings::v2::run::{
AgentPermissions, ApprovalMode, DaytonaDockerfileLayer, RunExecutionLayer, RunLayer, RunMode,
RunModelLayer, RunSandboxLayer,
};
use fabro_types::{RunId, Settings};
use fabro_util::check_report::{CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus};
use fabro_validate::Severity;
@ -200,7 +204,7 @@ fn parse_manifest_config(config: &types::ManifestConfig) -> Result<ConfigLayer>
let Some(source) = config.source.as_deref() else {
return Ok(ConfigLayer::default());
};
toml::from_str(source).map_err(Into::into)
ConfigLayer::parse(source)
}
fn manifest_args_layer(args: Option<&types::ManifestArgs>) -> ConfigLayer {
@ -208,28 +212,61 @@ fn manifest_args_layer(args: Option<&types::ManifestArgs>) -> ConfigLayer {
return ConfigLayer::default();
};
let llm = (args.model.is_some() || args.provider.is_some()).then(|| LlmConfig {
model: args.model.clone(),
provider: args.provider.clone(),
fallbacks: None,
let model = (args.model.is_some() || args.provider.is_some()).then(|| RunModelLayer {
provider: args.provider.as_deref().map(InterpString::parse),
name: args.model.as_deref().map(InterpString::parse),
fallbacks: Vec::new(),
});
let sandbox =
(args.sandbox.is_some() || args.preserve_sandbox.is_some()).then(|| SandboxConfig {
(args.sandbox.is_some() || args.preserve_sandbox.is_some()).then(|| RunSandboxLayer {
provider: args.sandbox.clone(),
preserve: args.preserve_sandbox,
..Default::default()
..RunSandboxLayer::default()
});
ConfigLayer {
llm,
let execution_has_any =
args.dry_run.is_some() || args.auto_approve.is_some() || args.no_retro.is_some();
let execution = execution_has_any.then(|| RunExecutionLayer {
mode: args
.dry_run
.map(|d| if d { RunMode::DryRun } else { RunMode::Normal }),
approval: args.auto_approve.map(|a| {
if a {
ApprovalMode::Auto
} else {
ApprovalMode::Prompt
}
}),
retros: args.no_retro.map(|nr| !nr),
});
let run_has_any =
model.is_some() || sandbox.is_some() || execution.is_some() || !args.label.is_empty();
let run = run_has_any.then(|| RunLayer {
model,
sandbox,
verbose: args.verbose,
dry_run: args.dry_run,
auto_approve: args.auto_approve,
no_retro: args.no_retro,
labels: parse_labels(&args.label),
..Default::default()
execution,
metadata: parse_labels(&args.label),
..RunLayer::default()
});
let mut file = fabro_types::settings::v2::SettingsFile::default();
if let Some(run) = run {
file.run = Some(run);
}
// Verbose is a CLI output-verbosity concern in v2, but manifest args
// are resolved server-side as run knobs too. For now we store it as a
// metadata key so Stage 4 consumers can pick it up via the bridge.
if let Some(verbose) = args.verbose {
file.run
.get_or_insert_with(RunLayer::default)
.metadata
.insert("fabro.verbose".into(), verbose.to_string());
}
let _ = AgentPermissions::ReadOnly; // keep unused import alive until Stage 4 wires agent args
ConfigLayer::from(file)
}
fn parse_labels(labels: &[String]) -> HashMap<String, String> {
@ -246,22 +283,27 @@ fn resolve_manifest_dockerfile(
files: &HashMap<PathBuf, String>,
) -> Result<()> {
let source = layer
.sandbox
.as_v2_mut()
.run
.as_mut()
.and_then(|run| run.sandbox.as_mut())
.and_then(|sandbox| sandbox.daytona.as_mut())
.and_then(|daytona| daytona.snapshot.as_mut())
.and_then(|snapshot| snapshot.dockerfile.as_mut());
let Some(DockerfileSource::Path { path }) = source else {
let Some(DaytonaDockerfileLayer::Path { path }) = source else {
return Ok(());
};
let logical_path =
normalize_logical_path(config_path.parent().unwrap_or_else(|| Path::new(".")), path)
.ok_or_else(|| anyhow!("unsupported dockerfile reference: {path}"))?;
let path_owned = path.clone();
let logical_path = normalize_logical_path(
config_path.parent().unwrap_or_else(|| Path::new(".")),
&path_owned,
)
.ok_or_else(|| anyhow!("unsupported dockerfile reference: {path_owned}"))?;
let content = files
.get(&logical_path)
.cloned()
.ok_or_else(|| anyhow!("missing bundled dockerfile: {}", logical_path.display()))?;
*source.unwrap() = DockerfileSource::Inline(content);
*source.unwrap() = DaytonaDockerfileLayer::Inline(content);
Ok(())
}

View file

@ -335,7 +335,7 @@ fn write_marker(root: &Path) {
fn managed_storage_settings(storage_dir: &Path, rest: &str) -> String {
format!(
"{MANAGED_STORAGE_MARKER}\nstorage_dir = \"{}\"\n{rest}",
"{MANAGED_STORAGE_MARKER}\n_version = 1\n\n[server.storage]\nroot = \"{}\"\n\n{rest}",
storage_dir.display()
)
}
@ -349,24 +349,19 @@ fn strip_managed_storage_settings(contents: &str) -> &str {
.strip_prefix(MANAGED_STORAGE_MARKER)
.and_then(|rest| rest.strip_prefix('\n'))
.unwrap_or("");
let (first_line, mut rest) = after_marker.split_once('\n').unwrap_or((after_marker, ""));
if !first_line.starts_with("storage_dir = ") {
return after_marker;
}
if let Some((maybe_target, tail)) = rest.split_once('\n') {
if maybe_target.starts_with("server.target = ") {
rest = tail;
}
}
rest
after_marker
}
fn settings_storage_dir(settings_path: &Path) -> Option<PathBuf> {
let content = std::fs::read_to_string(settings_path).ok()?;
let value = toml::from_str::<toml::Value>(strip_managed_storage_settings(&content)).ok()?;
let stripped = strip_managed_storage_settings(&content);
let value = toml::from_str::<toml::Value>(stripped).ok()?;
value
.get("storage_dir")
.or_else(|| value.get("data_dir"))
.get("server")
.and_then(toml::Value::as_table)
.and_then(|server| server.get("storage"))
.and_then(toml::Value::as_table)
.and_then(|storage| storage.get("root"))
.and_then(toml::Value::as_str)
.map(PathBuf::from)
}
@ -379,7 +374,10 @@ fn write_settings_file(path: &Path, storage_dir: &Path, rest: &str) {
ensure_parent_dir(path);
std::fs::write(
path,
format!("storage_dir = \"{}\"\n{rest}", storage_dir.display()),
format!(
"_version = 1\n\n[server.storage]\nroot = \"{}\"\n\n{rest}",
storage_dir.display()
),
)
.unwrap_or_else(|err| panic!("failed to write {}: {err}", path.display()));
}
@ -407,36 +405,45 @@ fn write_settings_table(path: &Path, table: &TomlMap<String, TomlValue>) {
fn server_target_from_table(table: &TomlMap<String, TomlValue>) -> Option<String> {
table
.get("server")
.get("cli")
.and_then(TomlValue::as_table)
.and_then(|server| server.get("target"))
.and_then(|cli| cli.get("target"))
.and_then(TomlValue::as_table)
.and_then(|target| target.get("path").or_else(|| target.get("url")))
.and_then(TomlValue::as_str)
.map(ToOwned::to_owned)
}
fn set_server_target(table: &mut TomlMap<String, TomlValue>, socket_path: &Path) {
let server_entry = table
.entry("server".to_string())
let cli_entry = table
.entry("cli".to_string())
.or_insert_with(|| TomlValue::Table(TomlMap::new()));
let Some(server_table) = server_entry.as_table_mut() else {
panic!("expected [server] to be a TOML table");
let Some(cli_table) = cli_entry.as_table_mut() else {
panic!("expected [cli] to be a TOML table");
};
server_table.insert(
"target".to_string(),
let target_entry = cli_table
.entry("target".to_string())
.or_insert_with(|| TomlValue::Table(TomlMap::new()));
let Some(target_table) = target_entry.as_table_mut() else {
panic!("expected [cli.target] to be a TOML table");
};
target_table.insert("type".to_string(), TomlValue::String("unix".to_string()));
target_table.insert(
"path".to_string(),
TomlValue::String(socket_path.display().to_string()),
);
}
fn clear_server_target(table: &mut TomlMap<String, TomlValue>) {
let Some(server_entry) = table.get_mut("server") else {
let Some(cli_entry) = table.get_mut("cli") else {
return;
};
let Some(server_table) = server_entry.as_table_mut() else {
let Some(cli_table) = cli_entry.as_table_mut() else {
return;
};
server_table.remove("target");
if server_table.is_empty() {
table.remove("server");
cli_table.remove("target");
if cli_table.is_empty() {
table.remove("cli");
}
}
@ -451,8 +458,8 @@ fn sync_home_settings(
Ok(contents) => {
let had_managed_storage = contents.starts_with(MANAGED_STORAGE_MARKER);
let table = parse_settings_table(&contents, settings_path);
let had_explicit_storage = !had_managed_storage
&& (table.contains_key("storage_dir") || table.contains_key("data_dir"));
let had_explicit_storage =
!had_managed_storage && has_explicit_storage_root(&table);
let had_explicit_target = server_target_from_table(&table).is_some();
(table, had_explicit_storage, had_explicit_target)
}
@ -462,12 +469,12 @@ fn sync_home_settings(
Err(err) => panic!("failed to read {}: {err}", settings_path.display()),
};
table
.entry("_version".to_string())
.or_insert(TomlValue::Integer(1));
if !had_explicit_storage {
table.insert(
"storage_dir".to_string(),
TomlValue::String(storage_dir.display().to_string()),
);
table.remove("data_dir");
set_server_storage_root(&mut table, storage_dir);
}
if force_server_target || (!had_explicit_storage && !had_explicit_target) {
@ -478,21 +485,23 @@ fn sync_home_settings(
if !had_explicit_storage {
let mut rest = table.clone();
rest.remove("storage_dir");
clear_server_storage(&mut rest);
rest.remove("_version");
let managed_target = !had_explicit_target && !force_server_target;
if managed_target {
clear_server_target(&mut rest);
}
let rest = toml::to_string(&rest)
let rest_toml = toml::to_string(&rest)
.unwrap_or_else(|err| panic!("failed to serialize {}: {err}", settings_path.display()));
let mut contents = managed_storage_settings(storage_dir, &rest);
if managed_target {
contents = format!(
"{MANAGED_STORAGE_MARKER}\nstorage_dir = \"{}\"\nserver.target = \"{}\"\n{rest}",
let contents = if managed_target {
format!(
"{MANAGED_STORAGE_MARKER}\n_version = 1\n\n[server.storage]\nroot = \"{}\"\n\n[cli.target]\ntype = \"unix\"\npath = \"{}\"\n\n{rest_toml}",
storage_dir.display(),
socket_path.display()
);
}
)
} else {
managed_storage_settings(storage_dir, &rest_toml)
};
ensure_parent_dir(settings_path);
std::fs::write(settings_path, contents)
.unwrap_or_else(|err| panic!("failed to write {}: {err}", settings_path.display()));
@ -502,6 +511,48 @@ fn sync_home_settings(
write_settings_table(settings_path, &table);
}
fn has_explicit_storage_root(table: &TomlMap<String, TomlValue>) -> bool {
table
.get("server")
.and_then(TomlValue::as_table)
.and_then(|server| server.get("storage"))
.and_then(TomlValue::as_table)
.and_then(|storage| storage.get("root"))
.is_some()
}
fn set_server_storage_root(table: &mut TomlMap<String, TomlValue>, storage_dir: &Path) {
let server_entry = table
.entry("server".to_string())
.or_insert_with(|| TomlValue::Table(TomlMap::new()));
let Some(server_table) = server_entry.as_table_mut() else {
panic!("expected [server] to be a TOML table");
};
let storage_entry = server_table
.entry("storage".to_string())
.or_insert_with(|| TomlValue::Table(TomlMap::new()));
let Some(storage_table) = storage_entry.as_table_mut() else {
panic!("expected [server.storage] to be a TOML table");
};
storage_table.insert(
"root".to_string(),
TomlValue::String(storage_dir.display().to_string()),
);
}
fn clear_server_storage(table: &mut TomlMap<String, TomlValue>) {
let Some(server_entry) = table.get_mut("server") else {
return;
};
let Some(server_table) = server_entry.as_table_mut() else {
return;
};
server_table.remove("storage");
if server_table.is_empty() {
table.remove("server");
}
}
fn server_record_path(storage_dir: &Path) -> PathBuf {
storage_dir.join("server.json")
}