wip(settings): stage 6.1 consumer migration (broken build)

Partial Stage 6.1 migration of consumers off the legacy flat Settings
shape to v2 SettingsFile. Commits the in-flight work so subsequent
sessions can resume from here. Workspace currently does NOT build --
fabro-server still has ~60 consumer sites that reference state.settings
as legacy Settings, and fabro-cli is entirely untouched.

Landed in this commit:

fabro-types
- RunRecord.settings: Settings -> SettingsFile
- RunCreatedProps.settings: Settings -> SettingsFile

fabro-config
- effective_settings: full rewrite. resolve_settings now returns
  SettingsFile; apply_server_defaults / apply_local_daemon_overrides
  are v2-native and use the v2 merge matrix for server-owned domains.
- project::resolve_working_directory takes &SettingsFile and reads
  run.working_dir as an InterpString.

fabro-workflow
- start.rs, create.rs, source.rs, validate.rs, run_options.rs, git.rs,
  initialize.rs, manager_loop.rs all migrated to &SettingsFile reads.
- resolve_sandbox_provider / resolve_worktree_mode / resolve_daytona_config
  / resolve_fallback_chain walk v2 trees using the bridge helper fns.
- LifecycleOptions built from run_prepare_commands() / run_prepare_timeout_ms().
- Hooks built via bridge_hook on v2 HookEntry.
- MCPs built via bridge_mcp_entry on v2 McpEntryLayer.
- resolve_run_settings writes resolved model/provider back into
  run.model (InterpString), not the flat llm struct.
- preprocess_and_validate pulls var expansion from run_inputs_as_strings.

fabro-server/run_manifest.rs
- PreparedManifest.settings -> SettingsFile.
- prepare_manifest_with_mode takes &SettingsFile.
- build_preflight_report / run_llm_check / resolve_model_provider /
  run_github_token_check / resolve_sandbox_provider / resolve_daytona_config
  all migrated.
- Tests rewritten to use v2 fixtures via ConfigLayer::parse.

fabro-server/server.rs
- AppState.settings type changed to Arc<RwLock<SettingsFile>>.
- github_app_credentials call site uses settings.github_app_id_str()
  accessor instead of the flat app_id().

Known remaining errors:
- fabro-server/server.rs: ~60 state.settings.read() sites still
  reference legacy Settings fields (llm, sandbox, setup, git, etc.).
- fabro-server/web_auth.rs: heavy git settings usage, tests.
- fabro-server/serve.rs: state mutation of flat llm/sandbox fields.
- fabro-server/diagnostics.rs: app_id / api auth strategies.
- fabro-cli: manifest_builder, commands, tests all untouched.
- Test fixtures across the workspace still construct Settings literals.
- insta snapshots will need bulk-accept after the runtime shape stabilizes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-04-09 12:52:03 -04:00
parent 842ab71eb8
commit 5d9aad85a3
No known key found for this signature in database
14 changed files with 454 additions and 345 deletions

View file

@ -1,4 +1,4 @@
//! Effective settings resolution: combine layers into one resolved [`Settings`].
//! Effective settings resolution: combine layers into one resolved [`SettingsFile`].
//!
//! Shared layered domains (`project`, `workflow`, `run`, `features`) merge
//! across all three config files (settings.toml, fabro.toml, workflow.toml).
@ -7,10 +7,11 @@
//! stanzas in `fabro.toml` and `workflow.toml` remain schema-valid but inert.
use anyhow::{Result, anyhow};
use fabro_types::Settings;
use fabro_types::settings::v2::SettingsFile;
use fabro_types::settings::v2::run::{RunExecutionLayer, RunLayer};
use crate::ConfigLayer;
use crate::merge::combine_files;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum EffectiveSettingsMode {
@ -44,11 +45,12 @@ impl EffectiveSettingsLayers {
}
}
/// Resolve layered configuration down to a single effective [`SettingsFile`].
pub fn resolve_settings(
layers: EffectiveSettingsLayers,
server_settings: Option<&Settings>,
server_settings: Option<&SettingsFile>,
mode: EffectiveSettingsMode,
) -> Result<Settings> {
) -> Result<SettingsFile> {
let EffectiveSettingsLayers {
args,
mut workflow,
@ -57,11 +59,9 @@ pub fn resolve_settings(
} = layers;
match mode {
EffectiveSettingsMode::LocalOnly => Ok(args
.combine(workflow)
.combine(project)
.combine(user)
.resolve()),
EffectiveSettingsMode::LocalOnly => {
Ok(args.combine(workflow).combine(project).combine(user).into())
}
EffectiveSettingsMode::RemoteServer | EffectiveSettingsMode::LocalDaemon => {
let server_settings = server_settings.ok_or_else(|| {
anyhow!("server settings are required for server-targeted settings resolution")
@ -72,26 +72,33 @@ pub fn resolve_settings(
strip_owner_domains(workflow.as_v2_mut());
strip_owner_domains(project.as_v2_mut());
let server_defaults = server_defaults_layer(server_settings);
let server_defaults = server_defaults_file(server_settings);
let mut settings = args
.combine(workflow)
.combine(project)
.combine(user)
.resolve();
let combined: SettingsFile =
args.combine(workflow).combine(project).combine(user).into();
match mode {
let mut settings = match mode {
EffectiveSettingsMode::RemoteServer => {
apply_server_defaults(&mut settings, &server_defaults);
apply_server_defaults(combined, &server_defaults)
}
EffectiveSettingsMode::LocalDaemon => {
apply_local_daemon_overrides(&mut settings, &server_defaults);
apply_local_daemon_overrides(combined, &server_defaults)
}
EffectiveSettingsMode::LocalOnly => unreachable!(),
};
// Storage root always comes from the server's local
// ~/.fabro/settings.toml, never from the client.
if let Some(server_root) = server_settings
.server
.as_ref()
.and_then(|s| s.storage.as_ref())
.cloned()
{
let server = settings
.server
.get_or_insert_with(fabro_types::settings::v2::server::ServerLayer::default);
server.storage = Some(server_root);
}
settings
.storage_dir
.clone_from(&server_settings.storage_dir);
Ok(settings)
}
}
@ -102,103 +109,81 @@ fn strip_owner_domains(file: &mut SettingsFile) {
file.server = None;
}
fn server_defaults_layer(settings: &Settings) -> Settings {
/// Copy of the server settings with startup-time dry-run fallback cleared.
/// Run manifests carry their own dry-run intent; a daemon's startup-time
/// fallback mode must not silently force every submitted run into simulation.
fn server_defaults_file(settings: &SettingsFile) -> SettingsFile {
let mut out = settings.clone();
// Run manifests carry their own dry-run intent. Do not let a daemon's
// startup-time fallback mode silently force every submitted run/preflight
// into simulation.
out.dry_run = None;
if let Some(run) = out.run.as_mut() {
if let Some(execution) = run.execution.as_mut() {
execution.mode = None;
}
}
out
}
fn apply_server_defaults(settings: &mut Settings, server: &Settings) {
// Owner-specific storage and scheduling come from the server's local
// settings.toml. These always win over anything layered from the client.
if settings.storage_dir.is_none() {
settings.storage_dir.clone_from(&server.storage_dir);
}
if settings.max_concurrent_runs.is_none() {
settings.max_concurrent_runs = server.max_concurrent_runs;
}
if settings.artifact_storage.is_none() {
settings
.artifact_storage
.clone_from(&server.artifact_storage);
}
if settings.web.is_none() {
settings.web.clone_from(&server.web);
}
if settings.api.is_none() {
settings.api.clone_from(&server.api);
}
if settings.features.is_none() {
settings.features.clone_from(&server.features);
}
if settings.log.is_none() {
settings.log.clone_from(&server.log);
}
if settings.git.is_none() {
settings.git.clone_from(&server.git);
}
// Run-shaped defaults also flow from server to CLI in RemoteServer mode
// so the persisted run record matches the server's local configuration.
if settings.llm.is_none() {
settings.llm.clone_from(&server.llm);
}
if settings.sandbox.is_none() {
settings.sandbox.clone_from(&server.sandbox);
}
if settings.setup.is_none() {
settings.setup.clone_from(&server.setup);
}
if settings.checkpoint.exclude_globs.is_empty() {
settings.checkpoint = server.checkpoint.clone();
}
if settings.pull_request.is_none() {
settings.pull_request.clone_from(&server.pull_request);
}
if settings.artifacts.is_none() {
settings.artifacts.clone_from(&server.artifacts);
}
if settings.hooks.is_empty() {
settings.hooks.clone_from(&server.hooks);
}
if settings.mcp_servers.is_empty() {
settings.mcp_servers.clone_from(&server.mcp_servers);
}
if settings.github.is_none() {
settings.github.clone_from(&server.github);
}
if settings.slack.is_none() {
settings.slack.clone_from(&server.slack);
}
if settings.fabro.is_none() {
settings.fabro.clone_from(&server.fabro);
}
if settings.vars.is_none() {
settings.vars.clone_from(&server.vars);
} else if let (Some(local), Some(server_vars)) = (settings.vars.as_mut(), server.vars.as_ref())
{
for (k, v) in server_vars {
local.entry(k.clone()).or_insert_with(|| v.clone());
}
}
/// Apply server-side defaults to a client-layered [`SettingsFile`].
///
/// Server-owned domains (`server`, `features`, and parts of `run`) flow from
/// the server's local `~/.fabro/settings.toml` when the corresponding client
/// value is absent. Run-shaped defaults (model, prepare, sandbox, checkpoint,
/// hooks, agent mcps, etc.) also flow from server to client so the persisted
/// run record matches the server's local configuration.
fn apply_server_defaults(mut settings: SettingsFile, server: &SettingsFile) -> SettingsFile {
// Server-owned domains: server-side always wins when client left blank.
// Use the v2 merge matrix with the server layer in lower precedence so
// that client-supplied values still dominate when present.
settings = combine_files(server.clone(), settings);
settings
}
fn apply_local_daemon_overrides(settings: &mut Settings, server: &Settings) {
settings.storage_dir.clone_from(&server.storage_dir);
settings.max_concurrent_runs = server.max_concurrent_runs;
/// Apply server-side overrides in LocalDaemon mode.
///
/// In LocalDaemon mode, a subset of server-owned fields unconditionally
/// override any client-side values. Client-controlled run-level fields are
/// left alone.
fn apply_local_daemon_overrides(mut settings: SettingsFile, server: &SettingsFile) -> SettingsFile {
if let Some(server_layer) = server.server.clone() {
let client = settings
.server
.get_or_insert_with(fabro_types::settings::v2::server::ServerLayer::default);
if let Some(storage) = server_layer.storage {
client.storage = Some(storage);
}
if let Some(scheduler) = server_layer.scheduler {
client.scheduler = Some(scheduler);
}
if let Some(artifacts) = server_layer.artifacts {
client.artifacts = Some(artifacts);
}
if let Some(web) = server_layer.web {
client.web = Some(web);
}
if let Some(api) = server_layer.api {
client.api = Some(api);
}
}
if let Some(features) = server.features.clone() {
settings.features = Some(features);
}
// Ensure a run.execution table exists so downstream consumers that check
// for explicit dry-run defaults see a well-formed layer.
settings.run.get_or_insert_with(RunLayer::default);
settings
.run
.as_mut()
.unwrap()
.execution
.get_or_insert_with(RunExecutionLayer::default);
settings
.artifact_storage
.clone_from(&server.artifact_storage);
settings.web.clone_from(&server.web);
settings.api.clone_from(&server.api);
settings.features.clone_from(&server.features);
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use fabro_types::settings::v2::InterpString;
use fabro_types::settings::v2::server::{
ServerLayer, ServerSchedulerLayer, ServerStorageLayer,
};
use super::{EffectiveSettingsLayers, EffectiveSettingsMode, resolve_settings};
use crate::ConfigLayer;
@ -246,16 +231,21 @@ shared = "user"
)
.unwrap();
let llm = settings.llm.expect("llm config");
assert_eq!(llm.model.as_deref(), Some("project-model"));
assert_eq!(
settings.run_model_name_str().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 vars = settings.vars.as_ref().unwrap();
assert_eq!(vars.get("project_only"), Some(&"1".to_string()));
assert_eq!(vars.get("shared"), Some(&"project".to_string()));
let inputs = settings.run_inputs().unwrap();
assert!(inputs.contains_key("project_only"));
assert_eq!(
inputs.get("shared").and_then(|v| v.as_str()),
Some("project")
);
assert!(
vars.get("user_only").is_none(),
!inputs.contains_key("user_only"),
"project.inputs should replace user.inputs wholesale"
);
}
@ -298,19 +288,26 @@ provider = "openai"
)
.unwrap();
assert_eq!(settings.goal.as_deref(), Some("workflow goal"));
let llm = settings.llm.expect("llm config");
assert_eq!(llm.model.as_deref(), Some("workflow-model"));
assert_eq!(llm.provider.as_deref(), Some("openai"));
assert_eq!(settings.run_goal_str().as_deref(), Some("workflow goal"));
assert_eq!(
settings.run_model_name_str().as_deref(),
Some("workflow-model")
);
assert_eq!(settings.run_model_provider_str().as_deref(), Some("openai"));
}
#[test]
fn cli_and_server_domains_from_fabro_toml_are_inert_under_remote_mode() {
let server_settings: fabro_types::Settings = fabro_types::Settings {
storage_dir: Some(PathBuf::from("/srv/fabro")),
max_concurrent_runs: Some(9),
..Default::default()
};
let mut server_settings = fabro_types::settings::v2::SettingsFile::default();
server_settings.server = Some(ServerLayer {
storage: Some(ServerStorageLayer {
root: Some(InterpString::parse("/srv/fabro")),
}),
scheduler: Some(ServerSchedulerLayer {
max_concurrent_runs: Some(9),
}),
..ServerLayer::default()
});
let project_with_server = layer(
r#"
@ -336,17 +333,25 @@ root = "/tmp/should-be-inert"
)
.unwrap();
assert_eq!(settings.storage_dir, Some(PathBuf::from("/srv/fabro")));
assert_eq!(settings.goal.as_deref(), Some("project goal"));
assert_eq!(
settings.server_storage_root_str().as_deref(),
Some("/srv/fabro")
);
assert_eq!(settings.run_goal_str().as_deref(), Some("project goal"));
}
#[test]
fn local_daemon_mode_only_applies_server_owned_overrides() {
let server_settings: fabro_types::Settings = fabro_types::Settings {
storage_dir: Some(PathBuf::from("/srv/fabro")),
max_concurrent_runs: Some(7),
..Default::default()
};
let mut server_settings = fabro_types::settings::v2::SettingsFile::default();
server_settings.server = Some(ServerLayer {
storage: Some(ServerStorageLayer {
root: Some(InterpString::parse("/srv/fabro")),
}),
scheduler: Some(ServerSchedulerLayer {
max_concurrent_runs: Some(7),
}),
..ServerLayer::default()
});
let settings = resolve_settings(
EffectiveSettingsLayers::default(),
@ -355,7 +360,10 @@ root = "/tmp/should-be-inert"
)
.unwrap();
assert_eq!(settings.storage_dir, Some(PathBuf::from("/srv/fabro")));
assert_eq!(settings.max_concurrent_runs, Some(7));
assert_eq!(
settings.server_storage_root_str().as_deref(),
Some("/srv/fabro")
);
assert_eq!(settings.max_concurrent_runs(), Some(7));
}
}

View file

@ -12,8 +12,8 @@ use serde::Serialize;
use crate::config::ConfigLayer;
use crate::run;
use fabro_types::Settings;
pub use fabro_types::settings::project::ProjectSettings;
use fabro_types::settings::v2::{InterpString, SettingsFile};
const CONFIG_FILENAME: &str = "fabro.toml";
const RUN_GRAPH_FILE: &str = "workflow.fabro";
@ -124,11 +124,11 @@ pub fn resolve_workflow_path(
}
}
pub fn resolve_working_directory(settings: &Settings, caller_cwd: &Path) -> PathBuf {
let Some(work_dir) = settings.work_dir.as_deref() else {
pub fn resolve_working_directory(settings: &SettingsFile, caller_cwd: &Path) -> PathBuf {
let Some(work_dir) = settings.run_working_dir().map(InterpString::as_source) else {
return caller_cwd.to_path_buf();
};
let path = PathBuf::from(work_dir);
let path = PathBuf::from(&work_dir);
if path.is_absolute() {
path
} else {

View file

@ -15,6 +15,7 @@ use fabro_llm::Provider;
use fabro_model::Catalog;
use fabro_sandbox::daytona::DaytonaConfig;
use fabro_sandbox::{DockerSandboxOptions, Sandbox, SandboxProvider, SandboxSpec};
use fabro_types::RunId;
use fabro_types::settings::v2::SettingsFile;
use fabro_types::settings::v2::cli::{CliLayer, CliOutputLayer, OutputVerbosity};
use fabro_types::settings::v2::interp::InterpString;
@ -22,7 +23,6 @@ use fabro_types::settings::v2::run::{
ApprovalMode, DaytonaDockerfileLayer, RunExecutionLayer, RunLayer, RunMode, RunModelLayer,
RunSandboxLayer,
};
use fabro_types::{RunId, Settings};
use fabro_util::check_report::{CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus};
use fabro_validate::Severity;
use fabro_workflow::error::FabroError;
@ -38,7 +38,7 @@ pub(crate) struct PreparedManifest {
pub git: Option<types::ManifestGit>,
pub root_source: String,
pub run_id: Option<RunId>,
pub settings: Settings,
pub settings: SettingsFile,
pub target_path: PathBuf,
pub workflow_bundle: WorkflowBundle,
pub workflow_input: BundledWorkflow,
@ -46,7 +46,7 @@ pub(crate) struct PreparedManifest {
}
pub(crate) fn prepare_manifest_with_mode(
server_settings: &Settings,
server_settings: &SettingsFile,
manifest: &types::RunManifest,
local_daemon_mode: bool,
) -> Result<PreparedManifest> {
@ -89,8 +89,8 @@ pub(crate) fn prepare_manifest_with_mode(
},
)?;
if let Some(goal) = manifest.goal.as_ref() {
settings.goal = Some(goal.text.clone());
settings.goal_file = None;
let run = settings.run.get_or_insert_with(RunLayer::default);
run.goal = Some(InterpString::parse(&goal.text));
}
Ok(PreparedManifest {
@ -338,12 +338,12 @@ async fn build_preflight_report(
let settings = &prepared.settings;
let sandbox_provider = resolve_sandbox_provider(settings)?;
let github_app = state
.github_app_credentials(settings.app_id())
.github_app_credentials(settings.github_app_id_str().as_deref())
.await
.map_err(|err| anyhow!(err))?;
let mut checks = Vec::new();
let setup_command_count = settings.setup_commands().len();
let setup_command_count = settings.run_prepare_commands().len();
let repo_summary = prepared.git.as_ref().map_or_else(
|| "unknown".to_string(),
|git| {
@ -405,20 +405,19 @@ async fn build_preflight_report(
))
}
fn resolve_sandbox_provider(settings: &Settings) -> Result<SandboxProvider> {
fn resolve_sandbox_provider(settings: &SettingsFile) -> Result<SandboxProvider> {
Ok(settings
.sandbox_settings()
.and_then(|sandbox| sandbox.provider.as_deref())
.run_sandbox()
.and_then(|sb| sb.provider.as_deref())
.map(str::parse::<SandboxProvider>)
.transpose()
.map_err(|err| anyhow!("Invalid sandbox provider: {err}"))?
.unwrap_or_default())
}
fn resolve_daytona_config(settings: &Settings) -> Option<DaytonaConfig> {
settings
.sandbox_settings()
.and_then(|sandbox| sandbox.daytona.clone())
fn resolve_daytona_config(settings: &SettingsFile) -> Option<DaytonaConfig> {
let sandbox = settings.run_sandbox()?;
fabro_types::settings::v2::bridge::bridge_sandbox(sandbox).daytona
}
async fn run_sandbox_check(
@ -497,7 +496,7 @@ async fn run_llm_check(
state: &AppState,
checks: &mut Vec<CheckResult>,
graph: &Graph,
settings: &Settings,
settings: &SettingsFile,
) -> bool {
let (model, provider) = resolve_model_provider(settings, graph);
let default_provider = provider.as_deref().unwrap_or("anthropic");
@ -588,40 +587,34 @@ async fn run_llm_check(
}
}
fn resolve_model_provider(settings: &Settings, graph: &Graph) -> (String, Option<String>) {
let configured_model = settings.llm.as_ref().and_then(|llm| llm.model.as_deref());
let configured_provider = settings
.llm
.as_ref()
.and_then(|llm| llm.provider.as_deref());
fn resolve_model_provider(settings: &SettingsFile, graph: &Graph) -> (String, Option<String>) {
let configured_model = settings.run_model_name_str();
let configured_provider = settings.run_model_provider_str();
let provider = configured_provider
.or_else(|| {
graph
.attrs
.get("default_provider")
.and_then(|value| value.as_str())
})
.map(String::from);
let provider = configured_provider.or_else(|| {
graph
.attrs
.get("default_provider")
.and_then(|value| value.as_str())
.map(String::from)
});
let model = configured_model
.or_else(|| {
graph
.attrs
.get("default_model")
.and_then(|value| value.as_str())
.map(String::from)
})
.map_or_else(
|| {
let catalog = Catalog::builtin();
let info = provider
.as_deref()
.and_then(|value| value.parse::<Provider>().ok())
.and_then(|provider| catalog.default_for_provider(provider))
.unwrap_or_else(|| catalog.default_from_env());
info.id.clone()
},
String::from,
);
.unwrap_or_else(|| {
let catalog = Catalog::builtin();
let info = provider
.as_deref()
.and_then(|value| value.parse::<Provider>().ok())
.and_then(|provider| catalog.default_for_provider(provider))
.unwrap_or_else(|| catalog.default_from_env());
info.id.clone()
});
match Catalog::builtin().get(&model) {
Some(info) => (
@ -635,23 +628,30 @@ fn resolve_model_provider(settings: &Settings, graph: &Graph) -> (String, Option
async fn run_github_token_check(
checks: &mut Vec<CheckResult>,
prepared: &PreparedManifest,
settings: &Settings,
settings: &SettingsFile,
github_app: Option<fabro_github::GitHubAppCredentials>,
) {
let Some(github_permissions) = settings.github_permissions() else {
let Some(v2_permissions) = settings.github_permissions() else {
return;
};
if github_permissions.is_empty() {
if v2_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
.iter()
.map(|(k, v)| (k.clone(), v.as_source()))
.collect();
let perm_details = github_permissions
.iter()
.map(|(key, value)| CheckDetail::new(format!("{key}: {value}")))
.collect::<Vec<_>>();
match (&github_app, prepared.git.as_ref()) {
(Some(creds), Some(git)) => {
match mint_github_token(creds, &git.origin_url, github_permissions).await {
match mint_github_token(creds, &git.origin_url, &github_permissions).await {
Ok(_) => checks.push(CheckResult {
name: "GitHub Token".into(),
status: CheckStatus::Pass,
@ -810,31 +810,49 @@ mod tests {
}
}
fn server_settings_fixture(source: &str) -> SettingsFile {
fabro_config::ConfigLayer::parse(source)
.expect("v2 fixture should parse")
.into()
}
#[test]
fn prepare_manifest_does_not_inherit_server_dry_run_fallback() {
let server_settings = Settings {
dry_run: Some(true),
storage_dir: Some(PathBuf::from("/srv/fabro")),
..Default::default()
};
let server_settings = server_settings_fixture(
r#"
_version = 1
[run.execution]
mode = "dry_run"
[server.storage]
root = "/srv/fabro"
"#,
);
let prepared =
prepare_manifest_with_mode(&server_settings, &minimal_manifest(), false).unwrap();
assert_eq!(prepared.settings.dry_run, None);
assert!(!prepared.settings.dry_run_enabled());
assert_eq!(
prepared.settings.storage_dir,
Some(PathBuf::from("/srv/fabro"))
prepared.settings.server_storage_root_str().as_deref(),
Some("/srv/fabro"),
);
}
#[test]
fn prepare_manifest_preserves_explicit_manifest_dry_run() {
let server_settings = Settings {
dry_run: Some(true),
storage_dir: Some(PathBuf::from("/srv/fabro")),
..Default::default()
};
let server_settings = server_settings_fixture(
r#"
_version = 1
[run.execution]
mode = "dry_run"
[server.storage]
root = "/srv/fabro"
"#,
);
let mut manifest = minimal_manifest();
manifest.args = Some(types::ManifestArgs {
auto_approve: None,
@ -850,23 +868,25 @@ mod tests {
let prepared = prepare_manifest_with_mode(&server_settings, &manifest, false).unwrap();
assert_eq!(prepared.settings.dry_run, Some(true));
assert!(prepared.settings.dry_run_enabled());
}
#[test]
fn prepare_manifest_local_daemon_prefers_bundled_settings_without_duplication() {
let server_settings: Settings = toml::from_str(
let server_settings = server_settings_fixture(
r#"
storage_dir = "/srv/fabro"
_version = 1
[setup]
commands = ["cli-setup"]
[server.storage]
root = "/srv/fabro"
[git]
[[run.prepare.steps]]
script = "cli-setup"
[server.integrations.github]
app_id = "snapshotted-app-id"
"#,
)
.unwrap();
);
let mut manifest = minimal_manifest();
manifest.workflows.get_mut("workflow.fabro").unwrap().config =
@ -902,17 +922,16 @@ app_id = "snapshotted-app-id"
// 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
.setup
.as_ref()
.map(|setup| setup.commands.clone()),
Some(vec!["workflow-setup".to_string()])
prepared.settings.run_prepare_commands(),
vec!["workflow-setup".to_string()]
);
assert_eq!(prepared.settings.app_id(), Some("snapshotted-app-id"));
assert_eq!(
prepared.settings.storage_dir,
Some(PathBuf::from("/srv/fabro"))
prepared.settings.github_app_id_str().as_deref(),
Some("snapshotted-app-id")
);
assert_eq!(
prepared.settings.server_storage_root_str().as_deref(),
Some("/srv/fabro"),
);
}
}

View file

@ -33,6 +33,7 @@ use fabro_model::{BilledModelUsage, BilledTokenCounts};
use fabro_store::{
ArtifactStore, Database, EventEnvelope, EventPayload, PendingInterviewRecord, StageId,
};
use fabro_types::settings::v2::SettingsFile;
use fabro_types::{
EventBody, InterviewQuestionRecord, InterviewQuestionType, RunBlobId, RunClientProvenance,
RunControlAction, RunEvent, RunId, RunProvenance, RunServerProvenance, RunSubjectProvenance,
@ -520,7 +521,7 @@ pub struct AppState {
global_event_tx: broadcast::Sender<EventEnvelope>,
pub(crate) secret_store: AsyncRwLock<SecretStore>,
pub(crate) settings: Arc<RwLock<Settings>>,
pub(crate) settings: Arc<RwLock<SettingsFile>>,
pub(crate) config_path: PathBuf,
pub(crate) local_daemon_mode: bool,
shutting_down: AtomicBool,
@ -3585,7 +3586,13 @@ async fn execute_run_in_process(state: Arc<AppState>, run_id: RunId) {
}
};
let github_app = match state
.github_app_credentials(persisted.run_record().settings.app_id())
.github_app_credentials(
persisted
.run_record()
.settings
.github_app_id_str()
.as_deref(),
)
.await
{
Ok(github_app) => github_app,

View file

@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize};
use crate::graph::Graph;
use crate::run_blob_id::RunBlobId;
use crate::run_id::RunId;
use crate::settings::Settings;
use crate::settings::v2::SettingsFile;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
@ -52,7 +52,7 @@ pub struct RunProvenance {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunRecord {
pub run_id: RunId,
pub settings: Settings,
pub settings: SettingsFile,
pub graph: Graph,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workflow_slug: Option<String>,

View file

@ -2,13 +2,14 @@ use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::{Graph, RunBlobId, RunControlAction, RunProvenance, Settings, StatusReason};
use crate::settings::v2::SettingsFile;
use crate::{Graph, RunBlobId, RunControlAction, RunProvenance, StatusReason};
use super::{BilledTokenCounts, RunNoticeLevel};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunCreatedProps {
pub settings: Settings,
pub settings: SettingsFile,
pub graph: Graph,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workflow_source: Option<String>,

View file

@ -2,7 +2,7 @@ use std::path::Path;
use std::process::Command;
use fabro_checkpoint::git::Store;
use fabro_types::Settings;
use fabro_types::settings::v2::SettingsFile;
use crate::error::{FabroError, Result};
use tokio::task::{JoinError, spawn_blocking};
@ -15,9 +15,9 @@ pub use fabro_checkpoint::metadata::MetadataStore;
/// Branch prefix for workflow run branches (e.g. `fabro/run/{run_id}`).
pub const RUN_BRANCH_PREFIX: &str = "fabro/run/";
pub fn git_author_from_settings(settings: &Settings) -> GitAuthor {
pub fn git_author_from_settings(settings: &SettingsFile) -> GitAuthor {
settings
.git_author()
.run_git_author()
.map(GitAuthor::from)
.unwrap_or_default()
}

View file

@ -18,7 +18,7 @@ use crate::run_options::RunOptions;
use async_trait::async_trait;
use fabro_graphviz::graph::{AttrValue, Graph, Node};
use fabro_store::{ArtifactStore, Database};
use fabro_types::Settings;
use fabro_types::settings::v2::SettingsFile;
use object_store::memory::InMemory;
use tokio::time::{sleep, timeout};
@ -74,7 +74,7 @@ fn parse_child_graph(
source: dot.to_string(),
base_dir: None,
},
settings: Settings::default(),
settings: SettingsFile::default(),
cwd: cwd.clone(),
custom_transforms: Vec::new(),
})?;
@ -116,7 +116,7 @@ fn parse_child_graph(
};
let validated = validate(ValidateInput {
workflow,
settings: Settings::default(),
settings: SettingsFile::default(),
cwd,
custom_transforms: Vec::new(),
})?;
@ -202,7 +202,7 @@ impl Handler for SubWorkflowHandler {
let child_cancel = Arc::clone(&cancel_token);
let child_run_options = RunOptions {
settings: Settings::default(),
settings: SettingsFile::default(),
run_dir: child_logs,
cancel_token: Some(cancel_token),
// Child workflows are part of the parent run's event stream.

View file

@ -3,7 +3,9 @@ use fabro_graphviz::graph::{AttrValue, Graph};
use fabro_model::{Catalog, Provider};
use fabro_sandbox::SandboxProvider;
use fabro_store::Database;
use fabro_types::{RunId, RunProvenance, Settings};
use fabro_types::settings::v2::run::{RunLayer, RunModelLayer};
use fabro_types::settings::v2::{InterpString, SettingsFile};
use fabro_types::{RunId, RunProvenance};
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
@ -26,7 +28,7 @@ use crate::event::{Event, append_event, to_run_event_at};
#[derive(Clone, Debug)]
pub struct CreateRunInput {
pub workflow: WorkflowInput,
pub settings: Settings,
pub settings: SettingsFile,
pub cwd: PathBuf,
pub workflow_slug: Option<String>,
pub workflow_path: Option<PathBuf>,
@ -48,7 +50,7 @@ pub struct CreatedRun {
}
struct PersistCreateOptions {
settings: Settings,
settings: SettingsFile,
run_id: Option<RunId>,
run_dir: Option<PathBuf>,
workflow_slug: Option<String>,
@ -125,7 +127,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.labels.clone(),
labels: resolved.settings.all_labels(),
base_branch,
working_directory,
host_repo_path,
@ -246,9 +248,9 @@ fn store_error(err: impl std::fmt::Display) -> FabroError {
FabroError::engine(err.to_string())
}
fn validate_sandbox_provider(settings: &Settings) -> Result<(), FabroError> {
fn validate_sandbox_provider(settings: &SettingsFile) -> Result<(), FabroError> {
if let Some(provider) = settings
.sandbox_settings()
.run_sandbox()
.and_then(|sandbox| sandbox.provider.as_deref())
{
provider
@ -289,12 +291,11 @@ pub(super) fn preprocess_and_validate(
current_dir: Option<PathBuf>,
file_resolver: Option<Arc<dyn FileResolver>>,
custom_transforms: Vec<Box<dyn Transform>>,
settings: Option<&Settings>,
settings: Option<&SettingsFile>,
goal_override: Option<&str>,
) -> Result<Validated, FabroError> {
let source = match settings.and_then(|resolved| resolved.vars.as_ref()) {
Some(vars) => {
let mut vars = vars.clone();
let source = match settings.and_then(SettingsFile::run_inputs_as_strings) {
Some(mut vars) => {
vars.insert("goal".to_string(), "$goal".to_string());
expand_vars(dot_source, &vars)
.map_err(|e| FabroError::Parse(format!("var expansion failed: {e}")))?
@ -371,28 +372,32 @@ fn persist_validated(
)
}
pub(crate) fn resolve_run_settings(mut settings: Settings, graph: &Graph) -> Settings {
let llm_settings = settings.llm.as_ref();
let configured_model = llm_settings.and_then(|l| l.model.as_deref());
let configured_provider = llm_settings.and_then(|l| l.provider.as_deref());
let graph_provider = graph.attrs.get("default_provider").and_then(|v| v.as_str());
let graph_model = graph.attrs.get("default_model").and_then(|v| v.as_str());
pub(crate) fn resolve_run_settings(mut settings: SettingsFile, graph: &Graph) -> SettingsFile {
let configured_model = settings.run_model_name_str();
let configured_provider = settings.run_model_provider_str();
let graph_provider = graph
.attrs
.get("default_provider")
.and_then(|v| v.as_str())
.map(str::to_string);
let graph_model = graph
.attrs
.get("default_model")
.and_then(|v| v.as_str())
.map(str::to_string);
let provider = configured_provider.or(graph_provider).map(str::to_string);
let provider = configured_provider.or(graph_provider);
let model = configured_model.or(graph_model).map_or_else(
|| {
let catalog = Catalog::builtin();
provider
.as_deref()
.and_then(|value| value.parse::<Provider>().ok())
.and_then(|provider| catalog.default_for_provider(provider))
.unwrap_or_else(|| catalog.default_from_env())
.id
.clone()
},
str::to_string,
);
let model = configured_model.or(graph_model).unwrap_or_else(|| {
let catalog = Catalog::builtin();
provider
.as_deref()
.and_then(|value| value.parse::<Provider>().ok())
.and_then(|provider| catalog.default_for_provider(provider))
.unwrap_or_else(|| catalog.default_from_env())
.id
.clone()
});
let (resolved_model, resolved_provider) = match Catalog::builtin().get(&model) {
Some(info) => (
@ -402,16 +407,26 @@ pub(crate) fn resolve_run_settings(mut settings: Settings, graph: &Graph) -> Set
None => (model, provider),
};
let llm = settings.llm.get_or_insert_default();
llm.model = Some(resolved_model);
llm.provider = resolved_provider;
let run = settings.run.get_or_insert_with(RunLayer::default);
let model_layer = run.model.get_or_insert_with(RunModelLayer::default);
model_layer.name = Some(InterpString::parse(&resolved_model));
model_layer.provider = resolved_provider.as_deref().map(InterpString::parse);
let goal = graph.goal().to_string();
settings.goal = if goal.is_empty() { None } else { Some(goal) };
settings.pull_request = settings
run.goal = if goal.is_empty() {
None
} else {
Some(InterpString::parse(&goal))
};
// Strip disabled pull_request entries so downstream consumers can
// treat `Some(_)` as "PR creation is on".
if run
.pull_request
.take()
.filter(|pull_request| pull_request.enabled);
.as_ref()
.is_some_and(|pr| !pr.enabled.unwrap_or(false))
{
run.pull_request = None;
}
settings
}

View file

@ -3,7 +3,7 @@ use std::sync::Arc;
use anyhow::Context;
use fabro_config::project as project_config;
use fabro_types::Settings;
use fabro_types::settings::v2::{InterpString, SettingsFile};
use fabro_util::path::expand_tilde;
use crate::file_resolver::{FileResolver, FilesystemFileResolver};
@ -22,14 +22,14 @@ pub enum WorkflowInput {
#[derive(Clone, Debug)]
pub(crate) struct ResolveWorkflowInput {
pub workflow: WorkflowInput,
pub settings: Settings,
pub settings: SettingsFile,
pub cwd: PathBuf,
}
#[derive(Clone)]
pub(crate) struct ResolvedWorkflow {
pub raw_source: String,
pub settings: Settings,
pub settings: SettingsFile,
pub workflow_slug: Option<String>,
pub workflow_toml_path: Option<PathBuf>,
pub dot_path: Option<PathBuf>,
@ -85,10 +85,7 @@ pub(crate) fn resolve_workflow(request: ResolveWorkflowInput) -> anyhow::Result<
project_config::resolve_working_directory(&settings, &request.cwd);
let raw_source = std::fs::read_to_string(&resolution.dot_path)
.with_context(|| format!("Failed to read {}", resolution.dot_path.display()))?;
let goal_override = settings.goal.clone().or(resolve_goal_file(
settings.goal_file.as_deref(),
&working_directory,
)?);
let goal_override = resolve_goal_override(&settings, &working_directory)?;
let current_dir = resolution
.dot_path
.parent()
@ -113,10 +110,7 @@ pub(crate) fn resolve_workflow(request: ResolveWorkflowInput) -> anyhow::Result<
let settings = request.settings;
let working_directory =
project_config::resolve_working_directory(&settings, &request.cwd);
let goal_override = settings.goal.clone().or(resolve_goal_file(
settings.goal_file.as_deref(),
&working_directory,
)?);
let goal_override = resolve_goal_override(&settings, &working_directory)?;
let has_base_dir = base_dir.is_some();
Ok(ResolvedWorkflow {
raw_source: source,
@ -138,37 +132,56 @@ pub(crate) fn resolve_workflow(request: ResolveWorkflowInput) -> anyhow::Result<
let settings = request.settings;
let working_directory =
project_config::resolve_working_directory(&settings, &request.cwd);
let goal_override = settings.run_goal().map(InterpString::as_source);
Ok(ResolvedWorkflow {
raw_source: workflow.source.clone(),
settings: settings.clone(),
settings,
workflow_slug: workflow_slug_from_path(&workflow.logical_path),
workflow_toml_path: None,
dot_path: Some(workflow.logical_path.clone()),
current_dir: Some(workflow.current_dir()),
file_resolver: Some(workflow.file_resolver()),
goal_override: settings.goal.clone(),
goal_override,
working_directory,
})
}
}
}
fn resolve_goal_override(
settings: &SettingsFile,
working_directory: &Path,
) -> anyhow::Result<Option<String>> {
// V2 does not yet carry a separate `goal_file` field; file-based goals
// come through the workflow manifest layer in the server-side flow.
// For direct CLI paths, the goal override comes from `run.goal`.
Ok(settings
.run_goal()
.map(InterpString::as_source)
.or(resolve_goal_file(None, working_directory)?))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_workflow_uses_explicit_cwd_for_relative_work_dir() {
use fabro_types::settings::v2::run::RunLayer;
let dir = tempfile::tempdir().unwrap();
let resolved = resolve_workflow(ResolveWorkflowInput {
workflow: WorkflowInput::DotSource {
source: "digraph Test { start -> exit }".to_string(),
base_dir: None,
},
settings: Settings {
work_dir: Some("workspace".to_string()),
..Default::default()
settings: SettingsFile {
run: Some(RunLayer {
working_dir: Some(InterpString::parse("workspace")),
..RunLayer::default()
}),
..SettingsFile::default()
},
cwd: dir.path().to_path_buf(),
})

View file

@ -5,11 +5,14 @@ use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use fabro_config::sandbox::WorktreeMode;
use fabro_config::{project as project_config, run as run_config, sandbox as sandbox_config};
use fabro_config::{project as project_config, sandbox as sandbox_config};
use fabro_interview::{AutoApproveInterviewer, Interviewer};
use fabro_model::{Catalog, FallbackTarget, Provider};
use fabro_sandbox::{SandboxProvider, SandboxSpec};
use fabro_types::{RunId, Settings};
use fabro_types::RunId;
use fabro_types::settings::v2::bridge::{bridge_mcp_entry, bridge_sandbox, bridge_worktree_mode};
use fabro_types::settings::v2::run::ModelRefOrSplice;
use fabro_types::settings::v2::{InterpString, SettingsFile};
use crate::artifact_upload::ArtifactSink;
use crate::context::Context;
@ -260,7 +263,7 @@ async fn persist_terminal_engine_failure(
impl RunSession {
async fn new(persisted: &Persisted, services: StartServices) -> Result<Self, FabroError> {
let record = persisted.run_record();
let mut settings = record.settings.clone();
let settings = &record.settings;
let working_directory = record.working_directory.clone();
let state = services
.run_store
@ -287,34 +290,21 @@ impl RunSession {
let workflow_bundle =
accepted_definition.map(|definition| Arc::new(definition.workflow_bundle()));
if let Some(env) = settings
.sandbox
.as_mut()
.and_then(|sandbox| sandbox.env.as_mut())
{
run_config::resolve_env_refs(env)
.map_err(|err| FabroError::Precondition(err.to_string()))?;
}
let (origin_url, detected_base_branch) = detect_repo_info(&working_directory)
.map(|(url, branch)| (Some(url), branch))
.unwrap_or((None, None));
let sandbox_provider = resolve_sandbox_provider(&settings)?;
let sandbox_provider = resolve_sandbox_provider(settings)?;
let sandbox_provider = if settings.dry_run_enabled() && !sandbox_provider.is_local() {
SandboxProvider::Local
} else {
sandbox_provider
};
let model = settings
.llm
.as_ref()
.and_then(|llm| llm.model.clone())
.run_model_name_str()
.unwrap_or_else(|| Catalog::builtin().default_from_env().id.clone());
let provider = settings
.llm
.as_ref()
.and_then(|llm| llm.provider.clone())
.run_model_provider_str()
.filter(|value| !value.is_empty());
let provider_enum: Provider = provider
@ -324,13 +314,15 @@ impl RunSession {
.map_err(|err| FabroError::Precondition(err.clone()))?
.unwrap_or_else(Provider::default_from_env);
let fallback_chain = resolve_fallback_chain(provider_enum, &model, &settings);
let fallback_chain = resolve_fallback_chain(provider_enum, &model, settings);
let mcp_servers = settings
.mcp_server_entries()
.clone()
.into_iter()
.map(|(name, entry)| entry.into_config(name))
.collect();
.run_agent_mcps()
.map(|mcps| {
mcps.iter()
.map(|(name, entry)| bridge_mcp_entry(entry).into_config(name.clone()))
.collect()
})
.unwrap_or_default();
let sandbox = match sandbox_provider {
SandboxProvider::Local => SandboxSpec::Local {
@ -343,26 +335,39 @@ impl RunSession {
},
},
SandboxProvider::Daytona => SandboxSpec::Daytona {
config: resolve_daytona_config(&settings).unwrap_or_default(),
config: resolve_daytona_config(settings).unwrap_or_default(),
github_app: services.github_app.clone(),
run_id: Some(record.run_id),
clone_branch: detected_base_branch.or_else(|| record.base_branch.clone()),
},
};
let toml_env: HashMap<String, String> = settings
.run_sandbox()
.map(|sb| {
sb.env
.iter()
.map(|(k, v)| (k.clone(), resolve_interp(v)))
.collect()
})
.unwrap_or_default();
let github_permissions: Option<HashMap<String, String>> =
settings.github_permissions().map(|perms| {
perms
.iter()
.map(|(k, v)| (k.clone(), resolve_interp(v)))
.collect()
});
let sandbox_env = SandboxEnvSpec {
devcontainer_env: HashMap::new(),
toml_env: settings
.sandbox_settings()
.and_then(|sandbox| sandbox.env.clone())
.unwrap_or_default(),
github_permissions: settings.github_permissions().cloned(),
toml_env,
github_permissions,
origin_url: origin_url.clone(),
};
let devcontainer = settings
.sandbox_settings()
.and_then(|sandbox| sandbox.devcontainer)
.run_sandbox()
.and_then(|sb| sb.devcontainer)
.unwrap_or(false)
.then(|| DevcontainerSpec {
enabled: true,
@ -375,6 +380,10 @@ impl RunSession {
services.interviewer
};
let pr_config = settings
.run_pull_request()
.map(fabro_types::settings::v2::bridge::bridge_pull_request);
Ok(Self {
cancel_token: services.cancel_token,
emitter: services.emitter,
@ -391,12 +400,16 @@ impl RunSession {
interviewer,
on_node: services.on_node,
lifecycle: LifecycleOptions {
setup_commands: settings.setup_commands().to_vec(),
setup_command_timeout_ms: settings.setup_timeout_ms().unwrap_or(300_000),
setup_commands: settings.run_prepare_commands(),
setup_command_timeout_ms: settings.run_prepare_timeout_ms().unwrap_or(300_000),
devcontainer_phases: Vec::new(),
},
hooks: fabro_hooks::HookSettings {
hooks: settings.hooks.clone(),
hooks: settings
.run_hooks()
.iter()
.map(fabro_types::settings::v2::bridge::bridge_hook)
.collect(),
},
sandbox_env,
devcontainer,
@ -405,11 +418,11 @@ impl RunSession {
artifact_sink: services.artifact_sink,
git,
github_app: services.github_app.clone(),
worktree_mode: Some(resolve_worktree_mode(&settings)),
worktree_mode: Some(resolve_worktree_mode(settings)),
registry_override: services.registry_override,
retro_enabled: !settings.no_retro_enabled() && project_config::is_retro_enabled(),
preserve_sandbox: resolve_preserve_sandbox(&settings),
pr_config: settings.pull_request.clone(),
preserve_sandbox: resolve_preserve_sandbox(settings),
pr_config,
pr_github_app: services.github_app,
pr_origin_url: origin_url,
pr_model: model,
@ -419,6 +432,12 @@ impl RunSession {
}
}
fn resolve_interp(value: &InterpString) -> String {
value
.resolve(|name| std::env::var(name).ok())
.map_or_else(|_| value.as_source(), |resolved| resolved.value)
}
async fn load_accepted_run_definition(
run_store: &RunStoreHandle,
blob_id: fabro_types::RunBlobId,
@ -435,45 +454,62 @@ async fn load_accepted_run_definition(
serde_json::from_slice(&bytes).map_err(|err| FabroError::Parse(err.to_string()))
}
fn resolve_sandbox_provider(settings: &Settings) -> Result<SandboxProvider, FabroError> {
fn resolve_sandbox_provider(settings: &SettingsFile) -> Result<SandboxProvider, FabroError> {
settings
.sandbox_settings()
.and_then(|sandbox| sandbox.provider.as_deref())
.run_sandbox()
.and_then(|sb| sb.provider.as_deref())
.map(str::parse::<SandboxProvider>)
.transpose()
.map_err(|err| FabroError::Precondition(format!("Invalid sandbox provider: {err}")))?
.map_or_else(|| Ok(SandboxProvider::default()), Ok)
}
fn resolve_preserve_sandbox(settings: &Settings) -> bool {
fn resolve_preserve_sandbox(settings: &SettingsFile) -> bool {
settings.preserve_sandbox_enabled()
}
fn resolve_worktree_mode(settings: &Settings) -> sandbox_config::WorktreeMode {
fn resolve_worktree_mode(settings: &SettingsFile) -> sandbox_config::WorktreeMode {
settings
.sandbox_settings()
.and_then(|sandbox| sandbox.local.as_ref())
.map(|local| local.worktree_mode)
.run_sandbox()
.and_then(|sb| sb.local.as_ref())
.and_then(|local| local.worktree_mode)
.map(bridge_worktree_mode)
.unwrap_or_default()
}
fn resolve_daytona_config(settings: &Settings) -> Option<DaytonaConfig> {
settings
.sandbox_settings()
.and_then(|sandbox| sandbox.daytona.clone())
fn resolve_daytona_config(settings: &SettingsFile) -> Option<DaytonaConfig> {
let sandbox = settings.run_sandbox()?;
bridge_sandbox(sandbox).daytona
}
fn resolve_fallback_chain(
provider: Provider,
model: &str,
settings: &Settings,
settings: &SettingsFile,
) -> Vec<FallbackTarget> {
let fallbacks = settings.llm.as_ref().and_then(|llm| llm.fallbacks.as_ref());
match fallbacks {
Some(map) => Catalog::builtin().build_fallback_chain(provider, model, map),
None => Vec::new(),
let Some(model_layer) = settings.run_model() else {
return Vec::new();
};
if model_layer.fallbacks.is_empty() {
return Vec::new();
}
// Group v2 ModelRef entries by provider name, preserving the legacy
// shape expected by `Catalog::build_fallback_chain`. The historical
// bridge grouped all fallback tokens under the empty-string key; we
// preserve that behavior here so `Catalog::build_fallback_chain`
// returns an empty chain unless a consumer has explicitly wired
// provider-keyed fallbacks. A proper provider-aware fallback chain
// is a follow-up along with the model registry work.
let mut by_provider: HashMap<String, Vec<String>> = HashMap::new();
for entry in &model_layer.fallbacks {
if let ModelRefOrSplice::ModelRef(model_ref) = entry {
by_provider
.entry(String::new())
.or_default()
.push(model_ref.to_string());
}
}
Catalog::builtin().build_fallback_chain(provider, model, &by_provider)
}
impl RunSession {

View file

@ -1,6 +1,6 @@
use std::path::PathBuf;
use fabro_types::Settings;
use fabro_types::settings::v2::SettingsFile;
use crate::error::FabroError;
use crate::pipeline::Validated;
@ -11,7 +11,7 @@ use super::source::{ResolveWorkflowInput, WorkflowInput, resolve_workflow};
pub struct ValidateInput {
pub workflow: WorkflowInput,
pub settings: Settings,
pub settings: SettingsFile,
pub cwd: PathBuf,
pub custom_transforms: Vec<Box<dyn Transform>>,
}

View file

@ -534,8 +534,16 @@ pub async fn initialize(
build_registry(&options.llm, Arc::clone(&options.interviewer), &env, &graph).await?
};
if effective_dry_run {
use fabro_types::settings::v2::run::{RunExecutionLayer, RunLayer, RunMode};
options.dry_run = true;
options.run_options.settings.dry_run = Some(true);
let run = options
.run_options
.settings
.run
.get_or_insert_with(RunLayer::default);
let execution = run.execution.get_or_insert_with(RunExecutionLayer::default);
execution.mode = Some(RunMode::DryRun);
}
let has_run_branch = options

View file

@ -3,8 +3,9 @@ use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use fabro_config::run::PullRequestSettings;
use fabro_types::{RunId, Settings};
use fabro_types::RunId;
use fabro_types::settings::v2::SettingsFile;
use fabro_types::settings::v2::run::RunPullRequestLayer;
use crate::git::{GitAuthor, git_author_from_settings};
@ -19,7 +20,7 @@ pub struct GitCheckpointOptions {
/// Options for a workflow run.
#[derive(Clone)]
pub struct RunOptions {
pub settings: Settings,
pub settings: SettingsFile,
pub run_dir: PathBuf,
pub cancel_token: Option<Arc<AtomicBool>>,
/// Unique identifier for this workflow run.
@ -46,22 +47,23 @@ impl RunOptions {
}
pub fn checkpoint_exclude_globs(&self) -> &[String] {
&self.settings.checkpoint.exclude_globs
self.settings
.run_checkpoint()
.map_or(&[], |cp| cp.exclude_globs.as_slice())
}
pub fn git_author(&self) -> GitAuthor {
git_author_from_settings(&self.settings)
}
/// PR config (already normalized — disabled entries stripped at construction).
pub fn pull_request(&self) -> Option<&PullRequestSettings> {
self.settings.pull_request.as_ref()
/// PR config, if present in the v2 run layer.
pub fn pull_request(&self) -> Option<&RunPullRequestLayer> {
self.settings.run_pull_request()
}
pub fn artifact_globs(&self) -> &[String] {
self.settings
.artifacts
.as_ref()
.run_artifacts()
.map_or(&[], |a| a.include.as_slice())
}
}