From a740b2d39537e902bd4252038c2cd98661ee2a69 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Mon, 6 Apr 2026 13:34:28 -0400 Subject: [PATCH 1/3] refactor(settings): make settings command server-backed Default `fabro settings` now resolves effective runtime settings against the selected server, while `--local` preserves local-only inspection. This also extracts shared settings resolution logic so CLI output, manifest preparation, and the `/api/v1/settings` contract stay aligned. --- docs/api-reference/fabro-api.yaml | 76 +++ lib/crates/fabro-cli/src/args.rs | 8 +- .../fabro-cli/src/commands/config/mod.rs | 74 ++- lib/crates/fabro-cli/src/main.rs | 17 +- lib/crates/fabro-cli/src/server_client.rs | 12 +- lib/crates/fabro-cli/tests/it/cmd/config.rs | 223 ++++++++- lib/crates/fabro-cli/tests/it/cmd/fabro.rs | 2 +- lib/crates/fabro-config/Cargo.toml | 2 +- .../fabro-config/src/effective_settings.rs | 437 ++++++++++++++++++ lib/crates/fabro-config/src/lib.rs | 1 + lib/crates/fabro-server/src/run_manifest.rs | 113 ++--- lib/crates/fabro-server/src/server.rs | 40 +- lib/crates/fabro-server/tests/it/api/mod.rs | 1 + .../fabro-server/tests/it/api/settings.rs | 41 ++ .../tests/it/openapi_conformance.rs | 32 ++ 15 files changed, 971 insertions(+), 108 deletions(-) create mode 100644 lib/crates/fabro-config/src/effective_settings.rs create mode 100644 lib/crates/fabro-server/tests/it/api/settings.rs diff --git a/docs/api-reference/fabro-api.yaml b/docs/api-reference/fabro-api.yaml index 3fb0e90be..3c332e46e 100644 --- a/docs/api-reference/fabro-api.yaml +++ b/docs/api-reference/fabro-api.yaml @@ -5684,6 +5684,76 @@ components: description: Structured server settings mirroring fabro_types::Settings. type: object properties: + version: + type: integer + description: Settings schema version. + goal: + type: string + description: Default goal description. + goal_file: + type: string + description: Path to a goal file. + graph: + type: string + description: Default Graphviz graph path. + labels: + type: object + additionalProperties: + type: string + description: Default label map. + server: + type: object + properties: + target: + type: string + description: Default server target for CLI commands. + tls: + type: object + properties: + cert: + type: string + description: Client certificate path. + key: + type: string + description: Client key path. + ca: + type: string + description: Certificate authority path. + exec: + type: object + properties: + provider: + type: string + description: Default exec provider. + model: + type: string + description: Default exec model. + permissions: + type: string + enum: [read-only, read-write, full] + description: Exec permission level. + output_format: + type: string + enum: [text, json] + description: Exec output format. + prevent_idle_sleep: + type: boolean + description: Prevent system idle sleep while running. + verbose: + type: boolean + description: Enable verbose output by default. + upgrade_check: + type: boolean + description: Whether upgrade checks are enabled. + dry_run: + type: boolean + description: Default dry-run mode. + auto_approve: + type: boolean + description: Default auto-approve mode. + no_retro: + type: boolean + description: Skip retro generation by default. storage_dir: type: string description: Storage directory path. @@ -5731,6 +5801,12 @@ components: description: Default MCP server configurations. github: $ref: "#/components/schemas/GitHubSettings" + fabro: + type: object + properties: + root: + type: string + description: Project fabro root directory. GitHubSettings: description: GitHub App token injection configuration. diff --git a/lib/crates/fabro-cli/src/args.rs b/lib/crates/fabro-cli/src/args.rs index 0218496df..ee8c917b6 100644 --- a/lib/crates/fabro-cli/src/args.rs +++ b/lib/crates/fabro-cli/src/args.rs @@ -613,7 +613,11 @@ pub(crate) struct DfArgs { #[derive(Args)] pub(crate) struct SettingsArgs { #[command(flatten)] - pub(crate) storage_dir: StorageDirArgs, + pub(crate) target: ServerTargetArgs, + + /// Show only locally resolved settings and skip the server call + #[arg(long, conflicts_with = "server")] + pub(crate) local: bool, /// Optional workflow name, .fabro path, or .toml run config to overlay pub(crate) workflow: Option, @@ -921,7 +925,7 @@ pub(crate) enum Commands { Skill(SkillNamespace), /// Manage server-owned secrets Secret(SecretNamespace), - /// Inspect merged configuration + /// Inspect effective settings Settings(SettingsArgs), /// Workflow operations Workflow(WorkflowNamespace), diff --git a/lib/crates/fabro-cli/src/commands/config/mod.rs b/lib/crates/fabro-cli/src/commands/config/mod.rs index ffb84346a..6d7c3c4c0 100644 --- a/lib/crates/fabro-cli/src/commands/config/mod.rs +++ b/lib/crates/fabro-cli/src/commands/config/mod.rs @@ -2,28 +2,84 @@ use std::io::Write; use std::path::Path; use crate::args::{GlobalArgs, SettingsArgs}; +use crate::server_client; 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::project; use fabro_types::Settings; -fn merged_config(workflow: Option<&Path>, args: &SettingsArgs) -> anyhow::Result { +fn config_layers(workflow: Option<&Path>) -> anyhow::Result { let cwd = std::env::current_dir()?; - let base = match workflow { - Some(path) => ConfigLayer::for_workflow(path, &cwd)?, - None => ConfigLayer::project(&cwd)?, + let (workflow_layer, project_layer) = match workflow { + Some(path) => workflow_and_project_layers(path, &cwd)?, + None => (ConfigLayer::default(), ConfigLayer::project(&cwd)?), }; - let cli = user_config::settings_layer_with_storage_dir(args.storage_dir.as_deref())?; - - base.combine(cli).resolve() + let user_layer = user_config::settings_layer_with_storage_dir(None)?; + Ok(EffectiveSettingsLayers::new( + ConfigLayer::default(), + workflow_layer, + project_layer, + user_layer, + )) } -pub(crate) fn execute(args: &SettingsArgs, globals: &GlobalArgs) -> anyhow::Result<()> { - let config = merged_config(args.workflow.as_deref(), args)?; +fn workflow_and_project_layers( + path: &Path, + cwd: &Path, +) -> anyhow::Result<(ConfigLayer, ConfigLayer)> { + 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_layer = resolution.workflow_config.unwrap_or_default(); + let project_layer = project::discover_project_config( + resolution + .resolved_workflow_path + .parent() + .unwrap_or_else(|| Path::new(".")), + )? + .map(|(_, config)| config) + .unwrap_or_default(); + + Ok((workflow_layer, project_layer)) +} + +async fn merged_config(args: &SettingsArgs) -> anyhow::Result { + let layers = config_layers(args.workflow.as_deref())?; + if args.local { + return effective_settings::resolve_settings( + layers, + None, + EffectiveSettingsMode::LocalOnly, + ); + } + + let machine_settings = user_config::load_settings()?; + let target = user_config::resolve_server_target(&args.target, &machine_settings)?; + let client = server_client::connect_server_only(&args.target).await?; + let server_settings = client.retrieve_server_settings().await?; + let mode = match target { + user_config::ServerTarget::HttpUrl { .. } => EffectiveSettingsMode::RemoteServer, + user_config::ServerTarget::UnixSocket(_) => EffectiveSettingsMode::LocalDaemon, + }; + + effective_settings::resolve_settings(layers, Some(&server_settings), mode) +} + +pub(crate) async fn execute(args: &SettingsArgs, globals: &GlobalArgs) -> anyhow::Result<()> { + let config = merged_config(args).await?; if globals.json { print_json_pretty(&config)?; return Ok(()); } + let mut yaml = serde_yaml::to_string(&config)?; if !yaml.ends_with('\n') { yaml.push('\n'); diff --git a/lib/crates/fabro-cli/src/main.rs b/lib/crates/fabro-cli/src/main.rs index 3f87af016..80251f995 100644 --- a/lib/crates/fabro-cli/src/main.rs +++ b/lib/crates/fabro-cli/src/main.rs @@ -225,7 +225,7 @@ async fn main_inner() -> (String, Result<()>) { } Commands::Pr(ns) => Box::pin(commands::pr::dispatch(ns, &globals)).await?, Commands::Secret(ns) => commands::secret::dispatch(ns, &globals).await?, - Commands::Settings(args) => commands::config::execute(&args, &globals)?, + Commands::Settings(args) => commands::config::execute(&args, &globals).await?, Commands::Workflow(ns) => commands::workflow::dispatch(ns, &globals)?, Commands::Skill(ns) => commands::skill::dispatch(ns, &globals)?, Commands::Upgrade(args) => { @@ -534,6 +534,8 @@ mod tests { assert_eq!(cli.command.name(), "settings"); match *cli.command { Commands::Settings(args) => { + assert!(!args.local); + assert!(args.target.server.is_none()); assert!(args.workflow.is_none()); } _ => panic!("unexpected command variant"), @@ -551,6 +553,19 @@ mod tests { } } + #[test] + fn parse_settings_local_mode() { + let cli = + Cli::try_parse_from(["fabro", "settings", "--local", "demo"]).expect("should parse"); + match *cli.command { + Commands::Settings(args) => { + assert!(args.local); + assert_eq!(args.workflow, Some(std::path::PathBuf::from("demo"))); + } + _ => panic!("unexpected command variant"), + } + } + #[test] fn parse_quiet_flag() { let cli = Cli::try_parse_from(["fabro", "--quiet", "settings"]).expect("should parse"); diff --git a/lib/crates/fabro-cli/src/server_client.rs b/lib/crates/fabro-cli/src/server_client.rs index e04a0e9fd..1fd6f832d 100644 --- a/lib/crates/fabro-cli/src/server_client.rs +++ b/lib/crates/fabro-cli/src/server_client.rs @@ -9,7 +9,7 @@ use fabro_server::bind::Bind; use fabro_store::{EventEnvelope, RunSummary, StageId}; use fabro_types::{ Checkpoint, Conclusion, NodeStatusRecord, PullRequestRecord, Retro, RunEvent, RunId, RunRecord, - RunStatusRecord, SandboxRecord, StartRecord, + RunStatusRecord, SandboxRecord, Settings, StartRecord, }; use futures::StreamExt; use serde::de::DeserializeOwned; @@ -233,6 +233,16 @@ impl ServerStoreClient { self.clone() } + pub(crate) async fn retrieve_server_settings(&self) -> Result { + let response = self + .client + .retrieve_server_settings() + .send() + .await + .map_err(map_api_error)?; + convert_type(response.into_inner()) + } + pub(crate) async fn create_run_from_manifest( &self, manifest: types::RunManifest, diff --git a/lib/crates/fabro-cli/tests/it/cmd/config.rs b/lib/crates/fabro-cli/tests/it/cmd/config.rs index 753028ec7..b85d5bebd 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/config.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/config.rs @@ -3,6 +3,7 @@ use std::path::PathBuf; use fabro_config::mcp::McpTransport; use fabro_test::{fabro_snapshot, test_context}; use fabro_types::Settings; +use httpmock::MockServer; use predicates::prelude::*; use super::support::run_state; @@ -17,7 +18,7 @@ fn help() { success: true exit_code: 0 ----- stdout ----- - Inspect merged configuration + Inspect effective settings Usage: fabro settings [OPTIONS] [WORKFLOW] @@ -25,13 +26,14 @@ fn help() { [WORKFLOW] Optional workflow name, .fabro path, or .toml run config to overlay Options: - --json Output as JSON [env: FABRO_JSON=] - --storage-dir Local storage directory (default: ~/.fabro/storage) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]] - --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] - --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] - --quiet Suppress non-essential output [env: FABRO_QUIET=] - --verbose Enable verbose output [env: FABRO_VERBOSE=] - -h, --help Print help + --json Output as JSON [env: FABRO_JSON=] + --server Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=] + --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] + --local Show only locally resolved settings and skip the server call + --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] + --quiet Suppress non-essential output [env: FABRO_QUIET=] + --verbose Enable verbose output [env: FABRO_VERBOSE=] + -h, --help Print help ----- stderr ----- "); } @@ -62,6 +64,47 @@ fn parse_settings(stdout: &[u8]) -> Settings { serde_yaml::from_slice(stdout).expect("stdout should be valid YAML Settings") } +fn server_settings_fixture() -> Settings { + toml::from_str( + r#" +storage_dir = "/srv/fabro-server" +verbose = false + +[llm] +model = "server-model" +provider = "openai" + +[vars] +server_only = "1" +shared = "server" +"#, + ) + .expect("server settings fixture should parse") +} + +fn server_settings_body(settings: &Settings) -> String { + fn strip_nulls(value: &mut serde_json::Value) { + match value { + serde_json::Value::Object(map) => { + for child in map.values_mut() { + strip_nulls(child); + } + map.retain(|_, child| !child.is_null()); + } + serde_json::Value::Array(values) => { + for child in values { + strip_nulls(child); + } + } + _ => {} + } + } + + let mut value = serde_json::to_value(settings).expect("settings fixture should serialize"); + strip_nulls(&mut value); + serde_json::to_string(&value).expect("settings payload should serialize") +} + /// Set up home config and project config for settings command tests. /// Uses `context.home_dir` for the home directory. Returns project tempdir. fn setup_settings_fixture(context: &fabro_test::TestContext) -> tempfile::TempDir { @@ -255,12 +298,13 @@ commands = ["workflow-setup"] // --------------------------------------------------------------------------- #[test] -fn settings_merges_cli_and_project_defaults() { +fn settings_local_merges_cli_and_project_defaults() { let context = test_context!(); let project = setup_settings_fixture(&context); let output = context .settings() + .arg("--local") .current_dir(project.path()) .assert() .success() @@ -291,14 +335,14 @@ fn settings_merges_cli_and_project_defaults() { } #[test] -fn settings_workflow_name_applies_run_overlay_and_deep_merges() { +fn settings_local_workflow_name_applies_run_overlay_and_deep_merges() { let context = test_context!(); let project = setup_settings_fixture(&context); let output = context .settings() .current_dir(project.path()) - .args(["demo"]) + .args(["--local", "demo"]) .assert() .success() .get_output() @@ -367,7 +411,7 @@ fn settings_workflow_name_applies_run_overlay_and_deep_merges() { } #[test] -fn settings_explicit_workflow_path_uses_workflow_project_layers() { +fn settings_local_explicit_workflow_path_uses_workflow_project_layers() { let mut context = test_context!(); let (project, _storage_dir) = setup_external_workflow_fixture(&mut context); let cwd = tempfile::tempdir().unwrap(); @@ -378,7 +422,7 @@ fn settings_explicit_workflow_path_uses_workflow_project_layers() { .settings() .env_remove("FABRO_STORAGE_DIR") .current_dir(cwd.path()) - .args([workflow.to_str().unwrap()]) + .args(["--local", workflow.to_str().unwrap()]) .assert() .success() .get_output() @@ -473,6 +517,7 @@ fn settings_fabro_path_matches_ambient_defaults() { let ambient = context .settings() + .arg("--local") .current_dir(project.path()) .assert() .success() @@ -482,7 +527,7 @@ fn settings_fabro_path_matches_ambient_defaults() { let graph = context .settings() .current_dir(project.path()) - .args(["standalone.fabro"]) + .args(["--local", "standalone.fabro"]) .assert() .success() .get_output() @@ -499,7 +544,7 @@ fn settings_missing_run_config_errors() { let mut cmd = context.settings(); cmd.current_dir(project.path()); - cmd.args(["missing.toml"]); + cmd.args(["--local", "missing.toml"]); let output = cmd.output().expect("command should execute"); assert!(!output.status.success()); assert!(String::from_utf8_lossy(&output.stdout).trim().is_empty()); @@ -531,6 +576,7 @@ model = "legacy-model" let assert = context .settings() + .arg("--local") .current_dir(project.path()) .assert() .success() @@ -559,6 +605,7 @@ shared = "legacy" let assert = context .settings() + .arg("--local") .current_dir(project.path()) .assert() .success() @@ -593,7 +640,7 @@ model = "from-fabro-home" let output = context .settings() - .arg("--json") + .args(["--local", "--json"]) .env("FABRO_HOME", fabro_home.path()) .env_remove("FABRO_STORAGE_DIR") .output() @@ -623,3 +670,147 @@ fn settings_rejects_server_url_flag() { "unexpected argument '--server-url' found", )); } + +#[test] +fn settings_rejects_storage_dir_flag() { + let context = test_context!(); + context + .settings() + .args(["--storage-dir", "/tmp/fabro-settings"]) + .assert() + .failure() + .stderr(predicate::str::contains( + "unexpected argument '--storage-dir' found", + )); +} + +#[test] +fn settings_rejects_local_and_server_combination() { + let context = test_context!(); + context + .settings() + .args(["--local", "--server", "https://cli.example.com"]) + .assert() + .failure() + .stderr(predicate::str::contains( + "the argument '--local' cannot be used with '--server '", + )); +} + +#[test] +fn settings_fetches_server_settings_and_merges_with_local_config() { + let context = test_context!(); + let project = setup_settings_fixture(&context); + let server = MockServer::start(); + let server_settings = server_settings_fixture(); + let mock = server.mock(|when, then| { + when.method("GET").path("/api/v1/settings"); + then.status(200) + .header("Content-Type", "application/json") + .body(server_settings_body(&server_settings)); + }); + context.write_home( + ".fabro/settings.toml", + format!( + r#" +server = {{ target = "{}/api/v1" }} +verbose = true + +[llm] +model = "cli-model" +provider = "openai" + +[vars] +cli_only = "1" +shared = "cli" +"#, + server.base_url() + ), + ); + + let output = context + .settings() + .current_dir(project.path()) + .assert() + .success() + .get_output() + .stdout + .clone(); + + mock.assert(); + let cfg = parse_settings(&output); + let llm = cfg.llm.as_ref().expect("llm config"); + assert_eq!(llm.model.as_deref(), Some("project-model")); + assert_eq!(llm.provider.as_deref(), Some("openai")); + assert_eq!(cfg.storage_dir, Some(PathBuf::from("/srv/fabro-server"))); + assert_eq!(cfg.verbose, Some(true)); + + let vars = cfg.vars.as_ref().expect("vars"); + assert_eq!(vars.get("server_only").map(String::as_str), Some("1")); + assert_eq!(vars.get("project_only").map(String::as_str), Some("1")); + assert_eq!(vars.get("shared").map(String::as_str), Some("project")); +} + +#[test] +fn settings_cli_server_target_overrides_configured_server_target() { + let context = test_context!(); + let project = setup_settings_fixture(&context); + let configured_server = MockServer::start(); + let configured_mock = configured_server.mock(|when, then| { + when.method("GET").path("/api/v1/settings"); + then.status(500) + .body("configured-server-should-not-be-used"); + }); + let cli_server = MockServer::start(); + let cli_server_settings = server_settings_fixture(); + let cli_mock = cli_server.mock(|when, then| { + when.method("GET").path("/api/v1/settings"); + then.status(200) + .header("Content-Type", "application/json") + .body(server_settings_body(&cli_server_settings)); + }); + context.write_home( + ".fabro/settings.toml", + format!( + r#" +[server] +target = "{}/api/v1" + +verbose = true +"#, + configured_server.base_url() + ), + ); + + let output = context + .settings() + .current_dir(project.path()) + .args(["--server", &format!("{}/api/v1", cli_server.base_url())]) + .assert() + .success() + .get_output() + .stdout + .clone(); + + cli_mock.assert(); + configured_mock.assert_calls(0); + let cfg = parse_settings(&output); + assert_eq!(cfg.storage_dir, Some(PathBuf::from("/srv/fabro-server"))); +} + +#[test] +fn settings_unreachable_http_target_fails_clearly() { + let context = test_context!(); + let project = setup_settings_fixture(&context); + + context + .settings() + .current_dir(project.path()) + .args(["--server", "http://127.0.0.1:9"]) + .assert() + .failure() + .stderr( + predicate::str::contains("retrieve_server_settings") + .or(predicate::str::contains("error sending request")), + ); +} diff --git a/lib/crates/fabro-cli/tests/it/cmd/fabro.rs b/lib/crates/fabro-cli/tests/it/cmd/fabro.rs index 4ac0b0589..d598591ae 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/fabro.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/fabro.rs @@ -34,7 +34,7 @@ fn help() { install Set up the Fabro environment (LLMs, certs, GitHub) pr Pull request operations secret Manage server-owned secrets - settings Inspect merged configuration + settings Inspect effective settings workflow Workflow operations discord Open the Discord community in the browser docs Open the docs website in the browser diff --git a/lib/crates/fabro-config/Cargo.toml b/lib/crates/fabro-config/Cargo.toml index afd0d175e..fb35c7db0 100644 --- a/lib/crates/fabro-config/Cargo.toml +++ b/lib/crates/fabro-config/Cargo.toml @@ -24,11 +24,11 @@ fabro-types = { path = "../fabro-types" } fabro-util = { path = "../fabro-util" } dirs.workspace = true serde.workspace = true +serde_json.workspace = true strsim = "0.11" toml.workspace = true tracing.workspace = true [dev-dependencies] -serde_json.workspace = true tempfile = "3" toml.workspace = true diff --git a/lib/crates/fabro-config/src/effective_settings.rs b/lib/crates/fabro-config/src/effective_settings.rs new file mode 100644 index 000000000..04ffa275c --- /dev/null +++ b/lib/crates/fabro-config/src/effective_settings.rs @@ -0,0 +1,437 @@ +use anyhow::{Result, anyhow}; +use fabro_types::Settings; + +use crate::ConfigLayer; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum EffectiveSettingsMode { + LocalOnly, + RemoteServer, + LocalDaemon, +} + +#[derive(Clone, Debug, Default)] +pub struct EffectiveSettingsLayers { + pub args: ConfigLayer, + pub workflow: ConfigLayer, + pub project: ConfigLayer, + pub user: ConfigLayer, +} + +impl EffectiveSettingsLayers { + #[must_use] + pub fn new( + args: ConfigLayer, + workflow: ConfigLayer, + project: ConfigLayer, + user: ConfigLayer, + ) -> Self { + Self { + args, + workflow, + project, + user, + } + } +} + +pub fn resolve_settings( + layers: EffectiveSettingsLayers, + server_settings: Option<&Settings>, + mode: EffectiveSettingsMode, +) -> Result { + let EffectiveSettingsLayers { + args, + mut workflow, + mut project, + mut user, + } = layers; + + match mode { + EffectiveSettingsMode::LocalOnly => args + .combine(workflow) + .combine(project) + .combine(user) + .resolve(), + EffectiveSettingsMode::RemoteServer | EffectiveSettingsMode::LocalDaemon => { + let server_settings = server_settings.ok_or_else(|| { + anyhow!("server settings are required for server-targeted settings resolution") + })?; + strip_server_owned_fields(&mut workflow); + strip_server_owned_fields(&mut project); + strip_server_owned_fields(&mut user); + + let server_defaults = match mode { + EffectiveSettingsMode::RemoteServer => server_defaults_layer(server_settings)?, + EffectiveSettingsMode::LocalDaemon => { + local_daemon_server_overrides_layer(server_settings)? + } + EffectiveSettingsMode::LocalOnly => unreachable!(), + }; + + let mut settings = args + .combine(workflow) + .combine(project) + .combine(user) + .combine(server_defaults) + .resolve()?; + settings + .storage_dir + .clone_from(&server_settings.storage_dir); + Ok(settings) + } + } +} + +fn server_defaults_layer(settings: &Settings) -> Result { + let mut layer: ConfigLayer = serde_json::from_value(serde_json::to_value(settings)?)?; + // Run manifests carry their own dry-run intent. Do not let a daemon's + // startup-time fallback mode silently force every submitted run/preflight + // into simulation. + layer.dry_run = None; + Ok(layer) +} + +fn local_daemon_server_overrides_layer(settings: &Settings) -> Result { + let layer = server_defaults_layer(settings)?; + Ok(ConfigLayer { + storage_dir: layer.storage_dir, + max_concurrent_runs: layer.max_concurrent_runs, + web: layer.web, + api: layer.api, + features: layer.features, + ..Default::default() + }) +} + +fn strip_server_owned_fields(layer: &mut ConfigLayer) { + layer.server = None; + layer.exec = None; + layer.storage_dir = None; + layer.max_concurrent_runs = None; + layer.web = None; + layer.api = None; + layer.features = None; + layer.log = None; +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::{EffectiveSettingsLayers, EffectiveSettingsMode, resolve_settings}; + use crate::ConfigLayer; + + fn layer(source: &str) -> ConfigLayer { + toml::from_str(source).expect("config layer fixture should parse") + } + + #[test] + fn local_only_merges_project_and_user_layers() { + let settings = resolve_settings( + EffectiveSettingsLayers::new( + ConfigLayer::default(), + ConfigLayer::default(), + layer( + r#" +[llm] +model = "project-model" + +[vars] +project_only = "1" +shared = "project" +"#, + ), + layer( + r#" +storage_dir = "/tmp/local-storage" + +[llm] +provider = "openai" + +[vars] +user_only = "1" +shared = "user" +"#, + ), + ), + None, + EffectiveSettingsMode::LocalOnly, + ) + .unwrap(); + + let llm = settings.llm.expect("llm config"); + assert_eq!(llm.model.as_deref(), Some("project-model")); + assert_eq!(llm.provider.as_deref(), Some("openai")); + assert_eq!( + settings.storage_dir, + Some(PathBuf::from("/tmp/local-storage")) + ); + assert_eq!( + settings + .vars + .as_ref() + .and_then(|vars| vars.get("project_only")), + Some(&"1".to_string()) + ); + assert_eq!( + settings + .vars + .as_ref() + .and_then(|vars| vars.get("user_only")), + Some(&"1".to_string()) + ); + assert_eq!( + settings.vars.as_ref().and_then(|vars| vars.get("shared")), + Some(&"project".to_string()) + ); + } + + #[test] + fn local_only_merges_workflow_project_and_user_layers() { + let settings = resolve_settings( + EffectiveSettingsLayers::new( + ConfigLayer::default(), + layer( + r#" +goal = "workflow goal" + +[llm] +model = "workflow-model" + +[vars] +workflow_only = "1" +shared = "workflow" +"#, + ), + layer( + r#" +[llm] +model = "project-model" + +[vars] +project_only = "1" +shared = "project" +"#, + ), + layer( + r#" +[llm] +provider = "openai" + +[vars] +user_only = "1" +shared = "user" +"#, + ), + ), + None, + EffectiveSettingsMode::LocalOnly, + ) + .unwrap(); + + let llm = settings.llm.expect("llm config"); + assert_eq!(settings.goal.as_deref(), Some("workflow goal")); + assert_eq!(llm.model.as_deref(), Some("workflow-model")); + assert_eq!(llm.provider.as_deref(), Some("openai")); + assert_eq!( + settings + .vars + .as_ref() + .and_then(|vars| vars.get("workflow_only")), + Some(&"1".to_string()) + ); + assert_eq!( + settings + .vars + .as_ref() + .and_then(|vars| vars.get("project_only")), + Some(&"1".to_string()) + ); + assert_eq!( + settings + .vars + .as_ref() + .and_then(|vars| vars.get("user_only")), + Some(&"1".to_string()) + ); + assert_eq!( + settings.vars.as_ref().and_then(|vars| vars.get("shared")), + Some(&"workflow".to_string()) + ); + } + + #[test] + fn remote_server_mode_merges_server_defaults_without_allowing_server_owned_local_overrides() { + let server_settings: fabro_types::Settings = toml::from_str( + r#" +storage_dir = "/srv/fabro" +max_concurrent_runs = 9 +dry_run = true + +[vars] +server_only = "1" +shared = "server" +"#, + ) + .unwrap(); + + let settings = resolve_settings( + EffectiveSettingsLayers::new( + ConfigLayer::default(), + ConfigLayer::default(), + layer( + r#" +storage_dir = "/tmp/local-storage" +max_concurrent_runs = 3 + +[vars] +project_only = "1" +shared = "project" +"#, + ), + ConfigLayer::default(), + ), + Some(&server_settings), + EffectiveSettingsMode::RemoteServer, + ) + .unwrap(); + + assert_eq!(settings.storage_dir, Some(PathBuf::from("/srv/fabro"))); + assert_eq!(settings.max_concurrent_runs, Some(9)); + assert_eq!(settings.dry_run, None); + assert_eq!( + settings + .vars + .as_ref() + .and_then(|vars| vars.get("server_only")), + Some(&"1".to_string()) + ); + assert_eq!( + settings + .vars + .as_ref() + .and_then(|vars| vars.get("project_only")), + Some(&"1".to_string()) + ); + assert_eq!( + settings.vars.as_ref().and_then(|vars| vars.get("shared")), + Some(&"project".to_string()) + ); + } + + #[test] + fn remote_server_mode_merges_workflow_project_user_and_server_layers() { + let server_settings: fabro_types::Settings = toml::from_str( + r#" +storage_dir = "/srv/fabro" + +[vars] +server_only = "1" +shared = "server" +"#, + ) + .unwrap(); + + let settings = resolve_settings( + EffectiveSettingsLayers::new( + ConfigLayer::default(), + layer( + r#" +[llm] +model = "workflow-model" + +[vars] +workflow_only = "1" +shared = "workflow" +"#, + ), + layer( + r#" +[vars] +project_only = "1" +shared = "project" +"#, + ), + layer( + r#" +[llm] +provider = "openai" + +[vars] +user_only = "1" +"#, + ), + ), + Some(&server_settings), + EffectiveSettingsMode::RemoteServer, + ) + .unwrap(); + + let llm = settings.llm.expect("llm config"); + assert_eq!(llm.model.as_deref(), Some("workflow-model")); + assert_eq!(llm.provider.as_deref(), Some("openai")); + assert_eq!(settings.storage_dir, Some(PathBuf::from("/srv/fabro"))); + assert_eq!( + settings + .vars + .as_ref() + .and_then(|vars| vars.get("workflow_only")), + Some(&"1".to_string()) + ); + assert_eq!( + settings + .vars + .as_ref() + .and_then(|vars| vars.get("project_only")), + Some(&"1".to_string()) + ); + assert_eq!( + settings + .vars + .as_ref() + .and_then(|vars| vars.get("user_only")), + Some(&"1".to_string()) + ); + assert_eq!( + settings + .vars + .as_ref() + .and_then(|vars| vars.get("server_only")), + Some(&"1".to_string()) + ); + assert_eq!( + settings.vars.as_ref().and_then(|vars| vars.get("shared")), + Some(&"workflow".to_string()) + ); + } + + #[test] + fn local_daemon_mode_only_applies_server_owned_overrides() { + let server_settings: fabro_types::Settings = toml::from_str( + r#" +storage_dir = "/srv/fabro" +max_concurrent_runs = 7 + +[llm] +model = "server-model" + +[vars] +server_only = "1" +"#, + ) + .unwrap(); + + let settings = resolve_settings( + EffectiveSettingsLayers::default(), + Some(&server_settings), + EffectiveSettingsMode::LocalDaemon, + ) + .unwrap(); + + assert_eq!(settings.storage_dir, Some(PathBuf::from("/srv/fabro"))); + assert_eq!(settings.max_concurrent_runs, Some(7)); + assert_eq!(settings.llm, None); + assert_eq!(settings.vars, None); + } +} diff --git a/lib/crates/fabro-config/src/lib.rs b/lib/crates/fabro-config/src/lib.rs index eea98626d..89b49219e 100644 --- a/lib/crates/fabro-config/src/lib.rs +++ b/lib/crates/fabro-config/src/lib.rs @@ -2,6 +2,7 @@ extern crate self as fabro_config; pub mod combine; pub mod config; +pub mod effective_settings; pub mod home; pub mod hook; pub mod legacy_env; diff --git a/lib/crates/fabro-server/src/run_manifest.rs b/lib/crates/fabro-server/src/run_manifest.rs index b45f8b2c5..b58f21202 100644 --- a/lib/crates/fabro-server/src/run_manifest.rs +++ b/lib/crates/fabro-server/src/run_manifest.rs @@ -5,6 +5,8 @@ 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::project::resolve_working_directory; use fabro_config::run::{LlmConfig, parse_run_config}; use fabro_config::sandbox::{DockerfileSource, SandboxConfig}; @@ -71,21 +73,15 @@ pub(crate) fn prepare_manifest_with_mode( .try_fold(ConfigLayer::default(), |layer, config| { Ok::<_, anyhow::Error>(parse_manifest_config(config)?.combine(layer)) })?; - let server_defaults = if local_daemon_mode { - local_daemon_server_overrides_layer(server_settings)? - } else { - server_defaults_layer(server_settings)? - }; - - let mut settings = args_layer - .combine(workflow_layer) - .combine(project_layer) - .combine(user_layer) - .combine(server_defaults) - .resolve()?; - settings - .storage_dir - .clone_from(&server_settings.storage_dir); + let mut settings = effective_settings::resolve_settings( + EffectiveSettingsLayers::new(args_layer, workflow_layer, project_layer, user_layer), + Some(server_settings), + if local_daemon_mode { + EffectiveSettingsMode::LocalDaemon + } else { + EffectiveSettingsMode::RemoteServer + }, + )?; if let Some(goal) = manifest.goal.as_ref() { settings.goal = Some(goal.text.clone()); settings.goal_file = None; @@ -195,7 +191,6 @@ fn root_workflow_config_layer( let mut layer = parse_run_config(&config.source)?; resolve_manifest_dockerfile(&mut layer, Path::new(&config.path), &workflow.files)?; - strip_server_owned_fields(&mut layer); Ok(layer) } @@ -203,66 +198,7 @@ fn parse_manifest_config(config: &types::ManifestConfig) -> Result let Some(source) = config.source.as_deref() else { return Ok(ConfigLayer::default()); }; - let mut layer: ConfigLayer = toml::from_str(source)?; - strip_server_owned_fields(&mut layer); - Ok(layer) -} - -fn resolve_manifest_dockerfile( - layer: &mut ConfigLayer, - config_path: &Path, - files: &HashMap, -) -> Result<()> { - let source = layer - .sandbox - .as_mut() - .and_then(|sandbox| sandbox.daytona.as_mut()) - .and_then(|daytona| daytona.snapshot.as_mut()) - .and_then(|snapshot| snapshot.dockerfile.as_mut()); - let Some(DockerfileSource::Path { path }) = source else { - return Ok(()); - }; - let logical_path = - normalize_logical_path(config_path.parent().unwrap_or_else(|| Path::new(".")), path) - .ok_or_else(|| anyhow!("unsupported dockerfile reference: {path}"))?; - let content = files - .get(&logical_path) - .cloned() - .ok_or_else(|| anyhow!("missing bundled dockerfile: {}", logical_path.display()))?; - *source.unwrap() = DockerfileSource::Inline(content); - Ok(()) -} - -fn server_defaults_layer(settings: &Settings) -> Result { - let mut layer: ConfigLayer = serde_json::from_value(serde_json::to_value(settings)?)?; - // Run manifests carry their own dry-run intent. Do not let a daemon's - // startup-time fallback mode silently force every submitted run/preflight - // into simulation. - layer.dry_run = None; - Ok(layer) -} - -fn local_daemon_server_overrides_layer(settings: &Settings) -> Result { - let layer = server_defaults_layer(settings)?; - Ok(ConfigLayer { - storage_dir: layer.storage_dir, - max_concurrent_runs: layer.max_concurrent_runs, - web: layer.web, - api: layer.api, - features: layer.features, - ..Default::default() - }) -} - -fn strip_server_owned_fields(layer: &mut ConfigLayer) { - layer.server = None; - layer.exec = None; - layer.storage_dir = None; - layer.max_concurrent_runs = None; - layer.web = None; - layer.api = None; - layer.features = None; - layer.log = None; + toml::from_str(source).map_err(Into::into) } fn manifest_args_layer(args: Option<&types::ManifestArgs>) -> ConfigLayer { @@ -302,6 +238,31 @@ fn parse_labels(labels: &[String]) -> HashMap { .collect() } +fn resolve_manifest_dockerfile( + layer: &mut ConfigLayer, + config_path: &Path, + files: &HashMap, +) -> Result<()> { + let source = layer + .sandbox + .as_mut() + .and_then(|sandbox| sandbox.daytona.as_mut()) + .and_then(|daytona| daytona.snapshot.as_mut()) + .and_then(|snapshot| snapshot.dockerfile.as_mut()); + let Some(DockerfileSource::Path { path }) = source else { + return Ok(()); + }; + let logical_path = + normalize_logical_path(config_path.parent().unwrap_or_else(|| Path::new(".")), path) + .ok_or_else(|| anyhow!("unsupported dockerfile reference: {path}"))?; + let content = files + .get(&logical_path) + .cloned() + .ok_or_else(|| anyhow!("missing bundled dockerfile: {}", logical_path.display()))?; + *source.unwrap() = DockerfileSource::Inline(content); + Ok(()) +} + fn normalize_logical_path(current_dir: &Path, reference: &str) -> Option { let path = Path::new(reference); if path.is_absolute() || reference.starts_with('~') { diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 79820b0f9..1b1766e30 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -557,7 +557,7 @@ fn real_routes() -> Router> { .route("/repos/github/{owner}/{name}", get(get_github_repo)) .route("/health/diagnostics", post(run_diagnostics)) .route("/completions", post(create_completion)) - .route("/settings", get(not_implemented)) + .route("/settings", get(get_server_settings)) .route("/usage", get(get_aggregate_usage)) } @@ -573,6 +573,44 @@ async fn health() -> Response { .into_response() } +async fn get_server_settings( + _auth: AuthenticatedService, + State(state): State>, +) -> Response { + let settings = state.settings.read().unwrap().clone(); + let response = match api_server_settings(&settings) { + Ok(response) => response, + Err(err) => { + return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()) + .into_response(); + } + }; + (StatusCode::OK, Json(response)).into_response() +} + +fn api_server_settings(settings: &Settings) -> anyhow::Result { + let mut value = serde_json::to_value(settings)?; + strip_nulls(&mut value); + serde_json::from_value(value).map_err(Into::into) +} + +fn strip_nulls(value: &mut serde_json::Value) { + match value { + serde_json::Value::Object(map) => { + for child in map.values_mut() { + strip_nulls(child); + } + map.retain(|_, child| !child.is_null()); + } + serde_json::Value::Array(values) => { + for child in values { + strip_nulls(child); + } + } + _ => {} + } +} + async fn list_secrets(_auth: AuthenticatedService, State(state): State>) -> Response { let data = state.secret_store.read().await.list(); (StatusCode::OK, Json(serde_json::json!({ "data": data }))).into_response() diff --git a/lib/crates/fabro-server/tests/it/api/mod.rs b/lib/crates/fabro-server/tests/it/api/mod.rs index d07c2fefc..62180f615 100644 --- a/lib/crates/fabro-server/tests/it/api/mod.rs +++ b/lib/crates/fabro-server/tests/it/api/mod.rs @@ -1,3 +1,4 @@ #[cfg(target_os = "linux")] mod mtls; mod routing; +mod settings; diff --git a/lib/crates/fabro-server/tests/it/api/settings.rs b/lib/crates/fabro-server/tests/it/api/settings.rs new file mode 100644 index 000000000..a204a2908 --- /dev/null +++ b/lib/crates/fabro-server/tests/it/api/settings.rs @@ -0,0 +1,41 @@ +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use fabro_server::jwt_auth::AuthMode; +use fabro_server::server::{build_router, create_app_state_with_options}; +use fabro_types::Settings; +use tower::ServiceExt; + +use crate::helpers::body_json; + +#[tokio::test] +async fn retrieve_server_settings_returns_runtime_settings() { + let settings: Settings = toml::from_str( + r#" +storage_dir = "/srv/fabro" +max_concurrent_runs = 9 +verbose = true + +[vars] +server_only = "1" +"#, + ) + .expect("settings fixture should parse"); + let app = build_router( + create_app_state_with_options(settings, 5), + AuthMode::Disabled, + ); + + let request = Request::builder() + .method("GET") + .uri("/api/v1/settings") + .body(Body::empty()) + .unwrap(); + let response = app.oneshot(request).await.unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = body_json(response.into_body()).await; + assert_eq!(body["storage_dir"], "/srv/fabro"); + assert_eq!(body["max_concurrent_runs"], 9); + assert_eq!(body["verbose"], true); + assert_eq!(body["vars"]["server_only"], "1"); +} diff --git a/lib/crates/fabro-server/tests/it/openapi_conformance.rs b/lib/crates/fabro-server/tests/it/openapi_conformance.rs index abdfd547d..1afe1241b 100644 --- a/lib/crates/fabro-server/tests/it/openapi_conformance.rs +++ b/lib/crates/fabro-server/tests/it/openapi_conformance.rs @@ -19,6 +19,10 @@ use fabro_sandbox::daytona::*; use fabro_server::jwt_auth::AuthMode; use fabro_server::server::build_router; use fabro_server::server_config::*; +use fabro_types::settings::{ + ClientTlsSettings, ExecSettings, OutputFormat, PermissionLevel, ProjectSettings, + ServerSettings as UserServerSettings, +}; use tower::ServiceExt; fn load_spec() -> openapiv3::OpenAPI { @@ -230,6 +234,31 @@ fn compare_schema( /// in the serialized JSON. fn fully_populated_server_config() -> Settings { Settings { + version: Some(1), + goal: Some("default goal".into()), + goal_file: Some("/tmp/goal.txt".into()), + graph: Some("workflow.fabro".into()), + labels: std::collections::HashMap::from([("scope".into(), "server".into())]), + server: Some(UserServerSettings { + target: Some("https://server.example.com".into()), + tls: Some(ClientTlsSettings { + cert: "client-cert.pem".into(), + key: "client-key.pem".into(), + ca: "ca.pem".into(), + }), + }), + exec: Some(ExecSettings { + provider: Some("openai".into()), + model: Some("gpt-5.4".into()), + permissions: Some(PermissionLevel::ReadWrite), + output_format: Some(OutputFormat::Json), + }), + prevent_idle_sleep: Some(true), + verbose: Some(true), + upgrade_check: Some(false), + dry_run: Some(true), + auto_approve: Some(true), + no_retro: Some(true), storage_dir: Some("/data".into()), max_concurrent_runs: Some(10), web: Some(WebSettings { @@ -380,6 +409,9 @@ fn fully_populated_server_config() -> Settings { github: Some(GitHubSettings { permissions: std::collections::HashMap::from([("contents".into(), "read".into())]), }), + fabro: Some(ProjectSettings { + root: "fabro".into(), + }), ..Default::default() } } From 3d7c8392787d6e385c782a751bae9b8853d19225 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Mon, 6 Apr 2026 13:46:57 -0400 Subject: [PATCH 2/3] chore(api-client): regenerate settings client models --- .../src/.openapi-generator/FILES | 4 ++ .../fabro-api-client/src/models/index.ts | 4 ++ .../src/models/server-settings-exec.ts | 50 +++++++++++++++++ .../src/models/server-settings-fabro.ts | 23 ++++++++ .../src/models/server-settings-server-tls.ts | 31 ++++++++++ .../src/models/server-settings-server.ts | 27 +++++++++ .../src/models/server-settings.ts | 56 +++++++++++++++++++ 7 files changed, 195 insertions(+) create mode 100644 lib/packages/fabro-api-client/src/models/server-settings-exec.ts create mode 100644 lib/packages/fabro-api-client/src/models/server-settings-fabro.ts create mode 100644 lib/packages/fabro-api-client/src/models/server-settings-server-tls.ts create mode 100644 lib/packages/fabro-api-client/src/models/server-settings-server.ts diff --git a/lib/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES index 231f47fbc..541f1bfb0 100644 --- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES +++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES @@ -178,6 +178,10 @@ models/secret-list-response.ts models/secret-metadata.ts models/send-message-request.ts models/send-message-response.ts +models/server-settings-exec.ts +models/server-settings-fabro.ts +models/server-settings-server-tls.ts +models/server-settings-server.ts models/server-settings.ts models/session-detail.ts models/session-list-item.ts diff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts index 1324f1932..939479942 100644 --- a/lib/packages/fabro-api-client/src/models/index.ts +++ b/lib/packages/fabro-api-client/src/models/index.ts @@ -157,6 +157,10 @@ export * from './secret-metadata'; export * from './send-message-request'; export * from './send-message-response'; export * from './server-settings'; +export * from './server-settings-exec'; +export * from './server-settings-fabro'; +export * from './server-settings-server'; +export * from './server-settings-server-tls'; export * from './session-detail'; export * from './session-list-item'; export * from './session-turn'; diff --git a/lib/packages/fabro-api-client/src/models/server-settings-exec.ts b/lib/packages/fabro-api-client/src/models/server-settings-exec.ts new file mode 100644 index 000000000..905b9864a --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/server-settings-exec.ts @@ -0,0 +1,50 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +export interface ServerSettingsExec { + /** + * Default exec provider. + */ + 'provider'?: string; + /** + * Default exec model. + */ + 'model'?: string; + /** + * Exec permission level. + */ + 'permissions'?: ServerSettingsExecPermissionsEnum; + /** + * Exec output format. + */ + 'output_format'?: ServerSettingsExecOutputFormatEnum; +} + +export const ServerSettingsExecPermissionsEnum = { + READ_ONLY: 'read-only', + READ_WRITE: 'read-write', + FULL: 'full' +} as const; + +export type ServerSettingsExecPermissionsEnum = typeof ServerSettingsExecPermissionsEnum[keyof typeof ServerSettingsExecPermissionsEnum]; +export const ServerSettingsExecOutputFormatEnum = { + TEXT: 'text', + JSON: 'json' +} as const; + +export type ServerSettingsExecOutputFormatEnum = typeof ServerSettingsExecOutputFormatEnum[keyof typeof ServerSettingsExecOutputFormatEnum]; + + diff --git a/lib/packages/fabro-api-client/src/models/server-settings-fabro.ts b/lib/packages/fabro-api-client/src/models/server-settings-fabro.ts new file mode 100644 index 000000000..4760e3096 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/server-settings-fabro.ts @@ -0,0 +1,23 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +export interface ServerSettingsFabro { + /** + * Project fabro root directory. + */ + 'root'?: string; +} + diff --git a/lib/packages/fabro-api-client/src/models/server-settings-server-tls.ts b/lib/packages/fabro-api-client/src/models/server-settings-server-tls.ts new file mode 100644 index 000000000..497356fa3 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/server-settings-server-tls.ts @@ -0,0 +1,31 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +export interface ServerSettingsServerTls { + /** + * Client certificate path. + */ + 'cert'?: string; + /** + * Client key path. + */ + 'key'?: string; + /** + * Certificate authority path. + */ + 'ca'?: string; +} + diff --git a/lib/packages/fabro-api-client/src/models/server-settings-server.ts b/lib/packages/fabro-api-client/src/models/server-settings-server.ts new file mode 100644 index 000000000..8371218cb --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/server-settings-server.ts @@ -0,0 +1,27 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { ServerSettingsServerTls } from './server-settings-server-tls'; + +export interface ServerSettingsServer { + /** + * Default server target for CLI commands. + */ + 'target'?: string; + 'tls'?: ServerSettingsServerTls; +} + diff --git a/lib/packages/fabro-api-client/src/models/server-settings.ts b/lib/packages/fabro-api-client/src/models/server-settings.ts index dbbd55247..efc03d017 100644 --- a/lib/packages/fabro-api-client/src/models/server-settings.ts +++ b/lib/packages/fabro-api-client/src/models/server-settings.ts @@ -51,6 +51,15 @@ import type { PullRequestSettings } from './pull-request-settings'; import type { SandboxSettings } from './sandbox-settings'; // May contain unused imports in some cases // @ts-ignore +import type { ServerSettingsExec } from './server-settings-exec'; +// May contain unused imports in some cases +// @ts-ignore +import type { ServerSettingsFabro } from './server-settings-fabro'; +// May contain unused imports in some cases +// @ts-ignore +import type { ServerSettingsServer } from './server-settings-server'; +// May contain unused imports in some cases +// @ts-ignore import type { SetupSettings } from './setup-settings'; // May contain unused imports in some cases // @ts-ignore @@ -60,6 +69,52 @@ import type { WebSettings } from './web-settings'; * Structured server settings mirroring fabro_types::Settings. */ export interface ServerSettings { + /** + * Settings schema version. + */ + 'version'?: number; + /** + * Default goal description. + */ + 'goal'?: string; + /** + * Path to a goal file. + */ + 'goal_file'?: string; + /** + * Default Graphviz graph path. + */ + 'graph'?: string; + /** + * Default label map. + */ + 'labels'?: { [key: string]: string; }; + 'server'?: ServerSettingsServer; + 'exec'?: ServerSettingsExec; + /** + * Prevent system idle sleep while running. + */ + 'prevent_idle_sleep'?: boolean; + /** + * Enable verbose output by default. + */ + 'verbose'?: boolean; + /** + * Whether upgrade checks are enabled. + */ + 'upgrade_check'?: boolean; + /** + * Default dry-run mode. + */ + 'dry_run'?: boolean; + /** + * Default auto-approve mode. + */ + 'auto_approve'?: boolean; + /** + * Skip retro generation by default. + */ + 'no_retro'?: boolean; /** * Storage directory path. */ @@ -93,5 +148,6 @@ export interface ServerSettings { */ 'mcp_servers'?: { [key: string]: McpServerEntry; }; 'github'?: GitHubSettings; + 'fabro'?: ServerSettingsFabro; } From d00dea696e6fa6fdf85817c2147b13c3be33a40c Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Mon, 6 Apr 2026 14:09:32 -0400 Subject: [PATCH 3/3] fix(testing): stabilize artifact fixture under load Increase the artifact scenario timeout so the retry fixture still forces one timeout without spuriously creating a third retry under full-workspace nextest load. Also import the generated ServerSettings type directly so workspace clippy stays clean. --- lib/crates/fabro-cli/tests/it/cmd/support.rs | 2 +- lib/crates/fabro-server/src/server.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/crates/fabro-cli/tests/it/cmd/support.rs b/lib/crates/fabro-cli/tests/it/cmd/support.rs index 0ef23e054..4922b277d 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/support.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/support.rs @@ -358,7 +358,7 @@ pub(crate) fn setup_artifact_run(context: &TestContext) -> WorkspaceRunSetup { start [shape=Mdiamond] exit [shape=Msquare] create_assets [shape=parallelogram, script="mkdir -p assets/shared assets/node_a && printf one > assets/shared/report.txt && printf alpha > assets/node_a/summary.txt", max_retries=0] - retry_assets [shape=parallelogram, script="mkdir -p assets/retry && touch -c -t 200001010000 assets/shared/report.txt assets/node_a/summary.txt && if [ ! -f .retry-sentinel ]; then printf first > assets/retry/report.txt && touch .retry-sentinel && sleep 0.2; else printf second > assets/retry/report.txt; fi", retry_policy="linear", timeout="50ms"] + retry_assets [shape=parallelogram, script="mkdir -p assets/retry && touch -c -t 200001010000 assets/shared/report.txt assets/node_a/summary.txt && if [ ! -f .retry-sentinel ]; then printf first > assets/retry/report.txt && touch .retry-sentinel && sleep 0.2; else printf second > assets/retry/report.txt; fi", retry_policy="linear", timeout="150ms"] create_colliding [shape=parallelogram, script="mkdir -p assets/other assets/retry && touch -c -t 200001010000 assets/shared/report.txt assets/node_a/summary.txt assets/retry/report.txt && printf beta > assets/other/summary.txt && printf second > assets/retry/report.txt", max_retries=0] start -> create_assets -> retry_assets -> create_colliding -> exit } diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 1b1766e30..cb33dcddc 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -80,7 +80,7 @@ pub use fabro_api::types::{ QuestionType as ApiQuestionType, RenderWorkflowGraphDirection, RenderWorkflowGraphFormat, RenderWorkflowGraphRequest, RunArtifactEntry, RunArtifactListResponse, RunError, RunEvent as ApiRunEvent, RunManifest, RunStatus, RunStatusResponse, SandboxFileEntry, - SandboxFileListResponse, SetSecretRequest, SshAccessRequest, SshAccessResponse, + SandboxFileListResponse, ServerSettings, SetSecretRequest, SshAccessRequest, SshAccessResponse, StartRunRequest, SubmitAnswerRequest, TokenUsage, UsageByModel, WriteBlobResponse, }; use fabro_graphviz::render::GraphFormat; @@ -588,7 +588,7 @@ async fn get_server_settings( (StatusCode::OK, Json(response)).into_response() } -fn api_server_settings(settings: &Settings) -> anyhow::Result { +fn api_server_settings(settings: &Settings) -> anyhow::Result { let mut value = serde_json::to_value(settings)?; strip_nulls(&mut value); serde_json::from_value(value).map_err(Into::into)