refactor(settings): remove sparse settings compatibility layer

This commit is contained in:
Bryan Helmkamp 2026-04-10 07:58:46 -04:00
parent 7796c4d4e1
commit fab67ad31f
No known key found for this signature in database
59 changed files with 1396 additions and 1739 deletions

View file

@ -5,9 +5,9 @@ use crate::args::{GlobalArgs, SettingsArgs};
use crate::command_context::CommandContext;
use crate::shared::print_json_pretty;
use crate::user_config;
use fabro_config::ConfigLayer;
use fabro_config::effective_settings;
use fabro_config::effective_settings::{EffectiveSettingsLayers, EffectiveSettingsMode};
use fabro_config::load_settings_project;
use fabro_config::project;
use fabro_types::settings::SettingsFile;
@ -18,14 +18,14 @@ fn config_layers(
let cwd = ctx.cwd();
let (workflow_layer, project_layer) = match workflow {
Some(path) => workflow_and_project_layers(path, cwd)?,
None => (ConfigLayer::default(), ConfigLayer::project(cwd)?),
None => (SettingsFile::default(), load_settings_project(cwd)?),
};
let user_layer = user_config::settings_layer_with_config_and_storage_dir(
Some(ctx.base_config_path()),
None,
)?;
Ok(EffectiveSettingsLayers::new(
ConfigLayer::default(),
SettingsFile::default(),
workflow_layer,
project_layer,
user_layer,
@ -35,7 +35,7 @@ fn config_layers(
fn workflow_and_project_layers(
path: &Path,
cwd: &Path,
) -> anyhow::Result<(ConfigLayer, ConfigLayer)> {
) -> anyhow::Result<(SettingsFile, SettingsFile)> {
let resolution = project::resolve_workflow_path(path, cwd)?;
if resolution.workflow_config.is_none() && !resolution.resolved_workflow_path.is_file() {
anyhow::bail!(

View file

@ -2,6 +2,7 @@ use anyhow::Result;
use fabro_agent::cli::{OutputFormat, run_with_args, run_with_args_and_client};
use fabro_llm::client::Client;
use fabro_llm::providers::FabroServerAdapter;
use fabro_mcp::config::{McpServerSettings, McpTransport};
use fabro_types::settings::InterpString;
use fabro_types::settings::cli::OutputFormat as SettingsOutputFormat;
use fabro_types::settings::run::McpEntryLayer;
@ -11,7 +12,7 @@ use std::sync::Arc;
use crate::args::{ExecArgs, GlobalArgs};
use crate::user_config;
fn runtime_mcp_server(name: &str, entry: &McpEntryLayer) -> fabro_mcp::config::McpServerSettings {
fn runtime_mcp_server(name: &str, entry: &McpEntryLayer) -> McpServerSettings {
let transport = match entry {
McpEntryLayer::Stdio {
script,
@ -27,7 +28,7 @@ fn runtime_mcp_server(name: &str, entry: &McpEntryLayer) -> fabro_mcp::config::M
.map(|command| command.iter().map(InterpString::as_source).collect())
.unwrap_or_default()
};
fabro_mcp::config::McpTransport::Stdio {
McpTransport::Stdio {
command,
env: env
.iter()
@ -35,7 +36,7 @@ fn runtime_mcp_server(name: &str, entry: &McpEntryLayer) -> fabro_mcp::config::M
.collect(),
}
}
McpEntryLayer::Http { url, headers, .. } => fabro_mcp::config::McpTransport::Http {
McpEntryLayer::Http { url, headers, .. } => McpTransport::Http {
url: url.as_source(),
headers: headers
.iter()
@ -57,7 +58,7 @@ fn runtime_mcp_server(name: &str, entry: &McpEntryLayer) -> fabro_mcp::config::M
.map(|command| command.iter().map(InterpString::as_source).collect())
.unwrap_or_default()
};
fabro_mcp::config::McpTransport::Sandbox {
McpTransport::Sandbox {
command,
port: *port,
env: env
@ -87,7 +88,7 @@ fn runtime_mcp_server(name: &str, entry: &McpEntryLayer) -> fabro_mcp::config::M
tool_timeout.map_or(60, |duration| duration.as_std().as_secs()),
),
};
fabro_mcp::config::McpServerSettings {
McpServerSettings {
name: name.to_string(),
transport,
startup_timeout_secs,
@ -137,48 +138,47 @@ pub(crate) async fn execute(mut args: ExecArgs, globals: &GlobalArgs) -> Result<
// v2 MCPs live under `cli.exec.agent.mcps` (owner-specific) or
// `run.agent.mcps`. For `fabro exec` we use the cli.exec path, falling
// back to run.agent.mcps if unset.
let mcp_servers: Vec<fabro_mcp::config::McpServerSettings> =
if !resolved_cli.exec.agent.mcps.is_empty() {
resolved_cli
.exec
.agent
.mcps
.values()
.map(|server| fabro_mcp::config::McpServerSettings {
name: server.name.clone(),
transport: server.transport.clone(),
startup_timeout_secs: server.startup_timeout_secs,
tool_timeout_secs: server.tool_timeout_secs,
})
.collect()
} else if let Some(mcps) = cli_settings
.cli
.as_ref()
.and_then(|cli| cli.exec.as_ref())
.and_then(|exec| exec.agent.as_ref())
.map(|agent| &agent.mcps)
.filter(|mcps| !mcps.is_empty())
{
mcps.iter()
.map(|(name, entry)| runtime_mcp_server(name, entry))
.collect()
} else {
fabro_config::resolve_run_from_file(&cli_settings)
.map(|settings| {
settings
.agent
.mcps
.values()
.map(|server| fabro_mcp::config::McpServerSettings {
name: server.name.clone(),
transport: server.transport.clone(),
startup_timeout_secs: server.startup_timeout_secs,
tool_timeout_secs: server.tool_timeout_secs,
})
.collect()
})
.unwrap_or_default()
};
let mcp_servers: Vec<McpServerSettings> = if !resolved_cli.exec.agent.mcps.is_empty() {
resolved_cli
.exec
.agent
.mcps
.values()
.map(|server| McpServerSettings {
name: server.name.clone(),
transport: server.transport.clone(),
startup_timeout_secs: server.startup_timeout_secs,
tool_timeout_secs: server.tool_timeout_secs,
})
.collect()
} else if let Some(mcps) = cli_settings
.cli
.as_ref()
.and_then(|cli| cli.exec.as_ref())
.and_then(|exec| exec.agent.as_ref())
.map(|agent| &agent.mcps)
.filter(|mcps| !mcps.is_empty())
{
mcps.iter()
.map(|(name, entry)| runtime_mcp_server(name, entry))
.collect()
} else {
fabro_config::resolve_run_from_file(&cli_settings)
.map(|settings| {
settings
.agent
.mcps
.values()
.map(|server| McpServerSettings {
name: server.name.clone(),
transport: server.transport.clone(),
startup_timeout_secs: server.startup_timeout_secs,
tool_timeout_secs: server.tool_timeout_secs,
})
.collect()
})
.unwrap_or_default()
};
if let Some(target) = server_target {
tracing::info!(transport = "server", "Agent session starting");
let provider_name = args

View file

@ -2,7 +2,7 @@ use std::io::Write;
use anyhow::bail;
use fabro_api::types;
use fabro_config::ConfigLayer;
use fabro_types::settings::SettingsFile;
use fabro_util::terminal::Styles;
use tracing::debug;
@ -25,7 +25,7 @@ pub(crate) async fn run(
let built = build_run_manifest(ManifestBuildInput {
workflow: args.workflow.clone(),
cwd: ctx.cwd().to_path_buf(),
args_layer: ConfigLayer::default(),
args_layer: SettingsFile::default(),
args: None,
run_id: None,
})?;

View file

@ -574,7 +574,7 @@ pub(crate) async fn run_install(args: &InstallArgs, globals: &GlobalArgs) -> Res
let s = Styles::detect_stderr();
let emoji = console::Emoji("⚒️ ", "");
let cli_settings = user_config::load_settings_with_storage_dir(args.storage_dir.as_deref())?;
let storage_dir = cli_settings.storage_dir();
let storage_dir = user_config::storage_dir(&cli_settings)?;
let server_was_running = record::active_server_record(&storage_dir).is_some();
eprintln!();
@ -1026,9 +1026,8 @@ mod tests {
fn config_toml_roundtrips() {
use fabro_types::settings::SettingsFile;
let toml_str = format_config_toml("brynary");
let cfg: SettingsFile = fabro_config::ConfigLayer::parse(&toml_str)
.expect("generated config should parse as v2")
.into();
let cfg: SettingsFile = fabro_types::settings::parse_settings_file(&toml_str)
.expect("generated config should parse as v2");
let allowed = cfg
.server
.as_ref()
@ -1043,7 +1042,7 @@ mod tests {
fn config_toml_has_auth_strategies() {
use fabro_types::settings::SettingsFile;
let toml_str = format_config_toml("alice");
let cfg: SettingsFile = fabro_config::ConfigLayer::parse(&toml_str).unwrap().into();
let cfg: SettingsFile = fabro_types::settings::parse_settings_file(&toml_str).unwrap();
let auth_api = cfg
.server
.as_ref()
@ -1069,7 +1068,7 @@ mod tests {
use fabro_types::settings::SettingsFile;
use fabro_types::settings::server::ServerListenLayer;
let toml_str = format_config_toml("bob");
let cfg: SettingsFile = fabro_config::ConfigLayer::parse(&toml_str).unwrap().into();
let cfg: SettingsFile = fabro_types::settings::parse_settings_file(&toml_str).unwrap();
let listen = cfg
.server
.as_ref()

View file

@ -7,6 +7,7 @@ mod view;
use anyhow::{Context, Result};
use fabro_types::PullRequestRecord;
use fabro_types::settings::InterpString;
use crate::args::{GlobalArgs, PrCommand, PrNamespace, ServerTargetArgs};
use crate::command_context::CommandContext;
@ -15,8 +16,26 @@ use crate::shared::github::build_github_app_credentials;
pub(crate) async fn dispatch(ns: PrNamespace, globals: &GlobalArgs) -> Result<()> {
let ctx = CommandContext::base()?;
let github_app =
build_github_app_credentials(ctx.machine_settings().github_app_id_str().as_deref())?;
let server_settings =
fabro_config::resolve_server_from_file(ctx.machine_settings()).map_err(|errors| {
anyhow::anyhow!(
"failed to resolve server settings:\n{}",
errors
.into_iter()
.map(|error| error.to_string())
.collect::<Vec<_>>()
.join("\n")
)
})?;
let github_app = build_github_app_credentials(
server_settings
.integrations
.github
.app_id
.as_ref()
.map(InterpString::as_source)
.as_deref(),
)?;
match ns.command {
PrCommand::Create(args) => {
Box::pin(create::create_command(args, github_app, globals)).await

View file

@ -1,5 +1,4 @@
use anyhow::bail;
use fabro_config::ConfigLayer;
use fabro_types::settings::cli::OutputVerbosity;
use fabro_util::terminal::Styles;
@ -8,6 +7,7 @@ use crate::command_context::CommandContext;
use crate::commands::run::output::{
api_check_report_to_local, api_diagnostics_to_local, print_preflight_workflow_summary,
};
use crate::commands::run::overrides::preflight_args_layer;
use crate::manifest_builder::{ManifestBuildInput, build_run_manifest, preflight_manifest_args};
use crate::shared::print_json_pretty;
@ -19,7 +19,7 @@ pub(crate) async fn execute(mut args: PreflightArgs, globals: &GlobalArgs) -> an
let manifest = build_run_manifest(ManifestBuildInput {
workflow: args.workflow.clone(),
cwd: ctx.cwd().to_path_buf(),
args_layer: ConfigLayer::try_from(&args)?,
args_layer: preflight_args_layer(&args)?,
args: preflight_manifest_args(&args),
run_id: None,
})?;

View file

@ -13,6 +13,7 @@ use fabro_api::types;
use fabro_interview::{AnswerValue, ConsoleInterviewer, Question, QuestionOption, QuestionType};
use fabro_store::EventEnvelope;
use fabro_types::settings::cli::OutputVerbosity;
use fabro_types::settings::run::ApprovalMode;
use fabro_util::json::normalize_json_value;
use fabro_util::terminal::Styles;
use fabro_workflow::outcome::StageStatus;
@ -65,9 +66,7 @@ pub(crate) async fn attach_run_with_client(
let state = client.get_run_state(run_id).await?;
let auto_approve = state.run.as_ref().is_some_and(|record| {
fabro_config::resolve_run_from_file(&record.settings)
.map(|settings| {
settings.execution.approval == fabro_types::settings::run::ApprovalMode::Auto
})
.map(|settings| settings.execution.approval == ApprovalMode::Auto)
.unwrap_or(false)
});
let verbose = state.run.as_ref().is_some_and(|record| {

View file

@ -2,13 +2,13 @@ use std::path::PathBuf;
use crate::args::RunArgs;
use crate::command_context::CommandContext;
use fabro_config::ConfigLayer;
use fabro_config::Storage;
use fabro_types::RunId;
use fabro_types::settings::SettingsFile;
use fabro_util::terminal::Styles;
use super::output::{api_diagnostics_to_local, print_preflight_workflow_summary};
use super::overrides::run_args_layer;
use crate::manifest_builder::{ManifestBuildInput, build_run_manifest, run_manifest_args};
use crate::user_config::{self, ServerTarget};
@ -23,7 +23,7 @@ pub(crate) struct CreatedRun {
pub(crate) async fn create_run(
ctx: &CommandContext,
args: &RunArgs,
cli_defaults: ConfigLayer,
_cli_defaults: SettingsFile,
styles: &Styles,
quiet: bool,
) -> anyhow::Result<CreatedRun> {
@ -31,13 +31,8 @@ pub(crate) async fn create_run(
.workflow
.as_ref()
.ok_or_else(|| anyhow::anyhow!("--workflow is required"))?;
let cli_args_config = ConfigLayer::try_from(args)?;
let cli_args_config = run_args_layer(args)?;
let cwd = ctx.cwd().to_path_buf();
let _settings: SettingsFile = cli_args_config
.clone()
.combine(ConfigLayer::for_workflow(workflow_path, &cwd)?)
.combine(cli_defaults)
.into();
let run_id = args
.run_id
.as_deref()
@ -68,7 +63,7 @@ pub(crate) async fn create_run(
let created_run_id = client.create_run_from_manifest(built.manifest).await?;
let local_run_dir = match &target {
ServerTarget::UnixSocket(_) => Some(
Storage::new(ctx.machine_settings().storage_dir())
Storage::new(user_config::storage_dir(ctx.machine_settings())?)
.run_scratch(&created_run_id)
.root()
.to_path_buf(),

View file

@ -2,7 +2,6 @@ use std::collections::HashMap;
use std::path::{Path, PathBuf};
use anyhow::{Result, anyhow};
use fabro_config::ConfigLayer;
use fabro_sandbox::SandboxProvider;
use fabro_types::settings::SettingsFile;
use fabro_types::settings::cli::{CliLayer, CliOutputLayer, OutputVerbosity};
@ -117,67 +116,59 @@ fn current_dir_or_dot() -> PathBuf {
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
}
impl TryFrom<&RunArgs> for ConfigLayer {
type Error = anyhow::Error;
pub(crate) fn run_args_layer(args: &RunArgs) -> Result<SettingsFile> {
let model = model_from_args(args.model.as_deref(), args.provider.as_deref());
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),
);
fn try_from(args: &RunArgs) -> Result<Self, Self::Error> {
let model = model_from_args(args.model.as_deref(), args.provider.as_deref());
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 cwd = current_dir_or_dot();
let goal = goal_layer_from_args(args.goal.as_deref(), args.goal_file.as_deref(), &cwd)?;
let cwd = current_dir_or_dot();
let goal = goal_layer_from_args(args.goal.as_deref(), args.goal_file.as_deref(), &cwd)?;
let run = RunLayer {
goal,
metadata: parse_labels(&args.label),
model,
sandbox,
execution,
..RunLayer::default()
};
let run = RunLayer {
goal,
metadata: parse_labels(&args.label),
model,
sandbox,
execution,
..RunLayer::default()
};
Ok(Self::from(SettingsFile {
run: Some(run),
cli: cli_layer_for_verbose(args.verbose),
..SettingsFile::default()
}))
}
Ok(SettingsFile {
run: Some(run),
cli: cli_layer_for_verbose(args.verbose),
..SettingsFile::default()
})
}
impl TryFrom<&PreflightArgs> for ConfigLayer {
type Error = anyhow::Error;
pub(crate) fn preflight_args_layer(args: &PreflightArgs) -> Result<SettingsFile> {
let model = model_from_args(args.model.as_deref(), args.provider.as_deref());
let sandbox = args.sandbox.map(|s| RunSandboxLayer {
provider: Some(SandboxProvider::from(s).to_string()),
..RunSandboxLayer::default()
});
fn try_from(args: &PreflightArgs) -> Result<Self, Self::Error> {
let model = model_from_args(args.model.as_deref(), args.provider.as_deref());
let sandbox = args.sandbox.map(|s| RunSandboxLayer {
provider: Some(SandboxProvider::from(s).to_string()),
..RunSandboxLayer::default()
});
let cwd = current_dir_or_dot();
let goal = goal_layer_from_args(args.goal.as_deref(), args.goal_file.as_deref(), &cwd)?;
let cwd = current_dir_or_dot();
let goal = goal_layer_from_args(args.goal.as_deref(), args.goal_file.as_deref(), &cwd)?;
let run = RunLayer {
goal,
model,
sandbox,
..RunLayer::default()
};
let run = RunLayer {
goal,
model,
sandbox,
..RunLayer::default()
};
Ok(Self::from(SettingsFile {
run: Some(run),
cli: cli_layer_for_verbose(args.verbose),
..SettingsFile::default()
}))
}
Ok(SettingsFile {
run: Some(run),
cli: cli_layer_for_verbose(args.verbose),
..SettingsFile::default()
})
}
#[cfg(test)]

View file

@ -7,6 +7,7 @@ use anyhow::{Context, Result, anyhow};
use async_trait::async_trait;
use fabro_interview::{ControlInterviewer, WorkerControlEnvelope, WorkerControlMessage};
use fabro_store::{EventEnvelope, EventPayload, RunProjection};
use fabro_types::settings::InterpString;
use fabro_types::settings::SettingsFile;
use fabro_types::{EventBody, RunBlobId, RunEvent, RunId, StatusReason};
use fabro_workflow::artifact_snapshot::CapturedArtifactInfo;
@ -422,16 +423,25 @@ fn maybe_build_github_app_credentials(
settings: &SettingsFile,
) -> Result<Option<fabro_github::GitHubAppCredentials>> {
let resolved_run = fabro_config::resolve_run_from_file(settings).ok();
let resolved_server = fabro_config::resolve_server_from_file(settings).ok();
let needs_github_app = resolved_run
.as_ref()
.is_some_and(|settings| settings.sandbox.provider == "daytona")
|| resolved_run
.as_ref()
.is_some_and(|settings| settings.pull_request.is_some())
|| settings.github_permissions().is_some();
|| resolved_server
.as_ref()
.is_some_and(|settings| !settings.integrations.github.permissions.is_empty());
if needs_github_app {
build_github_app_credentials(settings.github_app_id_str().as_deref())
build_github_app_credentials(
resolved_server
.as_ref()
.and_then(|settings| settings.integrations.github.app_id.as_ref())
.map(InterpString::as_source)
.as_deref(),
)
} else {
Ok(None)
}

View file

@ -43,16 +43,21 @@ pub(crate) async fn execute(
);
let pid = std::process::id();
serve::serve_command(serve_args, styles, storage_dir, move |resolved_bind| {
record::write_server_record(
&record_path,
&record::ServerRecord {
pid,
bind: resolved_bind.clone(),
log_path: log_path.clone(),
started_at: Utc::now(),
},
)
})
Box::pin(serve::serve_command(
serve_args,
styles,
storage_dir,
move |resolved_bind| {
record::write_server_record(
&record_path,
&record::ServerRecord {
pid,
bind: resolved_bind.clone(),
log_path: log_path.clone(),
started_at: Utc::now(),
},
)
},
))
.await
}

View file

@ -28,26 +28,33 @@ pub(crate) async fn dispatch(command: ServerCommand, _globals: &GlobalArgs) -> R
serve_args.config.as_deref(),
storage_dir.as_deref(),
)?;
let storage_dir = settings.storage_dir();
let storage_dir = user_config::storage_dir(&settings)?;
let bind_addr = match serve_args.bind.as_deref() {
Some(s) => bind::parse_bind(s)?,
None => BindRequest::Unix(user_config::default_socket_path()),
};
let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr()));
start::execute(bind_addr, foreground, serve_args, storage_dir, styles).await
Box::pin(start::execute(
bind_addr,
foreground,
serve_args,
storage_dir,
styles,
))
.await
}
ServerCommand::Stop(ServerStopArgs {
storage_dir,
timeout,
}) => {
let settings = user_config::load_settings_with_storage_dir(storage_dir.as_deref())?;
let storage_dir = settings.storage_dir();
let storage_dir = user_config::storage_dir(&settings)?;
stop::execute(&storage_dir, Duration::from_secs(timeout));
Ok(())
}
ServerCommand::Status(ServerStatusArgs { storage_dir, json }) => {
let settings = user_config::load_settings_with_storage_dir(storage_dir.as_deref())?;
let storage_dir = settings.storage_dir();
let storage_dir = user_config::storage_dir(&settings)?;
status::execute(&storage_dir, json)
}
ServerCommand::Serve(ServerServeArgs {
@ -69,7 +76,7 @@ pub(crate) async fn dispatch(command: ServerCommand, _globals: &GlobalArgs) -> R
BindRequest::Unix(user_config::default_socket_path())
};
let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr()));
foreground::execute(
Box::pin(foreground::execute(
record_path,
ServeArgs {
config: active_config_path,
@ -78,7 +85,7 @@ pub(crate) async fn dispatch(command: ServerCommand, _globals: &GlobalArgs) -> R
bind_addr,
storage_dir.clone_path(),
styles,
)
))
.await
}
}

View file

@ -24,7 +24,7 @@ pub(crate) async fn execute(
serve_args.bind = Some(bind.to_string());
if foreground {
execute_foreground(bind, serve_args, storage_dir, styles).await
Box::pin(execute_foreground(bind, serve_args, storage_dir, styles)).await
} else {
execute_daemon(&bind, &serve_args, &storage_dir, true)
}
@ -143,7 +143,7 @@ async fn execute_foreground(
None
};
serve::serve_command(
Box::pin(serve::serve_command(
serve_args,
styles,
Some(storage_dir),
@ -158,7 +158,7 @@ async fn execute_foreground(
},
)
},
)
))
.await
}

View file

@ -15,11 +15,11 @@ use crate::args::{GlobalArgs, StoreDumpArgs};
use crate::server_client::ServerStoreClient;
use crate::server_runs::ServerRunLookup;
use crate::shared::{absolute_or_current, print_json_pretty};
use crate::user_config::load_settings_with_storage_dir;
use crate::user_config::{load_settings_with_storage_dir, storage_dir};
pub(crate) async fn dump_command(args: &StoreDumpArgs, globals: &GlobalArgs) -> Result<()> {
let cli_settings = load_settings_with_storage_dir(args.storage_dir.as_deref())?;
let lookup = ServerRunLookup::connect(&cli_settings.storage_dir()).await?;
let lookup = ServerRunLookup::connect(&storage_dir(&cli_settings)?).await?;
let run = lookup.resolve(&args.run)?;
let run_id = run.run_id();
let state = lookup.client().get_run_state(&run_id).await?;

View file

@ -40,8 +40,10 @@ pub(crate) async fn run_uninstall(args: &UninstallArgs, globals: &GlobalArgs) ->
return Ok(());
}
let storage_dir =
user_config::load_settings().map_or_else(|_| home.storage_dir(), |s| s.storage_dir());
let storage_dir = user_config::load_settings().map_or_else(
|_| home.storage_dir(),
|settings| user_config::storage_dir(&settings).unwrap_or_else(|_| home.storage_dir()),
);
let inventory = build_inventory(&home_root, &storage_dir);

View file

@ -1,5 +1,5 @@
use anyhow::bail;
use fabro_config::ConfigLayer;
use fabro_types::settings::SettingsFile;
use fabro_util::terminal::Styles;
use crate::args::{GlobalArgs, ValidateArgs};
@ -17,7 +17,7 @@ pub(crate) async fn run(
let built = build_run_manifest(ManifestBuildInput {
workflow: args.workflow.clone(),
cwd: ctx.cwd().to_path_buf(),
args_layer: ConfigLayer::default(),
args_layer: SettingsFile::default(),
args: None,
run_id: None,
})?;

View file

@ -133,12 +133,13 @@ async fn main_inner() -> (String, Result<()>) {
{
match load_settings_config(args.config.as_deref()) {
Ok(layer) => {
use fabro_types::settings::SettingsFile;
let server_settings: SettingsFile = layer.into();
let server_settings = layer;
(
server_settings
.server_logging()
.and_then(|l| l.level.clone()),
.server
.as_ref()
.and_then(|server| server.logging.as_ref())
.and_then(|logging| logging.level.clone()),
false,
)
}

View file

@ -3,9 +3,10 @@ use std::path::{Component, Path, PathBuf};
use anyhow::{Context, Result, anyhow};
use fabro_api::types;
use fabro_config::ConfigLayer;
use fabro_config::load::{load_settings_for_workflow, load_settings_user};
use fabro_config::merge::combine_files;
use fabro_config::project::{self, discover_project_config, resolve_workflow_path};
use fabro_config::run::parse_run_config;
use fabro_config::run::{parse_run_config, resolve_run_goal};
use fabro_config::user::active_settings_path;
use fabro_graphviz::graph::AttrValue;
use fabro_graphviz::parser;
@ -21,7 +22,7 @@ use crate::args::{PreflightArgs, RunArgs};
pub(crate) struct ManifestBuildInput {
pub workflow: PathBuf,
pub cwd: PathBuf,
pub args_layer: ConfigLayer,
pub args_layer: SettingsFile,
pub args: Option<types::ManifestArgs>,
pub run_id: Option<RunId>,
}
@ -46,13 +47,12 @@ struct WorkflowScanInput {
}
pub(crate) fn build_run_manifest(input: ManifestBuildInput) -> Result<BuiltManifest> {
let user_layer = ConfigLayer::settings()?;
let merged_settings: SettingsFile = input
.args_layer
.clone()
.combine(ConfigLayer::for_workflow(&input.workflow, &input.cwd)?)
.combine(user_layer.clone())
.into();
let user_layer = load_settings_user()?;
let workflow_layer = load_settings_for_workflow(&input.workflow, &input.cwd)?;
let merged_settings = combine_files(
combine_files(user_layer, workflow_layer),
input.args_layer.clone(),
);
let root_resolution = resolve_workflow_path(&input.workflow, &input.cwd)?;
let target_path = root_resolution.dot_path.clone();
@ -321,7 +321,6 @@ fn collect_workflow_config_files(
) -> Result<()> {
let config_layer = parse_run_config(&config.source)?;
let dockerfile = config_layer
.as_v2()
.run
.as_ref()
.and_then(|run| run.sandbox.as_ref())
@ -385,7 +384,7 @@ fn collect_bundled_file(
}
fn resolve_manifest_goal(
args_layer: &ConfigLayer,
args_layer: &SettingsFile,
settings: &SettingsFile,
root_source: &str,
root_dot_path: &Path,
@ -395,19 +394,16 @@ fn resolve_manifest_goal(
// Precedence 1: CLI args (`--goal` / `--goal-file`). These are already
// resolved to absolute paths by `overrides::goal_layer_from_args`.
if let Some(resolved) = args_layer
.as_v2()
.resolve_run_goal(&working_directory)
if let Some(resolved) = resolve_run_goal(args_layer, &working_directory)
.context("failed to resolve --goal-file contents")?
{
return Ok(Some(resolved_goal_to_manifest(resolved)));
}
// Precedence 2: merged config `run.goal`. Config-sourced `goal.file`
// paths were rewritten to absolute by `ConfigLayer::load` at the
// paths were rewritten to absolute by `load_settings_path` at the
// directory of the config file that declared them.
if let Some(resolved) = settings
.resolve_run_goal(&working_directory)
if let Some(resolved) = resolve_run_goal(settings, &working_directory)
.context("failed to resolve run.goal.file contents")?
{
return Ok(Some(resolved_goal_to_manifest(resolved)));
@ -608,7 +604,7 @@ mod tests {
// Isolate from the developer's real ~/.fabro/settings.toml which may
// still be in the legacy shape. Setting FABRO_CONFIG to a path inside
// the test tempdir forces the loader to produce an empty ConfigLayer.
// the test tempdir forces the loader to produce an empty user layer.
let sandboxed_settings = temp.path().join("empty-settings.toml");
std::fs::write(&sandboxed_settings, "_version = 1\n").unwrap();
// SAFETY: single-threaded unit test body.
@ -619,7 +615,7 @@ mod tests {
let built = build_run_manifest(ManifestBuildInput {
workflow: PathBuf::from("fabro/workflows/demo/workflow.toml"),
cwd: project.to_path_buf(),
args_layer: ConfigLayer::default(),
args_layer: SettingsFile::default(),
args: None,
run_id: None,
})
@ -706,7 +702,7 @@ file = "prompts/goal.md"
let built = build_run_manifest(ManifestBuildInput {
workflow: PathBuf::from("fabro/workflows/demo/workflow.toml"),
cwd: project.to_path_buf(),
args_layer: ConfigLayer::default(),
args_layer: SettingsFile::default(),
args: None,
run_id: None,
})
@ -770,7 +766,7 @@ file = "prompts/goal.md"
let built = build_run_manifest(ManifestBuildInput {
workflow: PathBuf::from("fabro/workflows/demo/workflow.toml"),
cwd: project.to_path_buf(),
args_layer: ConfigLayer::default(),
args_layer: SettingsFile::default(),
args: None,
run_id: None,
})

View file

@ -106,7 +106,7 @@ pub(crate) async fn connect_server_with_settings(
let target = user_config::resolve_server_target(args, settings)?;
let runtime = LocalServerRuntime {
active_config_path: base_config_path.to_path_buf(),
storage_dir: settings.storage_dir(),
storage_dir: user_config::storage_dir(settings)?,
};
connect_target_api_client_bundle(&target, &runtime).await
}

View file

@ -3,7 +3,6 @@ use std::path::{Path, PathBuf};
pub(crate) use fabro_config::user::*;
use anyhow::{Result, bail};
use fabro_config::ConfigLayer;
use fabro_types::settings::cli::CliTargetSettings;
use fabro_types::settings::{CliSettings, SettingsFile};
use fabro_util::version::FABRO_VERSION;
@ -27,28 +26,28 @@ pub(crate) fn load_settings() -> anyhow::Result<SettingsFile> {
pub(crate) fn settings_layer_with_config_and_storage_dir(
config_path: Option<&Path>,
storage_dir: Option<&Path>,
) -> anyhow::Result<ConfigLayer> {
) -> anyhow::Result<SettingsFile> {
let layer = load_settings_config(config_path)?;
Ok(apply_storage_dir_override(layer, storage_dir))
}
pub(crate) fn settings_layer_with_storage_dir(
storage_dir: Option<&Path>,
) -> anyhow::Result<ConfigLayer> {
) -> anyhow::Result<SettingsFile> {
settings_layer_with_config_and_storage_dir(None, storage_dir)
}
pub(crate) fn load_settings_with_storage_dir(
storage_dir: Option<&Path>,
) -> anyhow::Result<SettingsFile> {
Ok(settings_layer_with_storage_dir(storage_dir)?.into())
settings_layer_with_storage_dir(storage_dir)
}
pub(crate) fn load_settings_with_config_and_storage_dir(
config_path: Option<&Path>,
storage_dir: Option<&Path>,
) -> anyhow::Result<SettingsFile> {
Ok(settings_layer_with_config_and_storage_dir(config_path, storage_dir)?.into())
settings_layer_with_config_and_storage_dir(config_path, storage_dir)
}
fn render_resolve_errors(errors: Vec<fabro_config::ResolveError>) -> anyhow::Error {
@ -67,14 +66,13 @@ pub(crate) fn resolve_cli_settings(file: &SettingsFile) -> anyhow::Result<CliSet
}
pub(crate) fn apply_storage_dir_override(
mut layer: ConfigLayer,
mut layer: SettingsFile,
storage_dir: Option<&Path>,
) -> ConfigLayer {
) -> SettingsFile {
use fabro_types::settings::interp::InterpString;
use fabro_types::settings::server::{ServerLayer, ServerStorageLayer};
if let Some(dir) = storage_dir {
let file = layer.as_v2_mut();
let server = file.server.get_or_insert_with(ServerLayer::default);
let server = layer.server.get_or_insert_with(ServerLayer::default);
let storage = server
.storage
.get_or_insert_with(ServerStorageLayer::default);
@ -100,12 +98,10 @@ fn cli_target_from_settings(settings: &CliSettings) -> Option<(String, Option<Cl
let target = settings.target.as_ref()?;
match target {
CliTargetSettings::Http { url, tls } => {
let tls_settings = tls.as_ref().and_then(|tls| {
Some(ClientTlsSettings {
cert: PathBuf::from(tls.cert.as_source()),
key: PathBuf::from(tls.key.as_source()),
ca: PathBuf::from(tls.ca.as_source()),
})
let tls_settings = tls.as_ref().map(|tls| ClientTlsSettings {
cert: PathBuf::from(tls.cert.as_source()),
key: PathBuf::from(tls.key.as_source()),
ca: PathBuf::from(tls.ca.as_source()),
});
Some((url.as_source(), tls_settings))
}
@ -125,6 +121,30 @@ pub(crate) fn default_server_target() -> ServerTarget {
ServerTarget::UnixSocket(default_socket_path())
}
pub(crate) fn storage_dir(settings: &SettingsFile) -> anyhow::Result<PathBuf> {
let resolved = fabro_config::resolve_server_from_file(settings).map_err(|errors| {
anyhow::anyhow!(
"failed to resolve server settings:\n{}",
errors
.into_iter()
.map(|error| error.to_string())
.collect::<Vec<_>>()
.join("\n")
)
})?;
let resolved_root = resolved
.storage
.root
.resolve(|name| std::env::var(name).ok())
.map_err(|err| {
anyhow::anyhow!(
"failed to resolve {}: {err}",
resolved.storage.root.as_source()
)
})?;
Ok(PathBuf::from(resolved_root.value))
}
fn parse_server_target(value: &str, tls: Option<ClientTlsSettings>) -> Result<ServerTarget> {
if value.starts_with("http://") || value.starts_with("https://") {
return Ok(ServerTarget::HttpUrl {
@ -213,6 +233,7 @@ pub(crate) fn build_server_client(
mod tests {
use super::*;
use crate::args::ServerTargetArgs;
use fabro_types::settings::parse_settings_file;
fn server_target_args(value: Option<&str>) -> ServerTargetArgs {
ServerTargetArgs {
@ -221,9 +242,7 @@ mod tests {
}
fn parse_v2(source: &str) -> SettingsFile {
fabro_config::ConfigLayer::parse(source)
.expect("fixture should parse")
.into()
parse_settings_file(source).expect("fixture should parse")
}
#[test]

View file

@ -1,8 +1,7 @@
use std::path::PathBuf;
use fabro_config::ConfigLayer;
use fabro_test::{fabro_snapshot, test_context};
use fabro_types::settings::SettingsFile;
use fabro_types::settings::{SettingsFile, parse_settings_file};
use httpmock::MockServer;
use predicates::prelude::*;
@ -43,8 +42,94 @@ fn resolve_project(settings: &SettingsFile) -> fabro_types::settings::ProjectSet
fabro_config::resolve_project_from_file(settings).expect("project settings should resolve")
}
fn resolve_run(settings: &SettingsFile) -> fabro_types::settings::RunSettings {
fabro_config::resolve_run_from_file(settings).expect("run settings should resolve")
}
fn resolve_server(settings: &SettingsFile) -> fabro_types::settings::ServerSettings {
fabro_config::resolve_server_from_file(settings).expect("server settings should resolve")
}
fn run_goal_inline(settings: &SettingsFile) -> Option<String> {
match resolve_run(settings).goal {
Some(fabro_types::settings::run::RunGoal::Inline(value)) => Some(value.as_source()),
_ => None,
}
}
fn run_model_name(settings: &SettingsFile) -> Option<String> {
resolve_run(settings)
.model
.name
.as_ref()
.map(|value| value.as_source())
}
fn run_model_provider(settings: &SettingsFile) -> Option<String> {
resolve_run(settings)
.model
.provider
.as_ref()
.map(|value| value.as_source())
}
fn run_inputs(settings: &SettingsFile) -> &std::collections::HashMap<String, toml::Value> {
settings
.run
.as_ref()
.and_then(|run| run.inputs.as_ref())
.expect("run.inputs")
}
fn run_sandbox(settings: &SettingsFile) -> &fabro_types::settings::run::RunSandboxLayer {
settings
.run
.as_ref()
.and_then(|run| run.sandbox.as_ref())
.expect("run.sandbox")
}
fn run_checkpoint(settings: &SettingsFile) -> &fabro_types::settings::run::RunCheckpointLayer {
settings
.run
.as_ref()
.and_then(|run| run.checkpoint.as_ref())
.expect("run.checkpoint")
}
fn run_hooks(settings: &SettingsFile) -> &[fabro_types::settings::run::HookEntry] {
settings
.run
.as_ref()
.map(|run| run.hooks.as_slice())
.unwrap_or(&[])
}
fn run_agent_mcps(
settings: &SettingsFile,
) -> &std::collections::HashMap<String, fabro_types::settings::run::McpEntryLayer> {
settings
.run
.as_ref()
.and_then(|run| run.agent.as_ref())
.map(|agent| &agent.mcps)
.expect("run.agent.mcps")
}
fn auto_approve_enabled(settings: &SettingsFile) -> bool {
resolve_run(settings).execution.approval == fabro_types::settings::run::ApprovalMode::Auto
}
fn run_prepare_commands(settings: &SettingsFile) -> Vec<String> {
resolve_run(settings).prepare.commands
}
fn server_storage_root(settings: &SettingsFile) -> String {
resolve_server(settings).storage.root.as_source()
}
fn server_settings_fixture() -> SettingsFile {
ConfigLayer::parse(
parse_settings_file(
r#"
_version = 1
@ -61,7 +146,6 @@ shared = "server"
"#,
)
.expect("server settings fixture should parse")
.into()
}
fn server_settings_body(settings: &SettingsFile) -> String {
@ -300,14 +384,14 @@ fn settings_local_merges_cli_and_project_defaults() {
.clone();
let cfg = parse_settings(&output);
assert_eq!(cfg.run_model_name_str().as_deref(), Some("project-model"));
assert_eq!(cfg.run_model_provider_str().as_deref(), Some("openai"));
assert_eq!(cfg.run_goal_inline_str().as_deref(), None);
assert_eq!(run_model_name(&cfg).as_deref(), Some("project-model"));
assert_eq!(run_model_provider(&cfg).as_deref(), Some("openai"));
assert_eq!(run_goal_inline(&cfg).as_deref(), None);
assert_eq!(resolve_project(&cfg).directory, "fabro");
// v2 R22: run.inputs replaces the inherited map wholesale rather than
// merging by key, so the project layer wipes out the CLI layer's inputs.
let vars = cfg.run_inputs().expect("run.inputs");
let vars = run_inputs(&cfg);
assert_eq!(vars.get("project_only").and_then(|v| v.as_str()), Some("1"));
assert_eq!(vars.get("shared").and_then(|v| v.as_str()), Some("project"));
assert!(
@ -317,7 +401,7 @@ fn settings_local_merges_cli_and_project_defaults() {
// v2 R71: provider-native maps such as run.sandbox.daytona.labels remain
// sticky merge-by-key, so CLI labels persist under the project layer.
let sandbox = cfg.run_sandbox().expect("run.sandbox");
let sandbox = run_sandbox(&cfg);
let labels = &sandbox.daytona.as_ref().expect("daytona").labels;
assert_eq!(labels.get("cli_only").map(String::as_str), Some("1"));
assert_eq!(labels.get("shared").map(String::as_str), Some("cli"));
@ -341,18 +425,18 @@ fn settings_local_workflow_name_applies_run_overlay_and_deep_merges() {
use fabro_types::settings::run::McpEntryLayer;
let cfg = parse_settings(&output);
assert_eq!(cfg.run_goal_inline_str().as_deref(), Some("demo goal"));
assert_eq!(cfg.run_model_name_str().as_deref(), Some("run-model"));
assert_eq!(cfg.run_model_provider_str().as_deref(), Some("anthropic"));
assert_eq!(run_goal_inline(&cfg).as_deref(), Some("demo goal"));
assert_eq!(run_model_name(&cfg).as_deref(), Some("run-model"));
assert_eq!(run_model_provider(&cfg).as_deref(), Some("anthropic"));
// v2 R22: run.inputs replaces wholesale, so the workflow layer wins
// over project and cli.
let vars = cfg.run_inputs().expect("run.inputs");
let vars = run_inputs(&cfg);
assert_eq!(vars.get("run_only").and_then(|v| v.as_str()), Some("1"));
assert_eq!(vars.get("shared").and_then(|v| v.as_str()), Some("run"));
// checkpoint.exclude_globs is a security/policy list: replace by default.
let checkpoint = cfg.run_checkpoint().expect("run.checkpoint");
let checkpoint = run_checkpoint(&cfg);
assert_eq!(
checkpoint.exclude_globs,
vec!["run-only".to_string(), "shared".to_string()]
@ -361,7 +445,7 @@ fn settings_local_workflow_name_applies_run_overlay_and_deep_merges() {
// Hooks: id-based replacement. The "shared" hook appears in both cli and
// workflow layers and resolves to the workflow entry; project and run-only
// contribute the other two ids.
let hooks = cfg.run_hooks();
let hooks = run_hooks(&cfg);
assert!(hooks.len() >= 2);
let shared_hook = hooks
.iter()
@ -381,7 +465,7 @@ fn settings_local_workflow_name_applies_run_overlay_and_deep_merges() {
.any(|hook| hook.name.as_deref() == Some("run-only"))
);
let mcps = cfg.run_agent_mcps().expect("run.agent.mcps");
let mcps = run_agent_mcps(&cfg);
match mcps.get("shared").expect("shared mcp") {
McpEntryLayer::Stdio { command, .. } => {
let command = command.as_ref().expect("command");
@ -393,7 +477,7 @@ fn settings_local_workflow_name_applies_run_overlay_and_deep_merges() {
assert!(mcps.contains_key("run_only"));
// run.sandbox.daytona.labels stays sticky merge-by-key per R71.
let sandbox = cfg.run_sandbox().expect("run.sandbox");
let sandbox = run_sandbox(&cfg);
let labels = &sandbox.daytona.as_ref().expect("daytona").labels;
assert_eq!(labels.get("run_only").map(String::as_str), Some("1"));
assert_eq!(labels.get("shared").map(String::as_str), Some("run"));
@ -434,14 +518,14 @@ fn settings_local_explicit_workflow_path_uses_workflow_project_layers() {
.clone();
let cfg = parse_settings(&output);
assert!(cfg.auto_approve_enabled());
assert!(auto_approve_enabled(&cfg));
// v2 R30: run.prepare.steps replaces the whole ordered list across layers.
// The highest-precedence layer (workflow) wins.
assert_eq!(
cfg.run_prepare_commands(),
run_prepare_commands(&cfg),
vec!["workflow-setup".to_string()]
);
assert_eq!(cfg.run_sandbox().and_then(|sb| sb.preserve), Some(true));
assert_eq!(run_sandbox(&cfg).preserve, Some(true));
}
#[test]
@ -594,7 +678,12 @@ name = "legacy-model"
resolve_cli(&cfg).output.verbosity,
fabro_types::settings::cli::OutputVerbosity::Normal
);
assert!(cfg.run_model().is_none());
assert!(
cfg.run
.as_ref()
.and_then(|run| run.model.as_ref())
.is_none()
);
}
#[test]
@ -623,13 +712,9 @@ shared = "legacy"
.stderr(predicate::str::contains("ignoring legacy config file"));
let cfg = parse_settings(&assert.get_output().stdout);
assert_eq!(cfg.run_model_name_str().as_deref(), Some("project-model"));
assert_eq!(
cfg.run_inputs()
.and_then(|vars| vars.get("shared"))
.and_then(|v| v.as_str()),
Some("project")
);
assert_eq!(run_model_name(&cfg).as_deref(), Some("project-model"));
let vars = run_inputs(&cfg);
assert_eq!(vars.get("shared").and_then(|v| v.as_str()), Some("project"));
}
#[test]
@ -761,12 +846,9 @@ shared = "cli"
mock.assert();
let cfg = parse_settings(&output);
assert_eq!(cfg.run_model_name_str().as_deref(), Some("project-model"));
assert_eq!(cfg.run_model_provider_str().as_deref(), Some("openai"));
assert_eq!(
cfg.server_storage_root_str().as_deref(),
Some("/srv/fabro-server")
);
assert_eq!(run_model_name(&cfg).as_deref(), Some("project-model"));
assert_eq!(run_model_provider(&cfg).as_deref(), Some("openai"));
assert_eq!(server_storage_root(&cfg), "/srv/fabro-server");
assert_eq!(
resolve_cli(&cfg).output.verbosity,
fabro_types::settings::cli::OutputVerbosity::Verbose
@ -775,7 +857,7 @@ shared = "cli"
// R22: run.inputs replaces wholesale across layers. Project is the
// highest-precedence layer that sets inputs, so project's vars win
// and server-side vars are discarded rather than merged.
let vars = cfg.run_inputs().expect("run.inputs");
let vars = run_inputs(&cfg);
assert_eq!(vars.get("project_only").and_then(|v| v.as_str()), Some("1"));
assert_eq!(vars.get("shared").and_then(|v| v.as_str()), Some("project"));
assert!(
@ -832,10 +914,7 @@ verbosity = "verbose"
cli_mock.assert();
configured_mock.assert_calls(0);
let cfg = parse_settings(&output);
assert_eq!(
cfg.server_storage_root_str().as_deref(),
Some("/srv/fabro-server")
);
assert_eq!(server_storage_root(&cfg), "/srv/fabro-server");
}
#[test]

View file

@ -8,6 +8,12 @@ use crate::support::{fabro_json_snapshot, unique_run_id};
use super::support::{fixture, output_stdout, resolve_run, run_count_for_test_case, run_state};
fn resolved_run(
settings: &fabro_types::settings::SettingsFile,
) -> fabro_types::settings::RunSettings {
fabro_config::resolve_run_from_file(settings).expect("run settings should resolve")
}
fn run_status_response(run_id: &str, status: &str) -> serde_json::Value {
serde_json::json!({
"id": run_id,
@ -353,22 +359,26 @@ fn create_persists_requested_overrides_into_store() {
"team": run_record.labels.get("team"),
});
let settings = &run_record.settings;
let resolved_run = resolved_run(settings);
let cli_settings = fabro_config::resolve_cli_from_file(settings).expect("cli settings");
let compact = json!({
"workflow_slug": run_record.workflow_slug,
"settings": {
"goal": settings.run_goal_inline_str(),
"dry_run": settings.dry_run_enabled(),
"auto_approve": settings.auto_approve_enabled(),
"no_retro": settings.no_retro_enabled(),
"goal": match resolved_run.goal.as_ref() {
Some(fabro_types::settings::run::RunGoal::Inline(value)) => Some(value.as_source()),
_ => None,
},
"dry_run": resolved_run.execution.mode == fabro_types::settings::run::RunMode::DryRun,
"auto_approve": resolved_run.execution.approval == fabro_types::settings::run::ApprovalMode::Auto,
"no_retro": !resolved_run.execution.retros,
"verbose": cli_settings.output.verbosity == fabro_types::settings::cli::OutputVerbosity::Verbose,
"llm": {
"model": settings.run_model_name_str(),
"provider": settings.run_model_provider_str(),
"model": resolved_run.model.name.as_ref().map(|value| value.as_source()),
"provider": resolved_run.model.provider.as_ref().map(|value| value.as_source()),
},
"sandbox": {
"provider": settings.run_sandbox().and_then(|sb| sb.provider.clone()),
"preserve": settings.preserve_sandbox_enabled(),
"provider": resolved_run.sandbox.provider,
"preserve": resolved_run.sandbox.preserve,
},
},
"labels": labels,
@ -425,12 +435,16 @@ fn create_json_implies_auto_approve() {
let run = resolve_run(&context, run_id);
assert!(
run_state(&run.run_dir)
.run
.as_ref()
.expect("run record should exist")
.settings
.auto_approve_enabled()
resolved_run(
&run_state(&run.run_dir)
.run
.as_ref()
.expect("run record should exist")
.settings,
)
.execution
.approval
== fabro_types::settings::run::ApprovalMode::Auto
);
}

View file

@ -210,10 +210,11 @@ digraph GitHubApp {
let run_dir = context.find_run_dir(&run_id);
let state = run_state(&run_dir);
let run = state.run.as_ref().expect("run record should exist");
let resolved_server = fabro_config::resolve_server_from_file(&run.settings).unwrap();
fabro_json_snapshot!(
context,
serde_json::json!({
"app_id": run.settings.github_app_id_str(),
"app_id": resolved_server.integrations.github.app_id.map(|value| value.as_source()),
}),
@r#"
{

View file

@ -1,376 +0,0 @@
//! v2-backed configuration layer.
//!
//! `ConfigLayer` is a newtype over [`SettingsFile`] — the v2 namespaced
//! parse tree in `fabro_types::settings::v2`. Loading functions (`parse`,
//! `load`, `for_workflow`, `project`, `settings`) all hard-fail on legacy
//! top-level keys with targeted rename hints. `ConfigLayer::combine` walks
//! the v2 merge matrix from [`crate::merge`].
//!
//! Consumers that need the inner tree call [`ConfigLayer::as_v2`] (borrow)
//! or `.into()` to move out an owned `SettingsFile`. The legacy flat
//! `Settings` shape is no longer reachable from this layer.
use std::path::Path;
use anyhow::Context;
use fabro_types::settings::accessors::resolve_goal_file_path;
use fabro_types::settings::interp::InterpString;
use fabro_types::settings::run::RunGoalLayer;
use fabro_types::settings::{SettingsFile, parse_settings_file as parse_v2_settings_file};
use serde::{Deserialize, Serialize};
use crate::merge::combine_files;
use crate::project::{self};
use crate::user;
/// Rewrite any relative `run.goal = { file = "..." }` path in `file` to an
/// absolute path anchored at `base_dir`.
///
/// Called from `ConfigLayer::load` so that layers coming from different
/// config files can be merged without losing the "relative to my source
/// file" context. Paths that contain `${env.NAME}` interpolation are left
/// alone (they get resolved against the run's working directory at consume
/// time via [`SettingsFile::resolve_run_goal`]).
fn resolve_goal_file_paths(file: &mut SettingsFile, base_dir: &Path) {
let Some(run) = file.run.as_mut() else {
return;
};
let Some(RunGoalLayer::File { file: goal_file }) = run.goal.as_mut() else {
return;
};
if !goal_file.is_literal() {
// Env-tokenized paths stay unresolved until consume time.
return;
}
let literal = goal_file.as_source();
if Path::new(&literal).is_absolute() {
return;
}
let absolute = resolve_goal_file_path(&literal, base_dir);
*goal_file = InterpString::parse(&absolute.to_string_lossy());
}
/// A parsed settings file layer.
///
/// 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 {
pub file: SettingsFile,
}
impl From<SettingsFile> for ConfigLayer {
fn from(file: SettingsFile) -> Self {
Self { file }
}
}
impl From<ConfigLayer> for SettingsFile {
fn from(layer: ConfigLayer) -> Self {
layer.file
}
}
impl ConfigLayer {
/// Combine two layers using the v2 merge matrix.
#[must_use]
pub fn combine(self, other: Self) -> Self {
// 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.
///
/// Relative `run.goal = { file = "..." }` paths are resolved against
/// the directory of `path` at load time. Subsequent merging with other
/// layers can then safely treat the path as self-contained.
pub fn load(path: &Path) -> anyhow::Result<Self> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read {}", path.display()))?;
let mut layer = Self::parse(&content)?;
let base_dir = path.parent().unwrap_or_else(|| Path::new("."));
resolve_goal_file_paths(&mut layer.file, base_dir);
Ok(layer)
}
/// Load workflow config + project config for a workflow path.
///
/// Resolves the workflow path, loads its config, discovers project config
/// (`fabro.toml`) from the resolved workflow's parent directory, and
/// combines them (workflow takes precedence over project).
pub fn for_workflow(path: &Path, cwd: &Path) -> anyhow::Result<Self> {
let resolution = project::resolve_workflow_path(path, cwd)?;
if resolution.workflow_config.is_none() && !resolution.resolved_workflow_path.is_file() {
anyhow::bail!(
"Workflow not found: {}",
resolution.resolved_workflow_path.display()
);
}
let workflow_config = resolution.workflow_config.unwrap_or_default();
let project_config = project::discover_project_config(
resolution
.resolved_workflow_path
.parent()
.unwrap_or_else(|| Path::new(".")),
)?
.map(|(_, config)| config)
.unwrap_or_default();
Ok(workflow_config.combine(project_config))
}
/// Discover project config (`fabro.toml`) by walking ancestors from `start`.
pub fn project(start: &Path) -> anyhow::Result<Self> {
Ok(project::discover_project_config(start)?
.map(|(_, config)| config)
.unwrap_or_default())
}
/// Load machine-level defaults from `~/.fabro/settings.toml`.
pub fn settings() -> anyhow::Result<Self> {
user::load_settings_config(None)
}
/// 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 fabro_types::settings::run::RunGoalLayer;
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_inline_goal() {
let layer = ConfigLayer::parse(
r#"
_version = 1
[run]
goal = "Do things"
"#,
)
.unwrap();
assert_eq!(
layer.file.run_goal_inline_str().as_deref(),
Some("Do things")
);
}
#[test]
fn parse_accepts_file_variant() {
let layer = ConfigLayer::parse(
r#"
_version = 1
[run.goal]
file = "prompts/goal.md"
"#,
)
.unwrap();
let Some(RunGoalLayer::File { file }) = layer.file.run_goal_layer() else {
panic!("expected run.goal.file variant");
};
assert_eq!(file.as_source(), "prompts/goal.md");
}
#[test]
fn parse_rejects_goal_with_unknown_sibling_fields() {
// The untagged enum should reject any `{ file = ..., extra = ... }`
// shape because neither the inline nor the file variant matches.
let err = ConfigLayer::parse(
r#"
_version = 1
[run.goal]
file = "prompts/goal.md"
extra = "boom"
"#,
)
.unwrap_err();
let text = format!("{err:#}");
assert!(text.to_lowercase().contains("run.goal") || text.contains("extra"));
}
#[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_goal_inline_str().as_deref(),
Some("higher goal")
);
}
#[test]
fn combine_replaces_file_goal_with_inline_from_higher_layer() {
// A higher-precedence `run.goal = "inline"` must fully override a
// lower layer's `run.goal = { file = "..." }` — the scalar merge
// treats `goal` as one field regardless of which variant each
// layer picked.
let higher = ConfigLayer::parse(
r#"
_version = 1
[run]
goal = "inline override"
"#,
)
.unwrap();
let lower = ConfigLayer::parse(
r#"
_version = 1
[run.goal]
file = "/tmp/goal.md"
"#,
)
.unwrap();
let merged = higher.combine(lower);
assert_eq!(
merged.file.run_goal_inline_str().as_deref(),
Some("inline override")
);
}
#[test]
fn combine_replaces_inline_goal_with_file_from_higher_layer() {
let higher = ConfigLayer::parse(
r#"
_version = 1
[run.goal]
file = "/tmp/goal.md"
"#,
)
.unwrap();
let lower = ConfigLayer::parse(
r#"
_version = 1
[run]
goal = "inline loser"
"#,
)
.unwrap();
let merged = higher.combine(lower);
assert!(matches!(
merged.file.run_goal_layer(),
Some(RunGoalLayer::File { .. })
));
}
#[test]
fn load_rewrites_relative_goal_file_to_absolute() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("fabro.toml");
std::fs::write(
&config_path,
r#"
_version = 1
[run.goal]
file = "prompts/goal.md"
"#,
)
.unwrap();
let layer = ConfigLayer::load(&config_path).unwrap();
let Some(RunGoalLayer::File { file }) = layer.file.run_goal_layer() else {
panic!("expected file variant");
};
let resolved = file.as_source();
let expected = tmp.path().join("prompts").join("goal.md");
assert_eq!(resolved, expected.to_string_lossy());
}
#[test]
fn load_leaves_absolute_goal_file_untouched() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("fabro.toml");
let abs_goal = "/etc/fabro/goal.md";
std::fs::write(
&config_path,
format!(
r#"
_version = 1
[run.goal]
file = "{abs_goal}"
"#
),
)
.unwrap();
let layer = ConfigLayer::load(&config_path).unwrap();
let Some(RunGoalLayer::File { file }) = layer.file.run_goal_layer() else {
panic!("expected file variant");
};
assert_eq!(file.as_source(), abs_goal);
}
#[test]
fn load_leaves_env_interpolated_goal_file_untouched() {
// InterpString paths aren't resolved at load time because env
// lookups happen at consume time. The loader should leave them
// alone.
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("fabro.toml");
std::fs::write(
&config_path,
r#"
_version = 1
[run.goal]
file = "${env.GOALS_DIR}/goal.md"
"#,
)
.unwrap();
let layer = ConfigLayer::load(&config_path).unwrap();
let Some(RunGoalLayer::File { file }) = layer.file.run_goal_layer() else {
panic!("expected file variant");
};
assert_eq!(file.as_source(), "${env.GOALS_DIR}/goal.md");
}
}

View file

@ -11,7 +11,6 @@ use fabro_types::settings::SettingsFile;
use fabro_types::settings::run::{RunExecutionLayer, RunLayer};
use fabro_types::settings::server::ServerLayer;
use crate::ConfigLayer;
use crate::merge::combine_files;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@ -23,19 +22,19 @@ pub enum EffectiveSettingsMode {
#[derive(Clone, Debug, Default)]
pub struct EffectiveSettingsLayers {
pub args: ConfigLayer,
pub workflow: ConfigLayer,
pub project: ConfigLayer,
pub user: ConfigLayer,
pub args: SettingsFile,
pub workflow: SettingsFile,
pub project: SettingsFile,
pub user: SettingsFile,
}
impl EffectiveSettingsLayers {
#[must_use]
pub fn new(
args: ConfigLayer,
workflow: ConfigLayer,
project: ConfigLayer,
user: ConfigLayer,
args: SettingsFile,
workflow: SettingsFile,
project: SettingsFile,
user: SettingsFile,
) -> Self {
Self {
args,
@ -60,9 +59,10 @@ pub fn resolve_settings(
} = layers;
match mode {
EffectiveSettingsMode::LocalOnly => {
Ok(args.combine(workflow).combine(project).combine(user).into())
}
EffectiveSettingsMode::LocalOnly => Ok(combine_files(
combine_files(combine_files(user, project), workflow),
args,
)),
EffectiveSettingsMode::RemoteServer | EffectiveSettingsMode::LocalDaemon => {
let server_settings = server_settings.ok_or_else(|| {
anyhow!("server settings are required for server-targeted settings resolution")
@ -70,13 +70,13 @@ pub fn resolve_settings(
// Owner-specific domains (cli, server) may only come from the
// local ~/.fabro/settings.toml, never from fabro.toml or
// workflow.toml. The user layer keeps its cli/server fields.
strip_owner_domains(workflow.as_v2_mut());
strip_owner_domains(project.as_v2_mut());
strip_owner_domains(&mut workflow);
strip_owner_domains(&mut project);
let server_defaults = server_defaults_file(server_settings);
let combined: SettingsFile =
args.combine(workflow).combine(project).combine(user).into();
let combined =
combine_files(combine_files(combine_files(user, project), workflow), args);
let mut settings = match mode {
EffectiveSettingsMode::RemoteServer => {
@ -179,20 +179,20 @@ fn apply_local_daemon_overrides(mut settings: SettingsFile, server: &SettingsFil
mod tests {
use fabro_types::settings::InterpString;
use fabro_types::settings::server::{ServerLayer, ServerSchedulerLayer, ServerStorageLayer};
use fabro_types::settings::{SettingsFile, parse_settings_file};
use super::{EffectiveSettingsLayers, EffectiveSettingsMode, resolve_settings};
use crate::ConfigLayer;
fn layer(source: &str) -> ConfigLayer {
ConfigLayer::parse(source).expect("v2 fixture should parse")
fn layer(source: &str) -> SettingsFile {
parse_settings_file(source).expect("v2 fixture should parse")
}
#[test]
fn local_only_merges_project_and_user_layers() {
let settings = resolve_settings(
EffectiveSettingsLayers::new(
ConfigLayer::default(),
ConfigLayer::default(),
SettingsFile::default(),
SettingsFile::default(),
layer(
r#"
_version = 1
@ -227,13 +227,23 @@ shared = "user"
.unwrap();
assert_eq!(
settings.run_model_name_str().as_deref(),
settings
.run
.as_ref()
.and_then(|run| run.model.as_ref())
.and_then(|model| model.name.as_ref())
.map(|value| value.as_source())
.as_deref(),
Some("project-model")
);
// 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 inputs = settings.run_inputs().unwrap();
let inputs = settings
.run
.as_ref()
.and_then(|run| run.inputs.as_ref())
.unwrap();
assert!(inputs.contains_key("project_only"));
assert_eq!(
inputs.get("shared").and_then(|v| v.as_str()),
@ -249,7 +259,7 @@ shared = "user"
fn local_only_merges_workflow_project_user() {
let settings = resolve_settings(
EffectiveSettingsLayers::new(
ConfigLayer::default(),
SettingsFile::default(),
layer(
r#"
_version = 1
@ -284,14 +294,35 @@ provider = "openai"
.unwrap();
assert_eq!(
settings.run_goal_inline_str().as_deref(),
match settings.run.as_ref().and_then(|run| run.goal.as_ref()) {
Some(fabro_types::settings::run::RunGoalLayer::Inline(value)) => {
Some(value.as_source())
}
_ => None,
}
.as_deref(),
Some("workflow goal")
);
assert_eq!(
settings.run_model_name_str().as_deref(),
settings
.run
.as_ref()
.and_then(|run| run.model.as_ref())
.and_then(|model| model.name.as_ref())
.map(|value| value.as_source())
.as_deref(),
Some("workflow-model")
);
assert_eq!(settings.run_model_provider_str().as_deref(), Some("openai"));
assert_eq!(
settings
.run
.as_ref()
.and_then(|run| run.model.as_ref())
.and_then(|model| model.provider.as_ref())
.map(|value| value.as_source())
.as_deref(),
Some("openai")
);
}
#[test]
@ -321,10 +352,10 @@ root = "/tmp/should-be-inert"
let settings = resolve_settings(
EffectiveSettingsLayers::new(
ConfigLayer::default(),
ConfigLayer::default(),
SettingsFile::default(),
SettingsFile::default(),
project_with_server,
ConfigLayer::default(),
SettingsFile::default(),
),
Some(&server_settings),
EffectiveSettingsMode::RemoteServer,
@ -332,11 +363,23 @@ root = "/tmp/should-be-inert"
.unwrap();
assert_eq!(
settings.server_storage_root_str().as_deref(),
settings
.server
.as_ref()
.and_then(|server| server.storage.as_ref())
.and_then(|storage| storage.root.as_ref())
.map(|value| value.as_source())
.as_deref(),
Some("/srv/fabro")
);
assert_eq!(
settings.run_goal_inline_str().as_deref(),
match settings.run.as_ref().and_then(|run| run.goal.as_ref()) {
Some(fabro_types::settings::run::RunGoalLayer::Inline(value)) => {
Some(value.as_source())
}
_ => None,
}
.as_deref(),
Some("project goal")
);
}
@ -362,9 +405,22 @@ root = "/tmp/should-be-inert"
.unwrap();
assert_eq!(
settings.server_storage_root_str().as_deref(),
settings
.server
.as_ref()
.and_then(|server| server.storage.as_ref())
.and_then(|storage| storage.root.as_ref())
.map(|value| value.as_source())
.as_deref(),
Some("/srv/fabro")
);
assert_eq!(settings.max_concurrent_runs(), Some(7));
assert_eq!(
settings
.server
.as_ref()
.and_then(|server| server.scheduler.as_ref())
.and_then(|scheduler| scheduler.max_concurrent_runs),
Some(7)
);
}
}

View file

@ -1,9 +1,9 @@
extern crate self as fabro_config;
pub mod config;
pub mod effective_settings;
pub mod home;
pub mod legacy_env;
pub mod load;
pub mod merge;
pub mod project;
pub mod resolve;
@ -11,25 +11,40 @@ pub mod run;
pub mod storage;
pub mod user;
pub use config::ConfigLayer;
pub use fabro_util::path::expand_tilde;
pub use home::Home;
pub use load::{
load_settings_for_workflow, load_settings_path, load_settings_project, load_settings_user,
};
pub use resolve::{
ResolveError, resolve_cli, resolve_cli_from_file, resolve_features, resolve_features_from_file,
resolve_project, resolve_project_from_file, resolve_run, resolve_run_from_file, resolve_server,
resolve_server_from_file, resolve_workflow, resolve_workflow_from_file,
ResolveError, resolve, resolve_cli, resolve_cli_from_file, resolve_features,
resolve_features_from_file, resolve_project, resolve_project_from_file, resolve_run,
resolve_run_from_file, resolve_server, resolve_server_from_file, resolve_workflow,
resolve_workflow_from_file,
};
pub use storage::{RunScratch, ServerState, Storage};
use std::path::{Path, PathBuf};
use std::path::Path;
use fabro_types::settings::SettingsFile;
use fabro_types::settings::{Settings, SettingsFile};
use serde::de::DeserializeOwned;
/// Resolve the storage directory: v2 `server.storage.root` > home default.
#[must_use]
pub fn resolve_storage_dir(settings: &SettingsFile) -> PathBuf {
settings.storage_dir()
pub fn load_and_resolve(
layers: effective_settings::EffectiveSettingsLayers,
server_settings: Option<&SettingsFile>,
mode: effective_settings::EffectiveSettingsMode,
) -> anyhow::Result<Settings> {
let layer = effective_settings::resolve_settings(layers, server_settings, mode)?;
resolve(&layer).map_err(|errors| {
anyhow::anyhow!(
"failed to resolve settings:\n{}",
errors
.into_iter()
.map(|error| error.to_string())
.collect::<Vec<_>>()
.join("\n")
)
})
}
/// Load a TOML config from an explicit path or `~/.fabro/{filename}`.

View file

@ -0,0 +1,79 @@
use std::path::{Path, PathBuf};
use anyhow::Context;
use fabro_types::settings::run::RunGoalLayer;
use fabro_types::settings::{InterpString, SettingsFile, parse_settings_file};
use crate::merge::combine_files;
use crate::project;
use crate::user;
pub fn load_settings_path(path: &Path) -> anyhow::Result<SettingsFile> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read {}", path.display()))?;
let mut layer = parse_settings_file(&content)
.map_err(|err| anyhow::anyhow!("{err}"))
.context("Failed to parse settings file")?;
let base_dir = path.parent().unwrap_or_else(|| Path::new("."));
resolve_goal_file_paths(&mut layer, base_dir);
Ok(layer)
}
pub fn load_settings_for_workflow(path: &Path, cwd: &Path) -> anyhow::Result<SettingsFile> {
let resolution = project::resolve_workflow_path(path, cwd)?;
if resolution.workflow_config.is_none() && !resolution.resolved_workflow_path.is_file() {
anyhow::bail!(
"Workflow not found: {}",
resolution.resolved_workflow_path.display()
);
}
let workflow_config = resolution.workflow_config.unwrap_or_default();
let project_config = project::discover_project_config(
resolution
.resolved_workflow_path
.parent()
.unwrap_or_else(|| Path::new(".")),
)?
.map(|(_, config)| config)
.unwrap_or_default();
Ok(combine_files(project_config, workflow_config))
}
pub fn load_settings_project(start: &Path) -> anyhow::Result<SettingsFile> {
Ok(project::discover_project_config(start)?
.map(|(_, config)| config)
.unwrap_or_default())
}
pub fn load_settings_user() -> anyhow::Result<SettingsFile> {
user::load_settings_config(None)
}
pub(crate) fn resolve_goal_file_paths(file: &mut SettingsFile, base_dir: &Path) {
let Some(run) = file.run.as_mut() else {
return;
};
let Some(RunGoalLayer::File { file: goal_file }) = run.goal.as_mut() else {
return;
};
if !goal_file.is_literal() {
return;
}
let literal = goal_file.as_source();
if Path::new(&literal).is_absolute() {
return;
}
let absolute = resolve_goal_file_path(&literal, base_dir);
*goal_file = InterpString::parse(&absolute.to_string_lossy());
}
pub(crate) fn resolve_goal_file_path(path_str: &str, base_dir: &Path) -> PathBuf {
let path = Path::new(path_str);
if path.is_absolute() {
path.to_path_buf()
} else {
base_dir.join(path)
}
}

View file

@ -10,33 +10,35 @@ use std::path::{Path, PathBuf};
use anyhow::{Context, bail};
use serde::Serialize;
use crate::config::ConfigLayer;
use crate::load::load_settings_path;
use crate::run;
use crate::{resolve_project_from_file, resolve_run_from_file, resolve_workflow_from_file};
use fabro_types::settings::SettingsFile;
use fabro_types::settings::{SettingsFile, parse_settings_file};
const CONFIG_FILENAME: &str = "fabro.toml";
#[derive(Clone, Debug)]
pub struct WorkflowPathResolution {
pub resolved_workflow_path: PathBuf,
pub dot_path: PathBuf,
pub workflow_config: Option<ConfigLayer>,
pub workflow_config: Option<SettingsFile>,
pub workflow_toml_path: Option<PathBuf>,
pub workflow_slug: Option<String>,
}
/// Parse a project config from a TOML string.
pub fn parse_project_config(content: &str) -> anyhow::Result<ConfigLayer> {
ConfigLayer::parse(content).context("Failed to parse project config")
pub fn parse_project_config(content: &str) -> anyhow::Result<SettingsFile> {
parse_settings_file(content)
.map_err(|err| anyhow::anyhow!("{err}"))
.context("Failed to parse project config")
}
/// Load a project config from a file path.
///
/// Goes through [`ConfigLayer::load`] so that relative `run.goal.file`
/// Goes through [`load_settings_path`] so that relative `run.goal.file`
/// paths are anchored at the directory of `path` at load time.
pub fn load_project_config(path: &Path) -> anyhow::Result<ConfigLayer> {
let config = ConfigLayer::load(path).context("Failed to parse project config")?;
let root = resolve_project_from_file(config.as_v2())
pub fn load_project_config(path: &Path) -> anyhow::Result<SettingsFile> {
let config = load_settings_path(path).context("Failed to parse project config")?;
let root = resolve_project_from_file(&config)
.map_err(|errors| anyhow::anyhow!("Failed to resolve project settings: {errors:?}"))?
.directory;
tracing::debug!(path = %path.display(), root = %root, "Loaded project config");
@ -45,7 +47,7 @@ pub fn load_project_config(path: &Path) -> anyhow::Result<ConfigLayer> {
/// Walk ancestor directories from `start` looking for `fabro.toml`.
/// Returns the config file path and parsed config, or `None` if not found.
pub fn discover_project_config(start: &Path) -> anyhow::Result<Option<(PathBuf, ConfigLayer)>> {
pub fn discover_project_config(start: &Path) -> anyhow::Result<Option<(PathBuf, SettingsFile)>> {
for ancestor in start.ancestors() {
let candidate = ancestor.join(CONFIG_FILENAME);
if candidate.is_file() {
@ -90,7 +92,7 @@ pub fn resolve_workflow_path(
if path.extension().is_some_and(|ext| ext == "toml") {
match run::load_run_config(&path) {
Ok(cfg) => {
let workflow = resolve_workflow_from_file(cfg.as_v2()).map_err(|errors| {
let workflow = resolve_workflow_from_file(&cfg).map_err(|errors| {
anyhow::anyhow!("Failed to resolve workflow settings: {errors:?}")
})?;
let dot_path = run::resolve_graph_path(&path, &workflow.graph);
@ -336,7 +338,7 @@ fn find_closest_match(input: &str, candidates: &[String]) -> Option<String> {
}
/// Resolve a workflow argument to a DOT path and optional run config.
pub fn resolve_workflow(arg: &Path) -> anyhow::Result<(PathBuf, Option<ConfigLayer>)> {
pub fn resolve_workflow(arg: &Path) -> anyhow::Result<(PathBuf, Option<SettingsFile>)> {
let start = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let resolution = resolve_workflow_path(arg, &start)?;
Ok((resolution.dot_path, resolution.workflow_config))
@ -348,7 +350,6 @@ 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
.as_v2()
.run
.as_ref()
.and_then(|r| r.execution.as_ref())
@ -361,11 +362,11 @@ pub fn is_retro_enabled() -> bool {
/// Resolve the fabro root directory from a config file path and its config.
/// The returned path is the directory containing `fabro.toml` joined with the
/// `project.directory` value (default: `fabro/`).
pub fn resolve_fabro_root(config_path: &Path, config: &ConfigLayer) -> PathBuf {
pub fn resolve_fabro_root(config_path: &Path, config: &SettingsFile) -> PathBuf {
let project_dir = config_path
.parent()
.expect("config_path should have a parent directory");
let root = resolve_project_from_file(config.as_v2())
let root = resolve_project_from_file(config)
.expect("project settings should resolve")
.directory;
project_dir.join(root)
@ -380,8 +381,8 @@ mod tests {
#[test]
fn parse_minimal_config() {
let config = parse_project_config("_version = 1\n").unwrap();
assert_eq!(config.as_v2().version, Some(1));
assert!(config.as_v2().project.is_none());
assert_eq!(config.version, Some(1));
assert!(config.project.is_none());
}
#[test]
@ -396,7 +397,7 @@ directory = "fabro/"
)
.unwrap();
assert_eq!(
resolve_project_from_file(config.as_v2()).unwrap().directory,
resolve_project_from_file(&config).unwrap().directory,
"fabro/"
);
}
@ -414,7 +415,6 @@ retros = true
.unwrap();
assert_eq!(
config
.as_v2()
.run
.as_ref()
.and_then(|r| r.execution.as_ref())
@ -453,7 +453,7 @@ retros = true
let path = tmp.path().join("fabro.toml");
fs::write(&path, "_version = 1\n").unwrap();
let config = load_project_config(&path).unwrap();
assert_eq!(config.as_v2().version, Some(1));
assert_eq!(config.version, Some(1));
}
#[test]
@ -465,7 +465,7 @@ retros = true
let (found_path, config) = discover_project_config(&sub).unwrap().unwrap();
assert_eq!(found_path, tmp.path().join("fabro.toml"));
assert_eq!(config.as_v2().version, Some(1));
assert_eq!(config.version, Some(1));
}
#[test]
@ -485,7 +485,9 @@ file = "prompts/goal.md"
.unwrap();
let config = load_project_config(&path).unwrap();
let Some(RunGoalLayer::File { file }) = config.as_v2().run_goal_layer() else {
let Some(RunGoalLayer::File { file }) =
config.run.as_ref().and_then(|run| run.goal.as_ref())
else {
panic!("expected file variant");
};
let expected = tmp.path().join("prompts").join("goal.md");

View file

@ -7,8 +7,8 @@ mod server;
mod workflow;
use fabro_types::settings::{
CliSettings, FeaturesSettings, ProjectSettings, RunSettings, ServerSettings, SettingsFile,
WorkflowSettings,
CliSettings, FeaturesSettings, InterpString, ProjectSettings, RunSettings, ServerSettings,
Settings, SettingsFile, WorkflowSettings,
};
pub use cli::resolve_cli;
@ -19,93 +19,76 @@ pub use run::resolve_run;
pub use server::resolve_server;
pub use workflow::resolve_workflow;
pub fn resolve_cli_from_file(file: &SettingsFile) -> Result<CliSettings, Vec<ResolveError>> {
pub fn resolve(file: &SettingsFile) -> Result<Settings, Vec<ResolveError>> {
let mut errors = Vec::new();
let layer = file.cli.as_ref().cloned().unwrap_or_default();
let resolved = resolve_cli(&layer, &mut errors);
let project_layer = file.project.clone().unwrap_or_default();
let workflow_layer = file.workflow.clone().unwrap_or_default();
let run_layer = file.run.clone().unwrap_or_default();
let cli_layer = file.cli.clone().unwrap_or_default();
let server_layer = file.server.clone().unwrap_or_default();
let features_layer = file.features.clone().unwrap_or_default();
let settings = Settings {
project: resolve_project(&project_layer, &mut errors),
workflow: resolve_workflow(&workflow_layer, &mut errors),
run: resolve_run(&run_layer, &mut errors),
cli: resolve_cli(&cli_layer, &mut errors),
server: resolve_server(&server_layer, &mut errors),
features: resolve_features(&features_layer, &mut errors),
};
if errors.is_empty() {
Ok(resolved)
Ok(settings)
} else {
Err(errors)
}
}
pub fn resolve_cli_from_file(file: &SettingsFile) -> Result<CliSettings, Vec<ResolveError>> {
resolve(file).map(|settings| settings.cli)
}
pub fn resolve_server_from_file(file: &SettingsFile) -> Result<ServerSettings, Vec<ResolveError>> {
let mut errors = Vec::new();
let layer = file.server.as_ref().cloned().unwrap_or_default();
let resolved = resolve_server(&layer, &mut errors);
if errors.is_empty() {
Ok(resolved)
} else {
Err(errors)
}
resolve(file).map(|settings| settings.server)
}
pub fn resolve_project_from_file(
file: &SettingsFile,
) -> Result<ProjectSettings, Vec<ResolveError>> {
let mut errors = Vec::new();
let layer = file.project.as_ref().cloned().unwrap_or_default();
let resolved = resolve_project(&layer, &mut errors);
if errors.is_empty() {
Ok(resolved)
} else {
Err(errors)
}
resolve(file).map(|settings| settings.project)
}
pub fn resolve_features_from_file(
file: &SettingsFile,
) -> Result<FeaturesSettings, Vec<ResolveError>> {
let mut errors = Vec::new();
let layer = file.features.as_ref().cloned().unwrap_or_default();
let resolved = resolve_features(&layer, &mut errors);
if errors.is_empty() {
Ok(resolved)
} else {
Err(errors)
}
resolve(file).map(|settings| settings.features)
}
pub fn resolve_run_from_file(file: &SettingsFile) -> Result<RunSettings, Vec<ResolveError>> {
let mut errors = Vec::new();
let layer = file.run.as_ref().cloned().unwrap_or_default();
let resolved = resolve_run(&layer, &mut errors);
if errors.is_empty() {
Ok(resolved)
} else {
Err(errors)
}
resolve(file).map(|settings| settings.run)
}
pub fn resolve_workflow_from_file(
file: &SettingsFile,
) -> Result<WorkflowSettings, Vec<ResolveError>> {
let mut errors = Vec::new();
let layer = file.workflow.as_ref().cloned().unwrap_or_default();
let resolved = resolve_workflow(&layer, &mut errors);
if errors.is_empty() {
Ok(resolved)
} else {
Err(errors)
}
resolve(file).map(|settings| settings.workflow)
}
pub(crate) fn require_interp(
value: Option<&fabro_types::settings::InterpString>,
value: Option<&InterpString>,
path: &str,
errors: &mut Vec<ResolveError>,
) -> fabro_types::settings::InterpString {
) -> InterpString {
value.cloned().unwrap_or_else(|| {
errors.push(ResolveError::Missing {
path: path.to_string(),
});
fabro_types::settings::InterpString::parse("")
InterpString::parse("")
})
}
pub(crate) fn parse_socket_addr(
value: &fabro_types::settings::InterpString,
value: &InterpString,
path: &str,
errors: &mut Vec<ResolveError>,
) -> std::net::SocketAddr {
@ -122,8 +105,6 @@ pub(crate) fn parse_socket_addr(
}
}
pub(crate) fn default_interp(
path: impl AsRef<std::path::Path>,
) -> fabro_types::settings::InterpString {
fabro_types::settings::InterpString::parse(&path.as_ref().to_string_lossy())
pub(crate) fn default_interp(path: impl AsRef<std::path::Path>) -> InterpString {
InterpString::parse(&path.as_ref().to_string_lossy())
}

View file

@ -1,15 +1,16 @@
use fabro_types::settings::InterpString;
use fabro_types::settings::run::{
ApprovalMode, ArtifactsSettings, DaytonaDockerfileLayer, DaytonaSettings,
ApprovalMode, ArtifactsSettings, DaytonaDockerfileLayer, DaytonaSandboxLayer, DaytonaSettings,
DaytonaSnapshotSettings, DockerfileSource, GitAuthorSettings, HookAgentMarker, HookDefinition,
HookTlsMode, HookType, InterviewProviderSettings, McpEntryLayer, McpServerSettings,
McpTransport, MergeStrategy, ModelRefOrSplice, NotificationProviderSettings,
HookEntry, HookTlsMode, HookType, InterviewProviderLayer, InterviewProviderSettings,
InterviewsLayer, LocalSandboxSettings, McpEntryLayer, McpServerSettings, McpTransport,
MergeStrategy, ModelRefOrSplice, NotificationProviderLayer, NotificationProviderSettings,
NotificationRouteLayer, NotificationRouteSettings, PullRequestSettings, RunAgentLayer,
RunAgentSettings, RunArtifactsLayer, RunCheckpointLayer, RunCheckpointSettings,
RunExecutionLayer, RunExecutionSettings, RunGitLayer, RunGitSettings, RunGoal, RunGoalLayer,
RunInterviewsSettings, RunLayer, RunMode, RunModelLayer, RunModelSettings, RunPrepareLayer,
RunPrepareSettings, RunSandboxLayer, RunSandboxSettings, RunScmLayer, RunScmSettings,
RunSettings, ScmGitHubSettings, StringOrSplice, TlsMode,
RunPrepareSettings, RunPullRequestLayer, RunSandboxLayer, RunSandboxSettings, RunScmLayer,
RunScmSettings, RunSettings, ScmGitHubSettings, StringOrSplice, TlsMode,
};
use super::ResolveError;
@ -165,10 +166,8 @@ fn resolve_sandbox(
}
}
fn resolve_local_sandbox(
sandbox: &RunSandboxLayer,
) -> fabro_types::settings::run::LocalSandboxSettings {
fabro_types::settings::run::LocalSandboxSettings {
fn resolve_local_sandbox(sandbox: &RunSandboxLayer) -> LocalSandboxSettings {
LocalSandboxSettings {
worktree_mode: sandbox
.local
.as_ref()
@ -177,7 +176,7 @@ fn resolve_local_sandbox(
}
}
fn resolve_daytona(daytona: &fabro_types::settings::run::DaytonaSandboxLayer) -> DaytonaSettings {
fn resolve_daytona(daytona: &DaytonaSandboxLayer) -> DaytonaSettings {
DaytonaSettings {
auto_stop_interval: daytona.auto_stop_interval,
labels: daytona.labels.clone(),
@ -224,16 +223,14 @@ fn resolve_notification_route(route: &NotificationRouteLayer) -> NotificationRou
}
fn resolve_notification_provider(
provider: &fabro_types::settings::run::NotificationProviderLayer,
provider: &NotificationProviderLayer,
) -> NotificationProviderSettings {
NotificationProviderSettings {
channel: provider.channel.clone(),
}
}
fn resolve_interviews(
interviews: Option<&fabro_types::settings::run::InterviewsLayer>,
) -> RunInterviewsSettings {
fn resolve_interviews(interviews: Option<&InterviewsLayer>) -> RunInterviewsSettings {
let Some(interviews) = interviews else {
return RunInterviewsSettings::default();
};
@ -246,9 +243,7 @@ fn resolve_interviews(
}
}
fn resolve_interview_provider(
provider: &fabro_types::settings::run::InterviewProviderLayer,
) -> InterviewProviderSettings {
fn resolve_interview_provider(provider: &InterviewProviderLayer) -> InterviewProviderSettings {
InterviewProviderSettings {
channel: provider.channel.clone(),
}
@ -347,11 +342,7 @@ fn resolve_mcp_command(
.unwrap_or_default()
}
fn resolve_hook(
hook: &fabro_types::settings::run::HookEntry,
index: usize,
errors: &mut Vec<ResolveError>,
) -> HookDefinition {
fn resolve_hook(hook: &HookEntry, index: usize, errors: &mut Vec<ResolveError>) -> HookDefinition {
let variants = [
hook.script.is_some() || hook.command.is_some(),
hook.url.is_some(),
@ -396,7 +387,7 @@ fn resolve_hook(
}
}
fn resolve_hook_type(hook: &fabro_types::settings::run::HookEntry) -> Option<HookType> {
fn resolve_hook_type(hook: &HookEntry) -> Option<HookType> {
if hook.script.is_some() || hook.command.is_some() {
return None;
}
@ -457,9 +448,7 @@ fn resolve_scm(scm: Option<&RunScmLayer>) -> RunScmSettings {
}
}
fn resolve_pull_request(
pull_request: Option<&fabro_types::settings::run::RunPullRequestLayer>,
) -> Option<PullRequestSettings> {
fn resolve_pull_request(pull_request: Option<&RunPullRequestLayer>) -> Option<PullRequestSettings> {
let pull_request = pull_request?;
if !pull_request.enabled.unwrap_or(false) {
return None;

View file

@ -3,14 +3,16 @@ use std::time::Duration;
use fabro_types::settings::InterpString;
use fabro_types::settings::server::{
DiscordIntegrationSettings, GithubIntegrationSettings, GithubOauthSettings,
IntegrationWebhooksSettings, ObjectStoreProvider, ObjectStoreSettings, ServerApiLayer,
ServerApiSettings, ServerArtifactsLayer, ServerArtifactsSettings, ServerAuthApiJwtSettings,
ServerAuthApiMtlsSettings, ServerAuthApiSettings, ServerAuthLayer, ServerAuthSettings,
ServerAuthWebGithubLayer, ServerAuthWebProvidersSettings, ServerAuthWebSettings,
ServerIntegrationsLayer, ServerIntegrationsSettings, ServerLayer, ServerListenLayer,
ServerListenSettings, ServerListenTlsLayer, ServerLoggingSettings, ServerSchedulerSettings,
ServerSettings, ServerSlateDbLayer, ServerSlateDbSettings, ServerStorageSettings,
ServerWebSettings, SlackIntegrationSettings, TeamsIntegrationSettings, TlsConfig,
IntegrationWebhooksSettings, ObjectStoreLocalLayer, ObjectStoreProvider, ObjectStoreS3Layer,
ObjectStoreSettings, ServerApiLayer, ServerApiSettings, ServerArtifactsLayer,
ServerArtifactsSettings, ServerAuthApiJwtSettings, ServerAuthApiMtlsSettings,
ServerAuthApiSettings, ServerAuthLayer, ServerAuthSettings, ServerAuthWebGithubLayer,
ServerAuthWebProvidersSettings, ServerAuthWebSettings, ServerIntegrationsLayer,
ServerIntegrationsSettings, ServerLayer, ServerListenLayer, ServerListenSettings,
ServerListenTlsLayer, ServerLoggingSettings, ServerSchedulerSettings, ServerSettings,
ServerSlateDbLayer, ServerSlateDbSettings, ServerStorageLayer, ServerStorageSettings,
ServerWebLayer, ServerWebSettings, SlackIntegrationSettings, TeamsIntegrationSettings,
TlsConfig,
};
use fabro_util::Home;
@ -49,9 +51,7 @@ pub fn resolve_server(layer: &ServerLayer, errors: &mut Vec<ResolveError>) -> Se
}
}
fn resolve_storage(
layer: Option<&fabro_types::settings::server::ServerStorageLayer>,
) -> ServerStorageSettings {
fn resolve_storage(layer: Option<&ServerStorageLayer>) -> ServerStorageSettings {
ServerStorageSettings {
root: layer
.and_then(|storage| storage.root.clone())
@ -106,10 +106,7 @@ fn resolve_tls(
(Some(TlsConfig { cert, key, ca }), valid)
}
fn resolve_web(
_api: Option<&ServerApiLayer>,
layer: Option<&fabro_types::settings::server::ServerWebLayer>,
) -> ServerWebSettings {
fn resolve_web(_api: Option<&ServerApiLayer>, layer: Option<&ServerWebLayer>) -> ServerWebSettings {
ServerWebSettings {
enabled: layer.and_then(|web| web.enabled).unwrap_or(true),
url: layer
@ -217,15 +214,14 @@ fn resolve_slatedb(
),
flush_interval: layer
.and_then(|slatedb| slatedb.flush_interval)
.map(|duration| duration.as_std())
.unwrap_or_else(|| Duration::from_millis(1)),
.map_or_else(|| Duration::from_millis(1), |duration| duration.as_std()),
}
}
fn resolve_object_store(
provider: ObjectStoreProvider,
local: Option<&fabro_types::settings::server::ObjectStoreLocalLayer>,
s3: Option<&fabro_types::settings::server::ObjectStoreS3Layer>,
local: Option<&ObjectStoreLocalLayer>,
s3: Option<&ObjectStoreS3Layer>,
storage_root: &InterpString,
path_prefix: &str,
errors: &mut Vec<ResolveError>,

View file

@ -1,6 +1,6 @@
//! Workflow / run config loading helpers.
//!
//! Thin wrappers around `ConfigLayer::parse` / `ConfigLayer::load` plus
//! Thin wrappers around `parse_settings_file` / `load_settings_path` plus
//! path resolution for the `[workflow] graph` override. Runtime types
//! that used to be re-exported from here live under
//! `fabro_types::settings::run` now.
@ -9,19 +9,23 @@ use std::path::{Path, PathBuf};
use anyhow::Context;
use crate::config::ConfigLayer;
use crate::load::{load_settings_path, resolve_goal_file_path};
use fabro_types::settings::run::{ResolvedGoalSource, ResolvedRunGoal, RunGoalLayer};
use fabro_types::settings::{SettingsFile, parse_settings_file};
/// Load and parse a run config from a TOML file.
pub fn parse_run_config(contents: &str) -> anyhow::Result<ConfigLayer> {
ConfigLayer::parse(contents).context("Failed to parse run config TOML")
pub fn parse_run_config(contents: &str) -> anyhow::Result<SettingsFile> {
parse_settings_file(contents)
.map_err(|err| anyhow::anyhow!("{err}"))
.context("Failed to parse run config TOML")
}
/// Load and parse a run config from a TOML file.
///
/// Goes through [`ConfigLayer::load`] so that relative `run.goal.file`
/// Goes through [`load_settings_path`] so that relative `run.goal.file`
/// paths are anchored at the directory of `path` at load time.
pub fn load_run_config(path: &Path) -> anyhow::Result<ConfigLayer> {
ConfigLayer::load(path)
pub fn load_run_config(path: &Path) -> anyhow::Result<SettingsFile> {
load_settings_path(path)
.with_context(|| format!("Failed to parse workflow config at {}", path.display()))
}
@ -34,6 +38,71 @@ pub fn resolve_graph_path(workflow_toml: &Path, graph_relative: &str) -> PathBuf
.join(graph_relative)
}
#[derive(Debug)]
pub enum ResolveRunGoalError {
EnvLookup {
var: String,
},
Io {
path: PathBuf,
source: std::io::Error,
},
}
impl std::fmt::Display for ResolveRunGoalError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::EnvLookup { var } => write!(
f,
"run.goal.file references env var `{var}` which is not set"
),
Self::Io { path, source } => {
write!(f, "failed to read goal file {}: {source}", path.display())
}
}
}
}
impl std::error::Error for ResolveRunGoalError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::EnvLookup { .. } => None,
Self::Io { source, .. } => Some(source),
}
}
}
pub fn resolve_run_goal(
settings: &SettingsFile,
base_dir: &Path,
) -> Result<Option<ResolvedRunGoal>, ResolveRunGoalError> {
let Some(goal) = settings.run.as_ref().and_then(|run| run.goal.as_ref()) else {
return Ok(None);
};
match goal {
RunGoalLayer::Inline(text) => Ok(Some(ResolvedRunGoal {
text: text.as_source(),
source: ResolvedGoalSource::Inline,
})),
RunGoalLayer::File { file } => {
let resolved = file
.resolve(|name| std::env::var(name).ok())
.map_err(|err| ResolveRunGoalError::EnvLookup { var: err.name })?;
let path = resolve_goal_file_path(&resolved.value, base_dir);
let text =
std::fs::read_to_string(&path).map_err(|source| ResolveRunGoalError::Io {
path: path.clone(),
source,
})?;
Ok(Some(ResolvedRunGoal {
text,
source: ResolvedGoalSource::File { path },
}))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@ -56,7 +125,9 @@ file = "prompts/goal.md"
.unwrap();
let config = load_run_config(&workflow_toml).unwrap();
let Some(RunGoalLayer::File { file }) = config.as_v2().run_goal_layer() else {
let Some(RunGoalLayer::File { file }) =
config.run.as_ref().and_then(|run| run.goal.as_ref())
else {
panic!("expected file variant");
};
let expected = workflow_dir.join("prompts").join("goal.md");
@ -78,7 +149,9 @@ file = "/etc/fabro/goal.md"
.unwrap();
let config = load_run_config(&workflow_toml).unwrap();
let Some(RunGoalLayer::File { file }) = config.as_v2().run_goal_layer() else {
let Some(RunGoalLayer::File { file }) =
config.run.as_ref().and_then(|run| run.goal.as_ref())
else {
panic!("expected file variant");
};
assert_eq!(file.as_source(), "/etc/fabro/goal.md");

View file

@ -8,8 +8,9 @@ use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use crate::config::ConfigLayer;
use crate::home::Home;
use crate::load::load_settings_path;
use fabro_types::settings::SettingsFile;
pub const SETTINGS_CONFIG_FILENAME: &str = "settings.toml";
pub const LEGACY_USER_CONFIG_FILENAME: &str = "cli.toml";
@ -68,7 +69,7 @@ fn should_warn_about_legacy_user_config(path: &Path) -> bool {
/// 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> {
pub fn load_settings_config(path: Option<&Path>) -> anyhow::Result<SettingsFile> {
if let Some(explicit) = path
.map(Path::to_path_buf)
.or_else(|| std::env::var_os(FABRO_CONFIG_ENV).map(PathBuf::from))
@ -98,12 +99,12 @@ pub fn load_settings_config(path: Option<&Path>) -> anyhow::Result<ConfigLayer>
if default.is_file() {
load_v2_layer_from_path(&default)
} else {
Ok(ConfigLayer::default())
Ok(SettingsFile::default())
}
}
fn load_v2_layer_from_path(path: &Path) -> anyhow::Result<ConfigLayer> {
ConfigLayer::load(path)
fn load_v2_layer_from_path(path: &Path) -> anyhow::Result<SettingsFile> {
load_settings_path(path)
}
#[cfg(test)]

View file

@ -1,7 +1,7 @@
use fabro_config::resolve_cli_from_file;
use fabro_types::settings::SettingsFile;
use fabro_types::settings::cli::{CliTargetSettings, OutputFormat, OutputVerbosity};
use fabro_types::settings::run::AgentPermissions;
use fabro_types::settings::{SettingsFile, parse_settings_file};
#[test]
fn resolves_cli_defaults_from_empty_settings() {
@ -19,7 +19,7 @@ fn resolves_cli_defaults_from_empty_settings() {
#[test]
fn resolves_cli_target_exec_and_output_settings() {
let settings: SettingsFile = fabro_config::ConfigLayer::parse(
let settings: SettingsFile = parse_settings_file(
r#"
_version = 1
@ -57,8 +57,7 @@ check = false
level = "debug"
"#,
)
.expect("fixture should parse")
.into();
.expect("fixture should parse");
let cli = resolve_cli_from_file(&settings).expect("cli settings should resolve");

View file

@ -1,5 +1,5 @@
use fabro_config::resolve_features_from_file;
use fabro_types::settings::SettingsFile;
use fabro_types::settings::{SettingsFile, parse_settings_file};
#[test]
fn resolves_features_defaults_from_empty_settings() {
@ -12,7 +12,7 @@ fn resolves_features_defaults_from_empty_settings() {
#[test]
fn resolves_session_sandboxes_flag() {
let settings: SettingsFile = fabro_config::ConfigLayer::parse(
let settings: SettingsFile = parse_settings_file(
r#"
_version = 1
@ -20,8 +20,7 @@ _version = 1
session_sandboxes = true
"#,
)
.expect("fixture should parse")
.into();
.expect("fixture should parse");
let features = resolve_features_from_file(&settings).expect("features should resolve");

View file

@ -1,5 +1,5 @@
use fabro_config::resolve_project_from_file;
use fabro_types::settings::SettingsFile;
use fabro_types::settings::{SettingsFile, parse_settings_file};
#[test]
fn resolves_project_defaults_from_empty_settings() {
@ -15,7 +15,7 @@ fn resolves_project_defaults_from_empty_settings() {
#[test]
fn resolves_project_directory_and_metadata() {
let settings: SettingsFile = fabro_config::ConfigLayer::parse(
let settings: SettingsFile = parse_settings_file(
r#"
_version = 1
@ -28,8 +28,7 @@ directory = ".fabro"
team = "platform"
"#,
)
.expect("fixture should parse")
.into();
.expect("fixture should parse");
let project = resolve_project_from_file(&settings).expect("project settings should resolve");

View file

@ -0,0 +1,200 @@
use fabro_config::effective_settings::{EffectiveSettingsLayers, EffectiveSettingsMode};
use fabro_types::run::RunRecord;
use fabro_types::run_event::run::RunCreatedProps;
use fabro_types::settings::{SettingsFile, parse_settings_file};
fn parse(source: &str) -> SettingsFile {
parse_settings_file(source).expect("fixture should parse")
}
#[test]
fn resolves_root_settings_defaults() {
let settings =
fabro_config::resolve(&SettingsFile::default()).expect("empty settings should resolve");
assert_eq!(settings.project.directory, "fabro/");
assert_eq!(settings.workflow.graph, "workflow.fabro");
assert_eq!(settings.run.execution.retros, true);
assert_eq!(settings.cli.updates.check, true);
assert_eq!(settings.server.scheduler.max_concurrent_runs, 5);
assert_eq!(settings.features.session_sandboxes, false);
}
#[test]
fn resolve_accumulates_errors_across_namespaces() {
let settings = parse(
r#"
_version = 1
[server.listen]
type = "tcp"
address = "127.0.0.1:3000"
[server.listen.tls]
cert = "/tmp/server.pem"
[server.auth.api.mtls]
enabled = true
[run.sandbox]
provider = "not-a-provider"
"#,
);
let errors = fabro_config::resolve(&settings).expect_err("invalid shape should fail");
let rendered = errors
.into_iter()
.map(|error| error.to_string())
.collect::<Vec<_>>()
.join("\n");
assert!(rendered.contains("server.listen.tls.key"));
assert!(rendered.contains("server.listen.tls.ca"));
assert!(rendered.contains("run.sandbox.provider"));
}
#[test]
fn load_and_resolve_merges_layers_before_resolution() {
let settings = fabro_config::load_and_resolve(
EffectiveSettingsLayers::new(
SettingsFile::default(),
parse(
r#"
_version = 1
[workflow]
graph = "graphs/workflow.dot"
"#,
),
parse(
r#"
_version = 1
[project]
directory = ".fabro"
"#,
),
parse(
r#"
_version = 1
[server.storage]
root = "/srv/fabro"
[run.model]
provider = "openai"
name = "gpt-5"
"#,
),
),
None,
EffectiveSettingsMode::LocalOnly,
)
.expect("layers should load and resolve");
assert_eq!(settings.project.directory, ".fabro");
assert_eq!(settings.workflow.graph, "graphs/workflow.dot");
assert_eq!(settings.server.storage.root.as_source(), "/srv/fabro");
assert_eq!(
settings
.run
.model
.provider
.as_ref()
.map(|value| value.as_source()),
Some("openai".to_string())
);
assert_eq!(
settings
.run
.model
.name
.as_ref()
.map(|value| value.as_source()),
Some("gpt-5".to_string())
);
}
#[test]
fn run_record_round_trips_templated_settings() {
let settings = parse(
r#"
_version = 1
[server.storage]
root = "${env.FABRO_STORAGE}"
"#,
);
let record = RunRecord {
run_id: fabro_types::fixtures::RUN_1,
settings,
graph: fabro_types::graph::Graph::new("test"),
workflow_slug: Some("demo".to_string()),
working_directory: std::path::PathBuf::from("/tmp/project"),
host_repo_path: Some("/tmp/project".to_string()),
repo_origin_url: None,
base_branch: Some("main".to_string()),
labels: std::collections::HashMap::new(),
provenance: None,
manifest_blob: None,
definition_blob: None,
};
let json = serde_json::to_value(&record).expect("record should serialize");
let round_trip: RunRecord = serde_json::from_value(json).expect("record should deserialize");
assert_eq!(
round_trip
.settings
.server
.as_ref()
.and_then(|server| server.storage.as_ref())
.and_then(|storage| storage.root.as_ref())
.map(|value| value.as_source()),
Some("${env.FABRO_STORAGE}".to_string())
);
}
#[test]
fn run_created_props_round_trips_templated_settings() {
let settings = parse(
r#"
_version = 1
[server.integrations.github]
app_id = "${env.GITHUB_APP_ID}"
"#,
);
let event = RunCreatedProps {
settings,
graph: fabro_types::graph::Graph::new("test"),
workflow_source: Some("digraph test { start -> exit }".to_string()),
workflow_config: None,
labels: std::collections::BTreeMap::new(),
run_dir: "/tmp/run".to_string(),
working_directory: "/tmp/project".to_string(),
host_repo_path: Some("/tmp/project".to_string()),
repo_origin_url: None,
base_branch: Some("main".to_string()),
workflow_slug: Some("demo".to_string()),
db_prefix: None,
provenance: None,
manifest_blob: None,
};
let json = serde_json::to_value(&event).expect("event should serialize");
let round_trip: RunCreatedProps =
serde_json::from_value(json).expect("event should deserialize");
assert_eq!(
round_trip
.settings
.server
.as_ref()
.and_then(|server| server.integrations.as_ref())
.and_then(|integrations| integrations.github.as_ref())
.and_then(|github| github.app_id.as_ref())
.map(|value| value.as_source()),
Some("${env.GITHUB_APP_ID}".to_string())
);
}

View file

@ -1,11 +1,8 @@
use fabro_config::ConfigLayer;
use fabro_types::settings::run::{ApprovalMode, RunGoal, RunMode, WorktreeMode};
use fabro_types::settings::{InterpString, SettingsFile};
use fabro_types::settings::{InterpString, SettingsFile, parse_settings_file};
fn parse(source: &str) -> SettingsFile {
ConfigLayer::parse(source)
.expect("fixture should parse")
.into()
parse_settings_file(source).expect("fixture should parse")
}
#[test]

View file

@ -1,12 +1,9 @@
use fabro_config::ConfigLayer;
use fabro_types::settings::server::{ObjectStoreSettings, ServerListenSettings};
use fabro_types::settings::{InterpString, SettingsFile};
use fabro_types::settings::{InterpString, SettingsFile, parse_settings_file};
use fabro_util::Home;
fn parse(source: &str) -> SettingsFile {
ConfigLayer::parse(source)
.expect("fixture should parse")
.into()
parse_settings_file(source).expect("fixture should parse")
}
#[test]

View file

@ -1,5 +1,5 @@
use fabro_config::resolve_workflow_from_file;
use fabro_types::settings::SettingsFile;
use fabro_types::settings::{SettingsFile, parse_settings_file};
#[test]
fn resolves_workflow_defaults_from_empty_settings() {
@ -15,7 +15,7 @@ fn resolves_workflow_defaults_from_empty_settings() {
#[test]
fn resolves_workflow_graph_and_metadata() {
let settings: SettingsFile = fabro_config::ConfigLayer::parse(
let settings: SettingsFile = parse_settings_file(
r#"
_version = 1
@ -28,8 +28,7 @@ graph = "graphs/ship.dot"
tier = "gold"
"#,
)
.expect("fixture should parse")
.into();
.expect("fixture should parse");
let workflow = resolve_workflow_from_file(&settings).expect("workflow settings should resolve");

View file

@ -452,8 +452,8 @@ mod tests {
use axum::http::{Request, StatusCode};
use axum::response::IntoResponse;
use axum::routing::get;
use fabro_config::ConfigLayer;
use fabro_config::resolve_server_from_file;
use fabro_types::settings::parse_settings_file;
use tower::ServiceExt;
use crate::web_auth::SessionCookie;
@ -461,9 +461,7 @@ mod tests {
// --- Fail-closed resolver tests (R52/R53) -----------------------------------
fn settings(source: &str) -> ResolvedServerSettings {
let file = ConfigLayer::parse(source)
.expect("fixture should parse")
.into();
let file = parse_settings_file(source).expect("fixture should parse");
resolve_server_from_file(&file).expect("fixture should resolve")
}

View file

@ -4,25 +4,30 @@ use std::sync::Arc;
use anyhow::{Result, anyhow, bail};
use fabro_api::types;
use fabro_config::ConfigLayer;
use fabro_config::effective_settings;
use fabro_config::effective_settings::{EffectiveSettingsLayers, EffectiveSettingsMode};
use fabro_config::merge::combine_files;
use fabro_config::project::resolve_working_directory;
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::config::{
DaytonaNetwork, DaytonaSnapshotSettings, DockerfileSource as SandboxDockerfileSource,
};
use fabro_sandbox::daytona::DaytonaConfig;
use fabro_sandbox::{DockerSandboxOptions, Sandbox, SandboxProvider, SandboxSpec};
use fabro_types::RunId;
use fabro_types::settings::SettingsFile;
use fabro_types::settings::ServerSettings;
use fabro_types::settings::cli::{CliLayer, CliOutputLayer, OutputVerbosity};
use fabro_types::settings::interp::InterpString;
use fabro_types::settings::run::{
ApprovalMode, DaytonaDockerfileLayer, RunExecutionLayer, RunGoalLayer, RunLayer, RunMode,
RunModelLayer, RunSandboxLayer,
ApprovalMode, DaytonaDockerfileLayer, DaytonaNetworkLayer, DaytonaSettings, DockerfileSource,
RunExecutionLayer, RunGoalLayer, RunLayer, RunMode, RunModelLayer, RunSandboxLayer,
RunSettings,
};
use fabro_types::settings::{SettingsFile, parse_settings_file};
use fabro_util::check_report::{CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus};
use fabro_validate::Severity;
use fabro_workflow::error::FabroError;
@ -70,15 +75,15 @@ pub(crate) fn prepare_manifest_with_mode(
.configs
.iter()
.filter(|config| config.type_ == types::ManifestConfigType::Project)
.try_fold(ConfigLayer::default(), |layer, config| {
Ok::<_, anyhow::Error>(parse_manifest_config(config)?.combine(layer))
.try_fold(SettingsFile::default(), |layer, config| {
Ok::<_, anyhow::Error>(combine_files(layer, parse_manifest_config(config)?))
})?;
let user_layer = manifest
.configs
.iter()
.filter(|config| config.type_ == types::ManifestConfigType::User)
.try_fold(ConfigLayer::default(), |layer, config| {
Ok::<_, anyhow::Error>(parse_manifest_config(config)?.combine(layer))
.try_fold(SettingsFile::default(), |layer, config| {
Ok::<_, anyhow::Error>(combine_files(layer, parse_manifest_config(config)?))
})?;
let mut settings = effective_settings::resolve_settings(
EffectiveSettingsLayers::new(args_layer, workflow_layer, project_layer, user_layer),
@ -193,12 +198,12 @@ fn workflow_bundle_from_manifest(
fn root_workflow_config_layer(
manifest: &types::RunManifest,
workflow: &BundledWorkflow,
) -> Result<ConfigLayer> {
) -> Result<SettingsFile> {
let Some(root) = manifest.workflows.get(&manifest.target.path) else {
bail!("manifest target path is missing from workflows map");
};
let Some(config) = root.config.as_ref() else {
return Ok(ConfigLayer::default());
return Ok(SettingsFile::default());
};
let mut layer = parse_run_config(&config.source)?;
@ -206,16 +211,16 @@ fn root_workflow_config_layer(
Ok(layer)
}
fn parse_manifest_config(config: &types::ManifestConfig) -> Result<ConfigLayer> {
fn parse_manifest_config(config: &types::ManifestConfig) -> Result<SettingsFile> {
let Some(source) = config.source.as_deref() else {
return Ok(ConfigLayer::default());
return Ok(SettingsFile::default());
};
ConfigLayer::parse(source)
parse_settings_file(source).map_err(|err| anyhow!("Failed to parse settings file: {err}"))
}
fn manifest_args_layer(args: Option<&types::ManifestArgs>) -> ConfigLayer {
fn manifest_args_layer(args: Option<&types::ManifestArgs>) -> SettingsFile {
let Some(args) = args else {
return ConfigLayer::default();
return SettingsFile::default();
};
let model = (args.model.is_some() || args.provider.is_some()).then(|| RunModelLayer {
@ -268,11 +273,11 @@ fn manifest_args_layer(args: Option<&types::ManifestArgs>) -> ConfigLayer {
})
});
ConfigLayer::from(SettingsFile {
SettingsFile {
run,
cli,
..SettingsFile::default()
})
}
}
fn parse_labels(labels: &[String]) -> HashMap<String, String> {
@ -284,12 +289,11 @@ fn parse_labels(labels: &[String]) -> HashMap<String, String> {
}
fn resolve_manifest_dockerfile(
layer: &mut ConfigLayer,
layer: &mut SettingsFile,
config_path: &Path,
files: &HashMap<PathBuf, String>,
) -> Result<()> {
let source = layer
.as_v2_mut()
.run
.as_mut()
.and_then(|run| run.sandbox.as_mut())
@ -340,12 +344,22 @@ async fn build_preflight_report(
) -> Result<(CheckReport, bool)> {
let graph = validated.graph();
let settings = &prepared.settings;
let materialized = materialize_run(settings.clone(), graph, &Catalog::builtin());
let materialized = materialize_run(settings.clone(), graph, Catalog::builtin());
let resolved_run = fabro_config::resolve_run_from_file(&materialized)
.map_err(|errors| anyhow!(render_resolve_errors(&errors)))?;
let resolved_server = fabro_config::resolve_server_from_file(settings)
.map_err(|errors| anyhow!(render_resolve_errors(&errors)))?;
let sandbox_provider = resolve_sandbox_provider(&resolved_run)?;
let github_app = state
.github_app_credentials(settings.github_app_id_str().as_deref())
.github_app_credentials(
resolved_server
.integrations
.github
.app_id
.as_ref()
.map(InterpString::as_source)
.as_deref(),
)
.await
.map_err(|err| anyhow!(err))?;
let mut checks = Vec::new();
@ -402,7 +416,7 @@ async fn build_preflight_report(
)
.await;
let llm_ok = run_llm_check(state, &mut checks, graph, &resolved_run).await;
run_github_token_check(&mut checks, prepared, settings, github_app).await;
run_github_token_check(&mut checks, prepared, &resolved_server, github_app).await;
let checks_ok = sandbox_ok && llm_ok;
@ -418,19 +432,16 @@ async fn build_preflight_report(
))
}
fn resolve_sandbox_provider(
settings: &fabro_types::settings::run::RunSettings,
) -> Result<SandboxProvider> {
Ok(Some(settings.sandbox.provider.as_str())
.map(str::parse::<SandboxProvider>)
.transpose()
.map_err(|err| anyhow!("Invalid sandbox provider: {err}"))?
.unwrap_or_default())
fn resolve_sandbox_provider(settings: &RunSettings) -> Result<SandboxProvider> {
Ok(Some(str::parse::<SandboxProvider>(
settings.sandbox.provider.as_str(),
))
.transpose()
.map_err(|err| anyhow!("Invalid sandbox provider: {err}"))?
.unwrap_or_default())
}
fn resolve_daytona_config(
settings: &fabro_types::settings::run::RunSettings,
) -> Option<DaytonaConfig> {
fn resolve_daytona_config(settings: &RunSettings) -> Option<DaytonaConfig> {
settings
.sandbox
.daytona
@ -442,7 +453,7 @@ async fn run_sandbox_check(
checks: &mut Vec<CheckResult>,
sandbox_provider: SandboxProvider,
prepared: &PreparedManifest,
resolved_run: &fabro_types::settings::run::RunSettings,
resolved_run: &RunSettings,
github_app: Option<fabro_github::GitHubAppCredentials>,
) -> bool {
let daytona_config = resolve_daytona_config(resolved_run);
@ -515,7 +526,7 @@ async fn run_llm_check(
state: &AppState,
checks: &mut Vec<CheckResult>,
graph: &Graph,
settings: &fabro_types::settings::run::RunSettings,
settings: &RunSettings,
) -> bool {
let (model, provider) = resolve_model_provider(settings, graph);
let default_provider = provider.as_deref().unwrap_or("anthropic");
@ -606,21 +617,16 @@ async fn run_llm_check(
}
}
fn resolve_model_provider(
settings: &fabro_types::settings::run::RunSettings,
_graph: &Graph,
) -> (String, Option<String>) {
fn resolve_model_provider(settings: &RunSettings, _graph: &Graph) -> (String, Option<String>) {
let provider = settings
.model
.provider
.as_ref()
.map(InterpString::as_source);
let model = settings
.model
.name
.as_ref()
.map(InterpString::as_source)
.unwrap_or_else(|| Catalog::builtin().default_from_env().id.clone());
let model = settings.model.name.as_ref().map_or_else(
|| Catalog::builtin().default_from_env().id.clone(),
InterpString::as_source,
);
match Catalog::builtin().get(&model) {
Some(info) => (
@ -639,12 +645,14 @@ fn render_resolve_errors(errors: &[fabro_config::ResolveError]) -> String {
.join("; ")
}
fn runtime_daytona_config(settings: &fabro_types::settings::run::DaytonaSettings) -> DaytonaConfig {
fn runtime_daytona_config(settings: &DaytonaSettings) -> DaytonaConfig {
DaytonaConfig {
auto_stop_interval: settings.auto_stop_interval,
labels: (!settings.labels.is_empty()).then_some(settings.labels.clone()),
snapshot: settings.snapshot.as_ref().map(|snapshot| {
fabro_sandbox::config::DaytonaSnapshotSettings {
snapshot: settings
.snapshot
.as_ref()
.map(|snapshot| DaytonaSnapshotSettings {
name: snapshot.name.clone(),
cpu: snapshot.cpu,
memory: snapshot.memory_gb,
@ -653,24 +661,19 @@ fn runtime_daytona_config(settings: &fabro_types::settings::run::DaytonaSettings
.dockerfile
.as_ref()
.map(|dockerfile| match dockerfile {
fabro_types::settings::run::DockerfileSource::Inline(text) => {
fabro_sandbox::config::DockerfileSource::Inline(text.clone())
DockerfileSource::Inline(text) => {
SandboxDockerfileSource::Inline(text.clone())
}
fabro_types::settings::run::DockerfileSource::Path { path } => {
fabro_sandbox::config::DockerfileSource::Path { path: path.clone() }
DockerfileSource::Path { path } => {
SandboxDockerfileSource::Path { path: path.clone() }
}
}),
}
}),
}),
network: settings.network.as_ref().map(|network| match network {
fabro_types::settings::run::DaytonaNetworkLayer::Block => {
fabro_sandbox::config::DaytonaNetwork::Block
}
fabro_types::settings::run::DaytonaNetworkLayer::AllowAll => {
fabro_sandbox::config::DaytonaNetwork::AllowAll
}
fabro_types::settings::run::DaytonaNetworkLayer::AllowList { allow_list } => {
fabro_sandbox::config::DaytonaNetwork::AllowList(allow_list.clone())
DaytonaNetworkLayer::Block => DaytonaNetwork::Block,
DaytonaNetworkLayer::AllowAll => DaytonaNetwork::AllowAll,
DaytonaNetworkLayer::AllowList { allow_list } => {
DaytonaNetwork::AllowList(allow_list.clone())
}
}),
skip_clone: settings.skip_clone,
@ -680,19 +683,19 @@ fn runtime_daytona_config(settings: &fabro_types::settings::run::DaytonaSettings
async fn run_github_token_check(
checks: &mut Vec<CheckResult>,
prepared: &PreparedManifest,
settings: &SettingsFile,
settings: &ServerSettings,
github_app: Option<fabro_github::GitHubAppCredentials>,
) {
let Some(v2_permissions) = settings.github_permissions() else {
return;
};
if v2_permissions.is_empty() {
if settings.integrations.github.permissions.is_empty() {
return;
}
// Resolve InterpString permission values eagerly for token minting and
// for display in the preflight report.
let github_permissions: HashMap<String, String> = v2_permissions
let github_permissions: HashMap<String, String> = settings
.integrations
.github
.permissions
.iter()
.map(|(k, v)| (k.clone(), v.as_source()))
.collect();
@ -863,9 +866,7 @@ mod tests {
}
fn server_settings_fixture(source: &str) -> SettingsFile {
fabro_config::ConfigLayer::parse(source)
.expect("v2 fixture should parse")
.into()
fabro_types::settings::parse_settings_file(source).expect("v2 fixture should parse")
}
#[test]
@ -885,11 +886,10 @@ root = "/srv/fabro"
let prepared =
prepare_manifest_with_mode(&server_settings, &minimal_manifest(), false).unwrap();
assert!(!prepared.settings.dry_run_enabled());
assert_eq!(
prepared.settings.server_storage_root_str().as_deref(),
Some("/srv/fabro"),
);
let resolved_run = fabro_config::resolve_run_from_file(&prepared.settings).unwrap();
let resolved_server = fabro_config::resolve_server_from_file(&prepared.settings).unwrap();
assert!(resolved_run.execution.mode != fabro_types::settings::run::RunMode::DryRun);
assert_eq!(resolved_server.storage.root.as_source(), "/srv/fabro");
}
#[test]
@ -920,7 +920,13 @@ root = "/srv/fabro"
let prepared = prepare_manifest_with_mode(&server_settings, &manifest, false).unwrap();
assert!(prepared.settings.dry_run_enabled());
assert_eq!(
fabro_config::resolve_run_from_file(&prepared.settings)
.unwrap()
.execution
.mode,
fabro_types::settings::run::RunMode::DryRun
);
}
#[test]
@ -970,20 +976,25 @@ app_id = "snapshotted-app-id"
});
let prepared = prepare_manifest_with_mode(&server_settings, &manifest, true).unwrap();
let resolved_run = fabro_config::resolve_run_from_file(&prepared.settings).unwrap();
let resolved_server = fabro_config::resolve_server_from_file(&prepared.settings).unwrap();
// v2 merge matrix: run.prepare.steps replaces the whole list across
// layers, so the higher-precedence workflow layer wins over cli.
assert_eq!(
prepared.settings.run_prepare_commands(),
resolved_run.prepare.commands,
vec!["workflow-setup".to_string()]
);
assert_eq!(
prepared.settings.github_app_id_str().as_deref(),
resolved_server
.integrations
.github
.app_id
.as_ref()
.map(|value| value.as_source())
.as_deref(),
Some("snapshotted-app-id")
);
assert_eq!(
prepared.settings.server_storage_root_str().as_deref(),
Some("/srv/fabro"),
);
assert_eq!(resolved_server.storage.root.as_source(), "/srv/fabro");
}
}

View file

@ -85,7 +85,7 @@ pub struct ServeArgs {
}
fn load_settings(path: Option<&Path>) -> anyhow::Result<SettingsFile> {
Ok(load_settings_config(path)?.into())
load_settings_config(path)
}
fn resolved_config_path(path: Option<&Path>) -> PathBuf {
@ -363,7 +363,7 @@ where
.github
.webhooks
.as_ref()
.and_then(|_| resolved_server_settings.integrations.github.app_id.as_ref())
.and(resolved_server_settings.integrations.github.app_id.as_ref())
.map(resolve_interp)
.transpose()?;
let webhook_manager = match webhook_app_id {
@ -695,13 +695,10 @@ mod tests {
build_object_store_with_preference, server_bind_title, server_title,
};
use crate::bind::Bind;
use fabro_config::ConfigLayer;
use fabro_types::settings::SettingsFile;
use fabro_types::settings::{SettingsFile, parse_settings_file};
fn parse_settings(source: &str) -> SettingsFile {
ConfigLayer::parse(source)
.expect("v2 fixture should parse")
.into()
parse_settings_file(source).expect("v2 fixture should parse")
}
#[test]
@ -722,10 +719,13 @@ mod tests {
let resolved =
apply_runtime_settings(&base, &args, false, &PathBuf::from("/srv/fabro-storage"));
assert_eq!(
resolved.server_storage_root_str().as_deref(),
Some("/srv/fabro-storage")
);
let storage_root = resolved
.server
.as_ref()
.and_then(|server| server.storage.as_ref())
.and_then(|storage| storage.root.as_ref())
.map(|value| value.as_source());
assert_eq!(storage_root.as_deref(), Some("/srv/fabro-storage"));
}
#[test]
@ -752,7 +752,14 @@ enabled = false
let resolved = apply_runtime_settings(&base, &args, false, &PathBuf::from("/srv/fabro"));
assert_eq!(resolved.server_web().and_then(|w| w.enabled), Some(true));
assert_eq!(
resolved
.server
.as_ref()
.and_then(|server| server.web.as_ref())
.and_then(|web| web.enabled),
Some(true)
);
}
#[test]
@ -772,7 +779,14 @@ enabled = false
let resolved = apply_runtime_settings(&base, &args, false, &PathBuf::from("/srv/fabro"));
assert_eq!(resolved.server_web().and_then(|w| w.enabled), Some(false));
assert_eq!(
resolved
.server
.as_ref()
.and_then(|server| server.web.as_ref())
.and_then(|web| web.enabled),
Some(false)
);
}
#[test]

View file

@ -34,6 +34,7 @@ use fabro_model::{BilledModelUsage, BilledTokenCounts};
use fabro_store::{
ArtifactStore, Database, EventEnvelope, EventPayload, PendingInterviewRecord, StageId,
};
use fabro_types::settings::run::RunMode;
use fabro_types::settings::{InterpString, ServerSettings as ResolvedServerSettings, SettingsFile};
use fabro_types::{
ActorRef, EventBody, InterviewQuestionRecord, InterviewQuestionType, RunBlobId,
@ -595,7 +596,7 @@ impl AppState {
pub(crate) fn dry_run(&self) -> bool {
fabro_config::resolve_run_from_file(&self.settings.read().unwrap())
.map(|settings| settings.execution.mode == fabro_types::settings::run::RunMode::DryRun)
.map(|settings| settings.execution.mode == RunMode::DryRun)
.unwrap_or(false)
}
@ -710,7 +711,7 @@ impl AppState {
}
pub(crate) fn reload_settings_from_disk(&self) -> anyhow::Result<()> {
let reloaded: SettingsFile = fabro_config::ConfigLayer::load(&self.config_path)?.into();
let reloaded = fabro_config::load_settings_path(&self.config_path)?;
self.replace_settings(reloaded)
}
}
@ -1467,9 +1468,45 @@ fn build_prune_plan(
}
fn system_sandbox_provider(settings: &SettingsFile) -> String {
fabro_config::resolve_run_from_file(settings)
.map(|settings| settings.sandbox.provider)
.unwrap_or_else(|_| SandboxProvider::default().to_string())
fabro_config::resolve_run_from_file(settings).map_or_else(
|_| SandboxProvider::default().to_string(),
|settings| settings.sandbox.provider,
)
}
fn render_resolve_errors(errors: &[fabro_config::ResolveError]) -> String {
errors
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("; ")
}
fn resolved_storage_dir(settings: &SettingsFile) -> Result<PathBuf, String> {
let resolved =
resolve_server_from_file(settings).map_err(|errors| render_resolve_errors(&errors))?;
resolved
.storage
.root
.resolve(|name| std::env::var(name).ok())
.map(|value| PathBuf::from(value.value))
.map_err(|err| {
format!(
"failed to resolve {}: {err}",
resolved.storage.root.as_source()
)
})
}
fn resolved_github_app_id(settings: &SettingsFile) -> Result<Option<String>, String> {
let resolved =
resolve_server_from_file(settings).map_err(|errors| render_resolve_errors(&errors))?;
Ok(resolved
.integrations
.github
.app_id
.as_ref()
.map(InterpString::as_source))
}
fn parse_system_duration(raw: &str) -> anyhow::Result<chrono::Duration> {
@ -3488,10 +3525,19 @@ async fn start_run(
)
.into_response();
};
let run_dir = Storage::new(run_record.settings.storage_dir())
.run_scratch(&id)
.root()
.to_path_buf();
let run_dir = match resolved_storage_dir(&run_record.settings) {
Ok(storage_dir) => Storage::new(storage_dir)
.run_scratch(&id)
.root()
.to_path_buf(),
Err(err) => {
return ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
format!("invalid persisted server storage settings: {err}"),
)
.into_response();
}
};
let dot_source = run_state.graph_source.unwrap_or_default();
{
@ -3649,16 +3695,21 @@ async fn execute_run_in_process(state: Arc<AppState>, run_id: RunId) {
return;
}
};
let github_app = match state
.github_app_credentials(
persisted
.run_record()
.settings
.github_app_id_str()
.as_deref(),
)
.await
{
let github_app_id = match resolved_github_app_id(&persisted.run_record().settings) {
Ok(app_id) => app_id,
Err(err) => {
tracing::error!(run_id = %run_id, error = %err, "Invalid GitHub App config");
let mut runs = state.runs.lock().expect("runs lock poisoned");
if let Some(managed_run) = runs.get_mut(&run_id) {
managed_run.status = RunStatus::Failed;
managed_run.error = Some(format!("Invalid GitHub App config: {err}"));
clear_live_run_state(managed_run);
}
state.scheduler_notify.notify_one();
return;
}
};
let github_app = match state.github_app_credentials(github_app_id.as_deref()).await {
Ok(github_app) => github_app,
Err(e) => {
tracing::error!(run_id = %run_id, error = %e, "Invalid GitHub App credentials");
@ -6299,7 +6350,7 @@ mod tests {
#[tokio::test]
async fn auth_login_github_redirects_to_github() {
let settings: SettingsFile = fabro_config::ConfigLayer::parse(
let settings: SettingsFile = fabro_types::settings::parse_settings_file(
r#"
_version = 1
@ -6313,8 +6364,7 @@ client_id = "Iv1.testclient"
slug = "fabro"
"#,
)
.expect("fixture should parse")
.into();
.expect("fixture should parse");
let app = build_router(
create_app_state_with_options(settings, 5),
AuthMode::Disabled,
@ -7440,7 +7490,7 @@ slug = "fabro"
#[tokio::test]
async fn start_run_persists_full_settings_snapshot() {
let settings: SettingsFile = fabro_config::ConfigLayer::parse(
let settings: SettingsFile = fabro_types::settings::parse_settings_file(
r#"
_version = 1
@ -7479,8 +7529,7 @@ url = "http://api.example.test"
level = "debug"
"#,
)
.expect("fixture should parse")
.into();
.expect("fixture should parse");
let state = create_app_state_with_options(settings, 5);
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
@ -7512,24 +7561,41 @@ level = "debug"
.unwrap()
.run
.expect("run record should exist");
let resolved_run = fabro_config::resolve_run_from_file(&run_record.settings).unwrap();
let resolved_server = fabro_config::resolve_server_from_file(&run_record.settings).unwrap();
// Server-side `dry_run` default must not override the manifest's intent.
// Verify a sampling of the persisted v2 settings.
assert_eq!(
run_record.settings.run_goal_inline_str().as_deref(),
match resolved_run.goal {
Some(fabro_types::settings::run::RunGoal::Inline(value)) => Some(value.as_source()),
_ => None,
}
.as_deref(),
Some("Test"),
"goal should be persisted from the manifest"
);
assert!(
!run_record.settings.dry_run_enabled(),
resolved_run.execution.mode != fabro_types::settings::run::RunMode::DryRun,
"server-local dry_run fallback must not override manifest intent"
);
assert_eq!(
run_record.settings.run_model_name_str().as_deref(),
resolved_run
.model
.name
.as_ref()
.map(|value| value.as_source())
.as_deref(),
Some("claude-sonnet-4-5"),
);
assert_eq!(
run_record.settings.github_app_id_str().as_deref(),
resolved_server
.integrations
.github
.app_id
.as_ref()
.map(|value| value.as_source())
.as_deref(),
Some("12345"),
);
}
@ -7894,7 +7960,7 @@ level = "debug"
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn cancel_during_startup_persists_cancelled_reason() {
let settings: SettingsFile = fabro_config::ConfigLayer::parse(
let settings: SettingsFile = fabro_types::settings::parse_settings_file(
r#"
_version = 1
@ -7905,8 +7971,7 @@ script = "sleep 5"
timeout = "30s"
"#,
)
.expect("fixture should parse")
.into();
.expect("fixture should parse");
let state = create_app_state_with_settings_and_registry_factory(settings, |interviewer| {
fabro_workflow::handler::default_registry(interviewer, || None)
});

View file

@ -73,12 +73,10 @@ pub(crate) fn redact_for_api(settings: &SettingsFile) -> SettingsFile {
#[cfg(test)]
mod tests {
use super::*;
use fabro_config::ConfigLayer;
use fabro_types::settings::parse_settings_file;
fn parse(source: &str) -> SettingsFile {
ConfigLayer::parse(source)
.expect("fixture should parse")
.into()
parse_settings_file(source).expect("fixture should parse")
}
#[test]

View file

@ -781,13 +781,9 @@ mod tests {
// Re-parse the emitted document to prove it round-trips into a
// valid v2 `SettingsFile`.
let emitted = doc.to_string();
let file = fabro_config::ConfigLayer::parse(&emitted)
let file = fabro_types::settings::parse_settings_file(&emitted)
.expect("merged output should parse as a v2 SettingsFile");
let server = file
.as_v2()
.server
.as_ref()
.expect("[server] should be present");
let server = file.server.as_ref().expect("[server] should be present");
let integrations = server
.integrations
.as_ref()
@ -869,7 +865,7 @@ name = "claude-sonnet"
// Finally, the whole thing must still parse as a valid v2
// SettingsFile.
fabro_config::ConfigLayer::parse(&emitted)
fabro_types::settings::parse_settings_file(&emitted)
.expect("merged output should still parse as v2 after the edit");
}
}

View file

@ -1,12 +1,11 @@
use axum::body::{Body, to_bytes};
use axum::http::{Method, Request, StatusCode};
use fabro_config::ConfigLayer;
use fabro_server::jwt_auth::AuthMode;
use fabro_server::server::{
RouterOptions, build_router, build_router_with_options, create_app_state,
create_app_state_with_options,
};
use fabro_types::settings::SettingsFile;
use fabro_types::settings::{SettingsFile, parse_settings_file};
use tower::ServiceExt;
use crate::helpers::body_json;
@ -121,7 +120,7 @@ async fn web_enabled_serves_web_only_routes() {
#[tokio::test]
async fn web_disabled_returns_404_for_web_routes_and_keeps_machine_api() {
let settings: SettingsFile = ConfigLayer::parse(
let settings: SettingsFile = parse_settings_file(
r#"
_version = 1
@ -129,8 +128,7 @@ _version = 1
enabled = false
"#,
)
.expect("settings fixture should parse")
.into();
.expect("settings fixture should parse");
let app = build_router_with_options(
create_app_state_with_options(settings, 5),
AuthMode::Disabled,
@ -179,7 +177,7 @@ enabled = false
#[tokio::test]
async fn web_disabled_ignores_demo_header_dispatch() {
let settings: SettingsFile = ConfigLayer::parse(
let settings: SettingsFile = parse_settings_file(
r#"
_version = 1
@ -187,8 +185,7 @@ _version = 1
enabled = false
"#,
)
.expect("settings fixture should parse")
.into();
.expect("settings fixture should parse");
let app = build_router_with_options(
create_app_state_with_options(settings, 5),
AuthMode::Disabled,

View file

@ -1,16 +1,15 @@
use axum::body::Body;
use axum::http::{Request, StatusCode};
use fabro_config::ConfigLayer;
use fabro_server::jwt_auth::AuthMode;
use fabro_server::server::{build_router, create_app_state_with_options};
use fabro_types::settings::SettingsFile;
use fabro_types::settings::{SettingsFile, parse_settings_file};
use tower::ServiceExt;
use crate::helpers::body_json;
#[tokio::test]
async fn retrieve_server_settings_returns_runtime_settings() {
let settings: SettingsFile = ConfigLayer::parse(
let settings: SettingsFile = parse_settings_file(
r#"
_version = 1
@ -27,8 +26,7 @@ verbosity = "verbose"
server_only = "1"
"#,
)
.expect("settings fixture should parse")
.into();
.expect("settings fixture should parse");
let app = build_router(
create_app_state_with_options(settings, 5),
AuthMode::Disabled,

View file

@ -1,593 +0,0 @@
//! Convenience accessors on [`SettingsFile`].
//!
//! These methods provide ergonomic, flat-shaped views into the v2 parse
//! tree. They exist so that consumers don't have to chain `.as_ref()`
//! through every Option layer when reading common fields. Each accessor
//! walks the real v2 structure — there is no transitional state here.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use super::interp::InterpString;
use super::run::{
ApprovalMode, GitAuthorLayer, HookEntry, McpEntryLayer, ResolvedGoalSource, ResolvedRunGoal,
RunAgentLayer, RunArtifactsLayer, RunCheckpointLayer, RunExecutionLayer, RunGoalLayer,
RunLayer, RunMode, RunModelLayer, RunPrepareLayer, RunPullRequestLayer, RunSandboxLayer,
};
use super::server::{
GithubIntegrationLayer, ServerApiLayer, ServerArtifactsLayer, ServerIntegrationsLayer,
ServerLayer, ServerLoggingLayer, ServerSchedulerLayer, ServerStorageLayer, ServerWebLayer,
SlackIntegrationLayer,
};
use super::tree::SettingsFile;
impl SettingsFile {
// ---------- run-scope ----------
#[must_use]
pub fn run_layer(&self) -> Option<&RunLayer> {
self.run.as_ref()
}
/// Raw access to the `run.goal` variant (inline or file).
#[must_use]
pub fn run_goal_layer(&self) -> Option<&RunGoalLayer> {
self.run.as_ref().and_then(|r| r.goal.as_ref())
}
/// Inline goal text only. Returns `None` when `run.goal` is unset **or**
/// when it's a file-sourced goal — callers that need the file contents
/// should use [`SettingsFile::resolve_run_goal`].
#[must_use]
pub fn run_goal_inline_str(&self) -> Option<String> {
match self.run_goal_layer()? {
RunGoalLayer::Inline(s) => Some(s.as_source()),
RunGoalLayer::File { .. } => None,
}
}
/// Resolve the `run.goal` layer to its final text, reading a file from
/// disk if necessary.
///
/// Path resolution:
///
/// - Absolute paths in the `file` variant are used as-is.
/// - Literal relative paths should already have been rewritten to
/// absolute at config-load time by
/// `fabro_config::resolve_goal_file_paths`. If one reaches this point
/// it will be resolved against `base_dir` as a fallback.
/// - `${env.NAME}` interpolation is resolved via `std::env::var` at
/// call time. Relative paths that survive interpolation are also
/// resolved against `base_dir`.
///
/// Returns `Ok(None)` when `run.goal` is unset. Returns `Err` when the
/// file variant points at a path that can't be read or has an
/// unresolved env token.
pub fn resolve_run_goal(
&self,
base_dir: &Path,
) -> Result<Option<ResolvedRunGoal>, ResolveGoalError> {
let Some(layer) = self.run_goal_layer() else {
return Ok(None);
};
match layer {
RunGoalLayer::Inline(s) => Ok(Some(ResolvedRunGoal {
text: s.as_source(),
source: ResolvedGoalSource::Inline,
})),
RunGoalLayer::File { file } => {
let resolved = file
.resolve(|name| std::env::var(name).ok())
.map_err(|err| ResolveGoalError::EnvLookup { var: err.name })?;
let path = resolve_goal_file_path(&resolved.value, base_dir);
let text = std::fs::read_to_string(&path).map_err(|err| ResolveGoalError::Io {
path: path.clone(),
source: err,
})?;
Ok(Some(ResolvedRunGoal {
text,
source: ResolvedGoalSource::File { path },
}))
}
}
}
#[must_use]
pub fn run_working_dir(&self) -> Option<&InterpString> {
self.run.as_ref().and_then(|r| r.working_dir.as_ref())
}
#[must_use]
pub fn run_working_dir_str(&self) -> Option<String> {
self.run_working_dir().map(InterpString::as_source)
}
#[must_use]
pub fn run_model(&self) -> Option<&RunModelLayer> {
self.run.as_ref().and_then(|r| r.model.as_ref())
}
#[must_use]
pub fn run_model_name_str(&self) -> Option<String> {
self.run_model()
.and_then(|m| m.name.as_ref())
.map(InterpString::as_source)
}
#[must_use]
pub fn run_model_provider_str(&self) -> Option<String> {
self.run_model()
.and_then(|m| m.provider.as_ref())
.map(InterpString::as_source)
}
#[must_use]
pub fn run_sandbox(&self) -> Option<&RunSandboxLayer> {
self.run.as_ref().and_then(|r| r.sandbox.as_ref())
}
#[must_use]
pub fn run_prepare(&self) -> Option<&RunPrepareLayer> {
self.run.as_ref().and_then(|r| r.prepare.as_ref())
}
/// Flattened prepare-step commands: each `script` is kept as-is, and
/// `command` argv is joined with spaces. Env-interpolation tokens are
/// emitted verbatim via [`InterpString::as_source`].
#[must_use]
pub fn run_prepare_commands(&self) -> Vec<String> {
let Some(prepare) = self.run_prepare() else {
return Vec::new();
};
prepare
.steps
.iter()
.filter_map(|step| {
if let Some(script) = &step.script {
Some(script.as_source())
} else {
step.command.as_ref().map(|argv| {
argv.iter()
.map(InterpString::as_source)
.collect::<Vec<_>>()
.join(" ")
})
}
})
.collect()
}
/// Prepare-step timeout in milliseconds.
#[must_use]
pub fn run_prepare_timeout_ms(&self) -> Option<u64> {
self.run_prepare()
.and_then(|p| p.timeout)
.map(|d| u64::try_from(d.as_std().as_millis()).unwrap_or(u64::MAX))
}
#[must_use]
pub fn run_checkpoint(&self) -> Option<&RunCheckpointLayer> {
self.run.as_ref().and_then(|r| r.checkpoint.as_ref())
}
#[must_use]
pub fn run_hooks(&self) -> &[HookEntry] {
self.run.as_ref().map_or(&[], |r| r.hooks.as_slice())
}
#[must_use]
pub fn run_pull_request(&self) -> Option<&RunPullRequestLayer> {
self.run.as_ref().and_then(|r| r.pull_request.as_ref())
}
#[must_use]
pub fn run_artifacts(&self) -> Option<&RunArtifactsLayer> {
self.run.as_ref().and_then(|r| r.artifacts.as_ref())
}
#[must_use]
pub fn run_execution(&self) -> Option<&RunExecutionLayer> {
self.run.as_ref().and_then(|r| r.execution.as_ref())
}
#[must_use]
pub fn run_agent(&self) -> Option<&RunAgentLayer> {
self.run.as_ref().and_then(|r| r.agent.as_ref())
}
#[must_use]
pub fn run_agent_mcps(&self) -> Option<&HashMap<String, McpEntryLayer>> {
self.run_agent().map(|a| &a.mcps)
}
#[must_use]
pub fn run_inputs(&self) -> Option<&HashMap<String, toml::Value>> {
self.run.as_ref().and_then(|r| r.inputs.as_ref())
}
/// Stringified view of `run.inputs`: non-string TOML values are rendered
/// via their canonical TOML representation (integers, booleans, and
/// arrays are flattened through `Display`). Returns `None` when no
/// inputs are set.
#[must_use]
pub fn run_inputs_as_strings(&self) -> Option<HashMap<String, String>> {
self.run_inputs().map(|inputs| {
inputs
.iter()
.map(|(k, v)| {
let stringified = match v {
toml::Value::String(s) => s.clone(),
other => other.to_string(),
};
(k.clone(), stringified)
})
.collect()
})
}
#[must_use]
pub fn run_metadata(&self) -> Option<&HashMap<String, String>> {
self.run.as_ref().map(|r| &r.metadata)
}
#[must_use]
pub fn run_git_author(&self) -> Option<&GitAuthorLayer> {
self.run
.as_ref()
.and_then(|r| r.git.as_ref())
.and_then(|g| g.author.as_ref())
}
// ---------- execution-posture booleans ----------
#[must_use]
pub fn dry_run_enabled(&self) -> bool {
matches!(
self.run_execution().and_then(|e| e.mode),
Some(RunMode::DryRun)
)
}
#[must_use]
pub fn auto_approve_enabled(&self) -> bool {
matches!(
self.run_execution().and_then(|e| e.approval),
Some(ApprovalMode::Auto)
)
}
/// Returns `true` when retros are explicitly disabled. Defaults to
/// `false` (retros enabled) when not set.
#[must_use]
pub fn no_retro_enabled(&self) -> bool {
matches!(self.run_execution().and_then(|e| e.retros), Some(false))
}
#[must_use]
pub fn preserve_sandbox_enabled(&self) -> bool {
self.run_sandbox()
.and_then(|sb| sb.preserve)
.unwrap_or(false)
}
// ---------- server-scope ----------
#[must_use]
pub fn server_layer(&self) -> Option<&ServerLayer> {
self.server.as_ref()
}
#[must_use]
pub fn server_api(&self) -> Option<&ServerApiLayer> {
self.server.as_ref().and_then(|s| s.api.as_ref())
}
#[must_use]
pub fn server_web(&self) -> Option<&ServerWebLayer> {
self.server.as_ref().and_then(|s| s.web.as_ref())
}
#[must_use]
pub fn server_storage(&self) -> Option<&ServerStorageLayer> {
self.server.as_ref().and_then(|s| s.storage.as_ref())
}
#[must_use]
pub fn server_storage_root_str(&self) -> Option<String> {
self.server_storage()
.and_then(|s| s.root.as_ref())
.map(InterpString::as_source)
}
#[must_use]
pub fn server_artifacts(&self) -> Option<&ServerArtifactsLayer> {
self.server.as_ref().and_then(|s| s.artifacts.as_ref())
}
#[must_use]
pub fn server_scheduler(&self) -> Option<&ServerSchedulerLayer> {
self.server.as_ref().and_then(|s| s.scheduler.as_ref())
}
#[must_use]
pub fn max_concurrent_runs(&self) -> Option<usize> {
self.server_scheduler().and_then(|s| s.max_concurrent_runs)
}
#[must_use]
pub fn server_logging(&self) -> Option<&ServerLoggingLayer> {
self.server.as_ref().and_then(|s| s.logging.as_ref())
}
#[must_use]
pub fn server_integrations(&self) -> Option<&ServerIntegrationsLayer> {
self.server.as_ref().and_then(|s| s.integrations.as_ref())
}
#[must_use]
pub fn server_integrations_github(&self) -> Option<&GithubIntegrationLayer> {
self.server_integrations().and_then(|i| i.github.as_ref())
}
#[must_use]
pub fn server_integrations_slack(&self) -> Option<&SlackIntegrationLayer> {
self.server_integrations().and_then(|i| i.slack.as_ref())
}
#[must_use]
pub fn github_app_id_str(&self) -> Option<String> {
self.server_integrations_github()
.and_then(|g| g.app_id.as_ref())
.map(InterpString::as_source)
}
#[must_use]
pub fn github_client_id_str(&self) -> Option<String> {
self.server_integrations_github()
.and_then(|g| g.client_id.as_ref())
.map(InterpString::as_source)
}
#[must_use]
pub fn github_slug_str(&self) -> Option<String> {
self.server_integrations_github()
.and_then(|g| g.slug.as_ref())
.map(InterpString::as_source)
}
#[must_use]
pub fn github_permissions(&self) -> Option<&HashMap<String, InterpString>> {
self.server_integrations_github()
.map(|g| &g.permissions)
.filter(|m| !m.is_empty())
}
// ---------- storage path with home-dir default ----------
/// Returns the configured server storage root, or the home-dir default
/// when unset. Env interpolation is resolved at read time against the
/// process environment.
#[must_use]
pub fn storage_dir(&self) -> PathBuf {
self.server_storage()
.and_then(|s| s.root.as_ref())
.and_then(|interp| {
interp
.resolve(|name| std::env::var(name).ok())
.ok()
.map(|resolved| resolved.value)
})
.map_or_else(|| fabro_util::Home::from_env().storage_dir(), PathBuf::from)
}
// ---------- labels / metadata aggregation ----------
/// Combined metadata labels from project, workflow, and run layers.
/// Later layers overwrite earlier ones (project < workflow < run).
#[must_use]
pub fn all_labels(&self) -> HashMap<String, String> {
let mut out = HashMap::new();
if let Some(project) = &self.project {
for (k, v) in &project.metadata {
out.insert(k.clone(), v.clone());
}
}
if let Some(workflow) = &self.workflow {
for (k, v) in &workflow.metadata {
out.insert(k.clone(), v.clone());
}
}
if let Some(run) = &self.run {
for (k, v) in &run.metadata {
out.insert(k.clone(), v.clone());
}
}
out
}
}
/// Resolve a goal-file path string against `base_dir`. Absolute paths are
/// used as-is; relative paths are joined onto `base_dir`.
#[must_use]
pub fn resolve_goal_file_path(path_str: &str, base_dir: &Path) -> PathBuf {
let path = Path::new(path_str);
if path.is_absolute() {
path.to_path_buf()
} else {
base_dir.join(path)
}
}
/// Error returned by [`SettingsFile::resolve_run_goal`].
#[derive(Debug)]
pub enum ResolveGoalError {
/// The `run.goal.file` InterpString referenced an env var that wasn't
/// set at consume time.
EnvLookup { var: String },
/// The goal file exists in config but could not be read.
Io {
path: PathBuf,
source: std::io::Error,
},
}
impl std::fmt::Display for ResolveGoalError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::EnvLookup { var } => write!(
f,
"failed to resolve run.goal.file: env var {var:?} referenced by ${{env.{var}}} is not set"
),
Self::Io { path, .. } => {
write!(f, "failed to read run.goal.file at {}", path.display())
}
}
}
}
impl std::error::Error for ResolveGoalError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::EnvLookup { .. } => None,
Self::Io { source, .. } => Some(source),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::settings::run::{RunLayer, RunModelLayer};
#[test]
fn run_goal_inline_str_returns_source_value() {
let file = SettingsFile {
run: Some(RunLayer {
goal: Some(RunGoalLayer::Inline(InterpString::parse("Implement OAuth"))),
..RunLayer::default()
}),
..SettingsFile::default()
};
assert_eq!(
file.run_goal_inline_str().as_deref(),
Some("Implement OAuth")
);
}
#[test]
fn run_goal_inline_str_is_none_for_file_variant() {
let file = SettingsFile {
run: Some(RunLayer {
goal: Some(RunGoalLayer::File {
file: InterpString::parse("/abs/goal.md"),
}),
..RunLayer::default()
}),
..SettingsFile::default()
};
assert_eq!(file.run_goal_inline_str(), None);
assert!(matches!(
file.run_goal_layer(),
Some(RunGoalLayer::File { .. })
));
}
#[test]
fn resolve_run_goal_reads_file_variant_from_disk() {
let tmp = tempfile::tempdir().unwrap();
let goal_path = tmp.path().join("goal.md");
std::fs::write(&goal_path, "ship the thing").unwrap();
let file = SettingsFile {
run: Some(RunLayer {
goal: Some(RunGoalLayer::File {
file: InterpString::parse(goal_path.to_str().unwrap()),
}),
..RunLayer::default()
}),
..SettingsFile::default()
};
let resolved = file
.resolve_run_goal(tmp.path())
.expect("goal file should resolve")
.expect("goal should be set");
assert_eq!(resolved.text, "ship the thing");
assert!(matches!(
resolved.source,
ResolvedGoalSource::File { ref path } if path == &goal_path
));
}
#[test]
fn resolve_run_goal_inline_passes_text_through() {
let file = SettingsFile {
run: Some(RunLayer {
goal: Some(RunGoalLayer::Inline(InterpString::parse("literal goal"))),
..RunLayer::default()
}),
..SettingsFile::default()
};
let resolved = file
.resolve_run_goal(std::path::Path::new("/"))
.unwrap()
.unwrap();
assert_eq!(resolved.text, "literal goal");
assert_eq!(resolved.source, ResolvedGoalSource::Inline);
}
#[test]
fn run_model_name_str_walks_tree() {
let file = SettingsFile {
run: Some(RunLayer {
model: Some(RunModelLayer {
name: Some(InterpString::parse("claude-sonnet-4-6")),
..RunModelLayer::default()
}),
..RunLayer::default()
}),
..SettingsFile::default()
};
assert_eq!(
file.run_model_name_str().as_deref(),
Some("claude-sonnet-4-6")
);
}
#[test]
fn all_labels_merges_project_workflow_run() {
use crate::settings::project::ProjectLayer;
use crate::settings::workflow::WorkflowLayer;
let mut project_metadata = HashMap::new();
project_metadata.insert("env".into(), "project".into());
project_metadata.insert("team".into(), "core".into());
let mut workflow_metadata = HashMap::new();
workflow_metadata.insert("env".into(), "workflow".into());
let mut run_metadata = HashMap::new();
run_metadata.insert("priority".into(), "high".into());
let file = SettingsFile {
project: Some(ProjectLayer {
metadata: project_metadata,
..ProjectLayer::default()
}),
workflow: Some(WorkflowLayer {
metadata: workflow_metadata,
..WorkflowLayer::default()
}),
run: Some(RunLayer {
metadata: run_metadata,
..RunLayer::default()
}),
..SettingsFile::default()
};
let labels = file.all_labels();
assert_eq!(labels.get("env").map(String::as_str), Some("workflow"));
assert_eq!(labels.get("team").map(String::as_str), Some("core"));
assert_eq!(labels.get("priority").map(String::as_str), Some("high"));
}
}

View file

@ -9,13 +9,13 @@
//! `settings/v2/` subdirectory, so the `::v2::` path prefix no longer
//! exists.
pub mod accessors;
pub mod cli;
pub mod duration;
pub mod features;
pub mod interp;
pub mod model_ref;
pub mod project;
pub mod resolved;
pub mod run;
pub mod server;
pub mod size;
@ -36,6 +36,7 @@ pub use model_ref::{
AmbiguousModelRef, ModelRef, ModelRegistry, ParseModelRefError, ResolvedModelRef,
};
pub use project::{ProjectLayer, ProjectSettings};
pub use resolved::Settings;
pub use run::{
ArtifactsSettings, DaytonaSettings, DaytonaSnapshotSettings, DockerfileSource,
GitAuthorSettings, HookDefinition, HookType, InterviewProviderSettings, McpServerSettings,

View file

@ -0,0 +1,14 @@
use super::{
CliSettings, FeaturesSettings, ProjectSettings, RunSettings, ServerSettings, WorkflowSettings,
};
/// A fully resolved settings view across all namespaces.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Settings {
pub project: ProjectSettings,
pub workflow: WorkflowSettings,
pub run: RunSettings,
pub cli: CliSettings,
pub server: ServerSettings,
pub features: FeaturesSettings,
}

View file

@ -16,7 +16,7 @@ use super::interp::InterpString;
use super::model_ref::ModelRef;
/// A structurally resolved `[run]` view for consumers.
#[derive(Debug, Clone, PartialEq)]
#[derive(Debug, Clone, Default, PartialEq)]
pub struct RunSettings {
pub goal: Option<RunGoal>,
pub working_dir: Option<InterpString>,
@ -37,30 +37,6 @@ pub struct RunSettings {
pub artifacts: ArtifactsSettings,
}
impl Default for RunSettings {
fn default() -> Self {
Self {
goal: None,
working_dir: None,
metadata: HashMap::new(),
inputs: HashMap::new(),
model: RunModelSettings::default(),
git: RunGitSettings::default(),
prepare: RunPrepareSettings::default(),
execution: RunExecutionSettings::default(),
checkpoint: RunCheckpointSettings::default(),
sandbox: RunSandboxSettings::default(),
notifications: HashMap::new(),
interviews: RunInterviewsSettings::default(),
agent: RunAgentSettings::default(),
hooks: Vec::new(),
scm: RunScmSettings::default(),
pull_request: None,
artifacts: ArtifactsSettings::default(),
}
}
}
/// The resolved source of a run goal.
#[derive(Debug, Clone, PartialEq)]
pub enum RunGoal {
@ -322,7 +298,7 @@ impl HookDefinition {
#[must_use]
pub fn is_blocking(&self) -> bool {
self.blocking.unwrap_or_else(|| {
self.blocking.unwrap_or({
matches!(
self.event,
HookEvent::RunStart

View file

@ -15,7 +15,7 @@ use super::duration::Duration as DurationLayer;
use super::interp::InterpString;
/// A structurally resolved `[server]` view for consumers.
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ServerSettings {
pub listen: ServerListenSettings,
pub api: ServerApiSettings,
@ -29,23 +29,6 @@ pub struct ServerSettings {
pub integrations: ServerIntegrationsSettings,
}
impl Default for ServerSettings {
fn default() -> Self {
Self {
listen: ServerListenSettings::default(),
api: ServerApiSettings::default(),
web: ServerWebSettings::default(),
auth: ServerAuthSettings::default(),
storage: ServerStorageSettings::default(),
artifacts: ServerArtifactsSettings::default(),
slatedb: ServerSlateDbSettings::default(),
scheduler: ServerSchedulerSettings::default(),
logging: ServerLoggingSettings::default(),
integrations: ServerIntegrationsSettings::default(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ServerListenSettings {
Tcp {

View file

@ -3,7 +3,8 @@ use fabro_graphviz::graph::{AttrValue, Graph};
use fabro_model::Catalog;
use fabro_sandbox::SandboxProvider;
use fabro_store::Database;
use fabro_types::settings::SettingsFile;
use fabro_types::settings::run::RunMode;
use fabro_types::settings::{Settings, SettingsFile};
use fabro_types::{RunId, RunProvenance};
use std::collections::BTreeMap;
use std::collections::HashMap;
@ -72,7 +73,7 @@ pub async fn create(store: &Database, request: CreateRunInput) -> Result<Created
.map_err(|err| FabroError::Parse(err.to_string()))?;
if fabro_config::resolve_run_from_file(&resolved.settings)
.map(|settings| settings.execution.mode != fabro_types::settings::run::RunMode::DryRun)
.map(|settings| settings.execution.mode != RunMode::DryRun)
.unwrap_or(true)
{
validate_sandbox_provider(&resolved.settings)?;
@ -94,8 +95,20 @@ pub async fn create(store: &Database, request: CreateRunInput) -> Result<Created
} = request;
let settings = resolved.settings.clone();
let resolved_settings = resolve_settings_tree(&settings)?;
let run_id = run_id.unwrap_or_else(RunId::new);
let storage = Storage::new(settings.storage_dir());
let storage_root = resolved_settings
.server
.storage
.root
.resolve(|name| std::env::var(name).ok())
.map_err(|err| {
FabroError::Precondition(format!(
"failed to resolve {}: {err}",
resolved_settings.server.storage.root.as_source()
))
})?;
let storage = Storage::new(storage_root.value);
let run_dir = storage.run_scratch(&run_id).root().to_path_buf();
let working_directory = resolved.working_directory.clone();
let host_repo_path =
@ -130,7 +143,7 @@ pub async fn create(store: &Database, request: CreateRunInput) -> Result<Created
run_id: Some(run_id),
run_dir: Some(run_dir.clone()),
workflow_slug: workflow_slug.or(resolved.workflow_slug.clone()),
labels: resolved.settings.all_labels(),
labels: combined_labels(&resolved_settings),
base_branch,
working_directory,
host_repo_path,
@ -252,16 +265,29 @@ fn store_error(err: impl std::fmt::Display) -> FabroError {
FabroError::engine(err.to_string())
}
fn render_resolve_errors(errors: &[fabro_config::ResolveError]) -> String {
errors
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("; ")
}
fn resolve_settings_tree(settings: &SettingsFile) -> Result<Settings, FabroError> {
fabro_config::resolve(settings)
.map_err(|errors| FabroError::Precondition(render_resolve_errors(&errors)))
}
fn combined_labels(settings: &Settings) -> HashMap<String, String> {
let mut labels = settings.project.metadata.clone();
labels.extend(settings.workflow.metadata.clone());
labels.extend(settings.run.metadata.clone());
labels
}
fn validate_sandbox_provider(settings: &SettingsFile) -> Result<(), FabroError> {
let resolved = fabro_config::resolve_run_from_file(settings).map_err(|errors| {
FabroError::Precondition(
errors
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("; "),
)
})?;
let resolved = fabro_config::resolve_run_from_file(settings)
.map_err(|errors| FabroError::Precondition(render_resolve_errors(&errors)))?;
resolved
.sandbox
.provider
@ -304,7 +330,7 @@ pub(super) fn preprocess_and_validate(
settings: Option<&SettingsFile>,
goal_override: Option<&str>,
) -> Result<Validated, FabroError> {
let source = match settings.and_then(SettingsFile::run_inputs_as_strings) {
let source = match run_inputs_as_strings(settings) {
Some(mut vars) => {
vars.insert("goal".to_string(), "$goal".to_string());
expand_vars(dot_source, &vars)
@ -327,6 +353,23 @@ pub(super) fn preprocess_and_validate(
Ok(pipeline::validate(transformed, &[]))
}
fn run_inputs_as_strings(settings: Option<&SettingsFile>) -> Option<HashMap<String, String>> {
settings
.and_then(|settings| settings.run.as_ref())
.and_then(|run| run.inputs.as_ref())
.map(|inputs| {
inputs
.iter()
.map(|(key, value)| {
let stringified = value
.as_str()
.map_or_else(|| value.to_string(), ToString::to_string);
(key.clone(), stringified)
})
.collect()
})
}
fn apply_goal_override(graph: &mut Graph, goal_override: Option<&str>) {
if let Some(goal_override) = goal_override {
graph.attrs.insert(
@ -353,7 +396,7 @@ fn persist_validated(
provenance,
} = options;
let settings = materialize_run(settings, validated.graph(), &Catalog::builtin());
let settings = materialize_run(settings, validated.graph(), Catalog::builtin());
let run_id = run_id.unwrap_or_else(RunId::new);
let run_dir = run_dir.unwrap_or_else(|| default_run_dir(&run_id));
@ -762,38 +805,42 @@ mod tests {
assert_eq!(created.run_id, fixtures::RUN_1);
assert_eq!(created.persisted.run_record().graph.goal(), "override goal");
assert_eq!(
created
.persisted
.run_record()
.settings
.run_model_name_str()
fabro_config::resolve_run_from_file(&created.persisted.run_record().settings)
.unwrap()
.model
.name
.as_ref()
.map(|value| value.as_source())
.as_deref(),
Some("claude-sonnet-4-6")
);
assert_eq!(
created
.persisted
.run_record()
.settings
.run_model_provider_str()
fabro_config::resolve_run_from_file(&created.persisted.run_record().settings)
.unwrap()
.model
.provider
.as_ref()
.map(|value| value.as_source())
.as_deref(),
Some("anthropic")
);
assert_eq!(
created
.persisted
.run_record()
.settings
.run_goal_inline_str()
.as_deref(),
match fabro_config::resolve_run_from_file(&created.persisted.run_record().settings)
.unwrap()
.goal
{
Some(fabro_types::settings::run::RunGoal::Inline(value)) => {
Some(value.as_source())
}
_ => None,
}
.as_deref(),
Some("override goal")
);
assert!(
created
.persisted
.run_record()
.settings
.run_pull_request()
fabro_config::resolve_run_from_file(&created.persisted.run_record().settings)
.unwrap()
.pull_request
.is_none()
);
assert_eq!(

View file

@ -3,6 +3,7 @@ use std::sync::Arc;
use anyhow::Context;
use fabro_config::project as project_config;
use fabro_config::run::resolve_run_goal;
use fabro_types::settings::SettingsFile;
use crate::file_resolver::{FileResolver, FilesystemFileResolver};
@ -137,8 +138,7 @@ fn resolve_goal_override(
settings: &SettingsFile,
working_directory: &Path,
) -> anyhow::Result<Option<String>> {
settings
.resolve_run_goal(working_directory)
resolve_run_goal(settings, working_directory)
.map(|opt| opt.map(|resolved| resolved.text))
.map_err(anyhow::Error::from)
}

View file

@ -6,16 +6,21 @@ use std::time::{Duration, Instant};
use fabro_config::project as project_config;
use fabro_interview::{AutoApproveInterviewer, Interviewer};
use fabro_mcp::config::{McpServerSettings, McpTransport};
use fabro_model::{Catalog, FallbackTarget, Provider};
use fabro_sandbox::config::{self as sandbox_config, WorktreeMode, bridge_worktree_mode};
use fabro_sandbox::config::{
DaytonaNetwork, DaytonaSnapshotSettings, DockerfileSource as SandboxDockerfileSource,
};
use fabro_sandbox::{SandboxProvider, SandboxSpec};
use fabro_types::RunId;
use fabro_types::settings::InterpString;
use fabro_types::settings::run::{
ApprovalMode, DaytonaNetworkLayer, DaytonaSettings,
DockerfileSource as ResolvedDockerfileSource, HookDefinition as ResolvedHookDefinition,
HookEvent as ResolvedHookEvent, HookType as ResolvedHookType,
McpServerSettings as ResolvedMcpServerSettings, McpTransport as ResolvedMcpTransport,
PullRequestSettings, RunModelSettings as ResolvedRunModelSettings,
PullRequestSettings, RunMode, RunModelSettings as ResolvedRunModelSettings,
RunSettings as ResolvedRunSettings, TlsMode as ResolvedTlsMode,
};
@ -302,20 +307,16 @@ impl RunSession {
.map_err(|errors| FabroError::Precondition(render_resolve_errors(&errors)))?;
let sandbox_provider = resolve_sandbox_provider(&resolved)?;
let sandbox_provider = if resolved.execution.mode
== fabro_types::settings::run::RunMode::DryRun
&& !sandbox_provider.is_local()
{
SandboxProvider::Local
} else {
sandbox_provider
};
let model = resolved
.model
.name
.as_ref()
.map(InterpString::as_source)
.unwrap_or_else(|| Catalog::builtin().default_from_env().id.clone());
let sandbox_provider =
if resolved.execution.mode == RunMode::DryRun && !sandbox_provider.is_local() {
SandboxProvider::Local
} else {
sandbox_provider
};
let model = resolved.model.name.as_ref().map_or_else(
|| Catalog::builtin().default_from_env().id.clone(),
InterpString::as_source,
);
let provider = resolved
.model
.provider
@ -362,9 +363,14 @@ impl RunSession {
.iter()
.map(|(k, v)| (k.clone(), resolve_interp(v)))
.collect();
let resolved_server = fabro_config::resolve_server_from_file(settings)
.map_err(|errors| FabroError::Precondition(render_resolve_errors(&errors)))?;
let github_permissions: Option<HashMap<String, String>> =
settings.github_permissions().map(|perms| {
perms
(!resolved_server.integrations.github.permissions.is_empty()).then(|| {
resolved_server
.integrations
.github
.permissions
.iter()
.map(|(k, v)| (k.clone(), resolve_interp(v)))
.collect()
@ -381,12 +387,12 @@ impl RunSession {
resolve_dir: working_directory.clone(),
});
let interviewer: Arc<dyn Interviewer> =
if resolved.execution.approval == fabro_types::settings::run::ApprovalMode::Auto {
Arc::new(AutoApproveInterviewer)
} else {
services.interviewer
};
let interviewer: Arc<dyn Interviewer> = if resolved.execution.approval == ApprovalMode::Auto
{
Arc::new(AutoApproveInterviewer)
} else {
services.interviewer
};
let pr_config = resolved.pull_request.clone();
@ -401,7 +407,7 @@ impl RunSession {
provider: provider_enum,
fallback_chain,
mcp_servers,
dry_run: resolved.execution.mode == fabro_types::settings::run::RunMode::DryRun,
dry_run: resolved.execution.mode == RunMode::DryRun,
},
interviewer,
on_node: services.on_node,
@ -457,11 +463,12 @@ async fn load_accepted_run_definition(
}
fn resolve_sandbox_provider(settings: &ResolvedRunSettings) -> Result<SandboxProvider, FabroError> {
Some(settings.sandbox.provider.as_str())
.map(str::parse::<SandboxProvider>)
.transpose()
.map_err(|err| FabroError::Precondition(format!("Invalid sandbox provider: {err}")))?
.map_or_else(|| Ok(SandboxProvider::default()), Ok)
Some(str::parse::<SandboxProvider>(
settings.sandbox.provider.as_str(),
))
.transpose()
.map_err(|err| FabroError::Precondition(format!("Invalid sandbox provider: {err}")))?
.map_or_else(|| Ok(SandboxProvider::default()), Ok)
}
fn resolve_worktree_mode(settings: &ResolvedRunSettings) -> sandbox_config::WorktreeMode {
@ -509,41 +516,37 @@ fn render_resolve_errors(errors: &[fabro_config::ResolveError]) -> String {
.join("; ")
}
fn runtime_mcp_server(
settings: &ResolvedMcpServerSettings,
) -> fabro_mcp::config::McpServerSettings {
fabro_mcp::config::McpServerSettings {
fn runtime_mcp_server(settings: &ResolvedMcpServerSettings) -> McpServerSettings {
McpServerSettings {
name: settings.name.clone(),
transport: match &settings.transport {
ResolvedMcpTransport::Stdio { command, env } => {
fabro_mcp::config::McpTransport::Stdio {
command: command.clone(),
env: env.clone(),
}
}
ResolvedMcpTransport::Http { url, headers } => fabro_mcp::config::McpTransport::Http {
ResolvedMcpTransport::Stdio { command, env } => McpTransport::Stdio {
command: command.clone(),
env: env.clone(),
},
ResolvedMcpTransport::Http { url, headers } => McpTransport::Http {
url: url.clone(),
headers: headers.clone(),
},
ResolvedMcpTransport::Sandbox { command, port, env } => {
fabro_mcp::config::McpTransport::Sandbox {
command: command.clone(),
port: *port,
env: env.clone(),
}
}
ResolvedMcpTransport::Sandbox { command, port, env } => McpTransport::Sandbox {
command: command.clone(),
port: *port,
env: env.clone(),
},
},
startup_timeout_secs: settings.startup_timeout_secs,
tool_timeout_secs: settings.tool_timeout_secs,
}
}
fn runtime_daytona_config(settings: &fabro_types::settings::run::DaytonaSettings) -> DaytonaConfig {
fn runtime_daytona_config(settings: &DaytonaSettings) -> DaytonaConfig {
DaytonaConfig {
auto_stop_interval: settings.auto_stop_interval,
labels: (!settings.labels.is_empty()).then_some(settings.labels.clone()),
snapshot: settings.snapshot.as_ref().map(|snapshot| {
fabro_sandbox::config::DaytonaSnapshotSettings {
snapshot: settings
.snapshot
.as_ref()
.map(|snapshot| DaytonaSnapshotSettings {
name: snapshot.name.clone(),
cpu: snapshot.cpu,
memory: snapshot.memory_gb,
@ -553,23 +556,18 @@ fn runtime_daytona_config(settings: &fabro_types::settings::run::DaytonaSettings
.as_ref()
.map(|dockerfile| match dockerfile {
ResolvedDockerfileSource::Inline(text) => {
fabro_sandbox::config::DockerfileSource::Inline(text.clone())
SandboxDockerfileSource::Inline(text.clone())
}
ResolvedDockerfileSource::Path { path } => {
fabro_sandbox::config::DockerfileSource::Path { path: path.clone() }
SandboxDockerfileSource::Path { path: path.clone() }
}
}),
}
}),
}),
network: settings.network.as_ref().map(|network| match network {
fabro_types::settings::run::DaytonaNetworkLayer::Block => {
fabro_sandbox::config::DaytonaNetwork::Block
}
fabro_types::settings::run::DaytonaNetworkLayer::AllowAll => {
fabro_sandbox::config::DaytonaNetwork::AllowAll
}
fabro_types::settings::run::DaytonaNetworkLayer::AllowList { allow_list } => {
fabro_sandbox::config::DaytonaNetwork::AllowList(allow_list.clone())
DaytonaNetworkLayer::Block => DaytonaNetwork::Block,
DaytonaNetworkLayer::AllowAll => DaytonaNetwork::AllowAll,
DaytonaNetworkLayer::AllowList { allow_list } => {
DaytonaNetwork::AllowList(allow_list.clone())
}
}),
skip_clone: settings.skip_clone,

View file

@ -6,7 +6,6 @@ use chrono::{DateTime, Utc};
use fabro_config::Storage;
use fabro_store::{Database, RunSummary};
use fabro_types::RunId;
use fabro_types::settings::SettingsFile;
use serde::Serialize;
use crate::operations::make_run_dir;
@ -142,7 +141,7 @@ pub fn scratch_base(storage_dir: &Path) -> PathBuf {
}
pub fn default_scratch_base() -> PathBuf {
scratch_base(&SettingsFile::default().storage_dir())
scratch_base(&fabro_util::Home::from_env().storage_dir())
}
fn scan_orphan_runs(base: &Path) -> Result<Vec<RunInfo>> {

View file

@ -5,6 +5,7 @@ use std::sync::atomic::AtomicBool;
use fabro_types::RunId;
use fabro_types::settings::SettingsFile;
use fabro_types::settings::run::RunMode;
use crate::git::{GitAuthor, git_author_from_settings};
@ -43,7 +44,7 @@ pub struct RunOptions {
impl RunOptions {
pub fn dry_run_enabled(&self) -> bool {
fabro_config::resolve_run_from_file(&self.settings)
.map(|settings| settings.execution.mode == fabro_types::settings::run::RunMode::DryRun)
.map(|settings| settings.execution.mode == RunMode::DryRun)
.unwrap_or(false)
}

View file

@ -33,18 +33,29 @@ fn materialize_run_applies_graph_and_catalog_defaults() {
};
let materialized = materialize_run(settings, &graph(source), &Catalog::builtin());
let resolved = fabro_config::resolve_run_from_file(&materialized).unwrap();
assert_eq!(
materialized.run_model_name_str().as_deref(),
resolved
.model
.name
.as_ref()
.map(|value| value.as_source())
.as_deref(),
Some("claude-sonnet-4-6")
);
assert_eq!(
materialized.run_model_provider_str().as_deref(),
resolved
.model
.provider
.as_ref()
.map(|value| value.as_source())
.as_deref(),
Some("anthropic")
);
assert_eq!(
materialized.run_goal_layer(),
materialized.run.as_ref().and_then(|run| run.goal.as_ref()),
Some(&RunGoalLayer::Inline(InterpString::parse("Build feature")))
);
assert!(materialized.run_pull_request().is_none());
assert!(resolved.pull_request.is_none());
}