mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-15 23:32:46 +00:00
Merge remote-tracking branch 'origin/main'
This commit is contained in:
commit
8ba893bf68
23 changed files with 1168 additions and 110 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<PathBuf>,
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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<Settings> {
|
||||
fn config_layers(workflow: Option<&Path>) -> anyhow::Result<EffectiveSettingsLayers> {
|
||||
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<Settings> {
|
||||
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');
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -230,6 +230,16 @@ impl ServerStoreClient {
|
|||
self.clone()
|
||||
}
|
||||
|
||||
pub(crate) async fn retrieve_server_settings(&self) -> Result<Settings> {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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 <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 <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 <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")),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
437
lib/crates/fabro-config/src/effective_settings.rs
Normal file
437
lib/crates/fabro-config/src/effective_settings.rs
Normal file
|
|
@ -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<Settings> {
|
||||
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<ConfigLayer> {
|
||||
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<ConfigLayer> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<ConfigLayer>
|
|||
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<PathBuf, String>,
|
||||
) -> 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<ConfigLayer> {
|
||||
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<ConfigLayer> {
|
||||
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<String, String> {
|
|||
.collect()
|
||||
}
|
||||
|
||||
fn resolve_manifest_dockerfile(
|
||||
layer: &mut ConfigLayer,
|
||||
config_path: &Path,
|
||||
files: &HashMap<PathBuf, String>,
|
||||
) -> 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<PathBuf> {
|
||||
let path = Path::new(reference);
|
||||
if path.is_absolute() || reference.starts_with('~') {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -557,7 +557,7 @@ fn real_routes() -> Router<Arc<AppState>> {
|
|||
.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<Arc<AppState>>,
|
||||
) -> 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<ServerSettings> {
|
||||
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<Arc<AppState>>) -> Response {
|
||||
let data = state.secret_store.read().await.list();
|
||||
(StatusCode::OK, Json(serde_json::json!({ "data": data }))).into_response()
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
#[cfg(target_os = "linux")]
|
||||
mod mtls;
|
||||
mod routing;
|
||||
mod settings;
|
||||
|
|
|
|||
41
lib/crates/fabro-server/tests/it/api/settings.rs
Normal file
41
lib/crates/fabro-server/tests/it/api/settings.rs
Normal file
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue