Merge origin/main (preserving RunServices architecture)

Reconciles 61 origin commits (settings/config architectural reshape:
sparse layers → dense snapshots via builders, WorkflowSettings rename,
RunLayer/CliLayer moves, workflow builders, drop of public load wrappers)
with our LLM credential + RunServices refactor.

Our architecture preserved where it conflicted with origin's:

- RunServices / EngineServices stay (services.rs does not exist on
  origin, which inlined the fields onto Initialized). Origin's new
  Initialized fields (inputs, run_store, emitter, sandbox, registry,
  env, dry_run, llm_client, provider) are absorbed through RunServices
  and EngineServices instead of being inlined.
- llm_source: Arc<dyn CredentialSource> stays on AppState and
  RunServices. Origin had a parallel ProviderCredentials struct in
  fabro-server; our CredentialSource trait is more general and
  complies with docs-internal/llm-client-resolution.md. Point-of-use
  Client::from_source(...) rebuild preserves OAuth refresh.
- CommandContext.llm_source() uses self.storage_dir (origin's direct
  field) instead of self.machine_settings (our side's field, removed
  by origin).
- standalone_llm_source in fabro-agent drops the dead Result wrap and
  uses fabro_config::user::default_storage_dir (origin's entrypoint)
  instead of the removed load_settings_user/resolve_storage_root.

Absorbed from origin wholesale:

- SettingsLayer → WorkflowSettings rename everywhere
- Dense run settings: RunOptions.settings is WorkflowSettings, inputs
  read via settings.run.inputs directly (not Option<RunLayer>)
- AppState.manifest_run_defaults / manifest_run_settings
- fabro_config re-exports of CliLayer/RunLayer/CliOutputLayer/etc.
- Lifecycle terminal-event changes, finalize dedup, list_events
  consolidation — already brought in on the previous merge, kept

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-04-24 00:10:01 -04:00
commit 4e9dd6fb52
No known key found for this signature in database
165 changed files with 6136 additions and 4436 deletions

3
Cargo.lock generated
View file

@ -1588,6 +1588,7 @@ name = "fabro-checkpoint"
version = "0.212.0-nightly.0"
dependencies = [
"chrono",
"fabro-config",
"fabro-store",
"fabro-types",
"git2",
@ -1724,6 +1725,7 @@ dependencies = [
"chrono",
"clap",
"dirs",
"fabro-macros",
"fabro-proc",
"fabro-types",
"fabro-util",
@ -2214,7 +2216,6 @@ dependencies = [
"chrono",
"clap",
"dirs",
"fabro-macros",
"fabro-model",
"fabro-util",
"hex",

View file

@ -1,10 +1,10 @@
import type { PaginationMeta } from "@qltysh/fabro-api-client";
/**
* Opaque persisted `SettingsLayer` payload returned by `/api/v1/runs/:id/settings`.
* Treated as a loose JSON object on the web side consumers only render it.
* Opaque persisted `WorkflowSettings` snapshot returned by `/api/v1/runs/:id/settings`.
* Treated as a loose JSON object on the web side; consumers only render it.
*/
export type RunSettingsLayer = Record<string, unknown>;
export type WorkflowSettingsSnapshot = Record<string, unknown>;
export interface WorkflowScheduleSummary {
expression: string;
@ -33,6 +33,6 @@ export interface WorkflowDetailResponse {
slug: string;
description: string;
filename: string;
settings: RunSettingsLayer;
settings: WorkflowSettingsSnapshot;
graph: string;
}

View file

@ -6,14 +6,14 @@ import { apiJson } from "../api";
import { isVisibleStage } from "../data/runs";
import { formatDurationSecs } from "../lib/format";
import type { PaginatedRunStageList } from "@qltysh/fabro-api-client";
import type { RunSettingsLayer } from "../lib/workflow-api";
export const handle = { wide: true };
type WorkflowSettingsSnapshot = Record<string, unknown>;
export async function loader({ request, params }: any) {
const [{ data: apiStages }, settings] = await Promise.all([
apiJson<PaginatedRunStageList>(`/runs/${params.id}/stages`, { request }),
apiJson<RunSettingsLayer>(`/runs/${params.id}/settings`, { request }),
apiJson<WorkflowSettingsSnapshot>(`/runs/${params.id}/settings`, { request }),
]);
const stages: Stage[] = apiStages.filter((s) => isVisibleStage(s.id)).map((s) => ({
id: s.id,

View file

@ -2,7 +2,7 @@ import { ChevronRightIcon } from "@heroicons/react/20/solid";
import { Link, Outlet, useLocation, useParams } from "react-router";
import { apiJsonOrNull } from "../api";
import type {
RunSettingsLayer,
WorkflowSettingsSnapshot,
WorkflowDetailResponse as ApiWorkflowDetail,
} from "../lib/workflow-api";
@ -11,14 +11,15 @@ export interface WorkflowEntry {
slug: string;
description: string;
filename: string;
settings: RunSettingsLayer;
settings: WorkflowSettingsSnapshot;
graph: string;
}
// Static sample data used by the `workflow-definition` index route for the
// hardcoded showcase workflows. Shape mirrors the persisted `SettingsLayer`
// JSON returned by `/api/v1/runs/:id/settings`. Fields are opaque to the
// `RunSettingsLayer` TypeScript type, which is a bare `Record<string, unknown>`.
// hardcoded showcase workflows. Shape mirrors the persisted
// `WorkflowSettings` snapshot returned by `/api/v1/runs/:id/settings`.
// Fields stay opaque to the `WorkflowSettingsSnapshot` TypeScript alias,
// which is a bare `Record<string, unknown>`.
export const workflowData: Record<string, WorkflowEntry> = {
fix_build: {
name: "Fix Build",

View file

@ -1376,7 +1376,7 @@ paths:
operationId: retrieveRunSettings
tags: [Run Internals]
summary: Retrieve Run Settings
description: Returns the persisted `SettingsLayer` used to launch this run.
description: Returns the persisted dense `WorkflowSettings` snapshot used to launch this run.
parameters:
- $ref: "#/components/parameters/RunId"
responses:
@ -1385,7 +1385,7 @@ paths:
content:
application/json:
schema:
$ref: "#/components/schemas/RunSettingsLayer"
$ref: "#/components/schemas/WorkflowSettings"
"404":
description: Run not found
content:
@ -5445,10 +5445,10 @@ components:
type: string
enum: [tailscale_funnel, server_url]
RunSettingsLayer:
WorkflowSettings:
description: |
The persisted `SettingsLayer` used for a specific run, serialized as-is.
This matches the stored run manifest shape rather than a resolved view.
The persisted dense `WorkflowSettings` snapshot used for a specific run.
This matches the resolved run settings recorded at launch time.
type: object
additionalProperties: true

View file

@ -809,7 +809,7 @@ memory. Delete the redaction machinery. Typed OpenAPI schema via
- Replace `ServerSettings`'s `additionalProperties: true` with a typed
schema (two fields: `server`, `features`) matching the Rust
`ServerSettings`.
- Rename the `RunSettings` schema to `RunSettingsLayer` and update its
- Rename the `RunSettings` schema to a sparse run-settings wire name and update its
description to reflect that the endpoint returns the persisted
`SettingsLayer` as-is.
- Remove all `redact`, `redaction`, `secret subtrees` language across
@ -883,7 +883,7 @@ memory. Delete the redaction machinery. Typed OpenAPI schema via
- *Contract:* Generated TypeScript client returns a typed object with
`server` and `features` fields.
- *Contract:* `GET /api/v1/runs/:id/settings` returns the persisted
`SettingsLayer` (renamed `RunSettingsLayer` in the spec) directly.
`SettingsLayer` (renamed to the sparse run-settings wire name in the spec) directly.
- *Behavior:* `server.listen` is present and visible in the main settings
response.
- *Integration:* OpenAPI conformance passes.
@ -961,7 +961,7 @@ and their constructors are the primary API; internal helpers go
- **API surface:** `GET /api/v1/settings` shape changes (single dense
`ServerSettings` served from `AppState`). `GET /api/v1/runs/:id/settings`
shape unchanged (still the persisted `SettingsLayer`; OpenAPI schema
renamed `RunSettingsLayer`).
renamed to the sparse run-settings wire name).
- **Integration coverage:** OpenAPI conformance guards spec/router
alignment. Progenitor regen + `bun run generate` + SPA refresh is the
known hygiene.

View file

@ -9,7 +9,8 @@ use std::sync::{Arc, Mutex};
use clap::{Args, Parser};
use fabro_auth::{CredentialSource, EnvCredentialSource, VaultCredentialSource};
use fabro_config::{Storage, load_settings_user, resolve_storage_root};
use fabro_config::Storage;
use fabro_config::user::default_storage_dir;
use fabro_llm::Error as LlmError;
use fabro_llm::client::Client;
use fabro_llm::middleware::{Middleware, NextFn, NextStreamFn};
@ -251,24 +252,14 @@ fn parse_provider(args: &AgentArgs) -> anyhow::Result<Provider> {
.map_err(|_| anyhow::anyhow!("unknown provider: {provider_str}"))
}
fn standalone_llm_source() -> anyhow::Result<Arc<dyn CredentialSource>> {
fn env_lookup(name: &str) -> Option<String> {
std::env::var(name).ok()
fn standalone_llm_source() -> Arc<dyn CredentialSource> {
let storage_dir = default_storage_dir();
match Vault::load(Storage::new(storage_dir).secrets_path()) {
Ok(vault) => Arc::new(VaultCredentialSource::new(Arc::new(AsyncRwLock::new(
vault,
)))),
Err(_) => Arc::new(EnvCredentialSource::new()),
}
let settings = load_settings_user()?;
let storage_root = resolve_storage_root(&settings);
let storage_dir = match storage_root.resolve(&env_lookup) {
Ok(resolved) => PathBuf::from(resolved.value),
Err(_) => return Ok(Arc::new(EnvCredentialSource::new())),
};
let vault = Vault::load(Storage::new(&storage_dir).secrets_path())
.map_err(|err| anyhow::anyhow!("Failed to load vault for LLM credentials: {err}"))?;
Ok(Arc::new(VaultCredentialSource::new(Arc::new(
AsyncRwLock::new(vault),
))))
}
fn ensure_provider_registered(client: &Client, provider: Provider) -> anyhow::Result<()> {
@ -442,7 +433,7 @@ pub async fn run_with_args(
args: AgentArgs,
mcp_servers: Vec<McpServerSettings>,
) -> anyhow::Result<()> {
let llm_source = standalone_llm_source()?;
let llm_source = standalone_llm_source();
run_with_args_and_source(args, llm_source, mcp_servers).await
}

View file

@ -176,7 +176,8 @@ fn main() {
"fabro_types::status::RunStatusRecord",
&[],
),
("ServerSettings", "fabro_config::ServerSettings", &[]),
("WorkflowSettings", "fabro_types::WorkflowSettings", &[]),
("ServerSettings", "fabro_types::ServerSettings", &[]),
(
"ServerNamespace",
"fabro_types::settings::ServerNamespace",

View file

@ -14,7 +14,6 @@ mod generated {
include!(concat!(env!("OUT_DIR"), "/codegen.rs"));
}
pub mod types {
pub use fabro_config::ServerSettings;
pub use fabro_types::settings::server::{
DiscordIntegrationSettings, GithubIntegrationSettings, GithubIntegrationStrategy,
IntegrationWebhooksSettings, IpAllowEntry, ObjectStoreSettings, ServerApiSettings,
@ -28,6 +27,7 @@ pub mod types {
pub use fabro_types::status::{
BlockedReason, FailureReason, RunControlAction, RunStatus, SuccessReason, TerminalStatus,
};
pub use fabro_types::{ServerSettings, WorkflowSettings};
pub use crate::generated::types::*;
}

View file

@ -4,7 +4,8 @@ use fabro_api::types::{
FeaturesNamespace as ApiFeaturesNamespace, ObjectStoreSettings as ApiObjectStoreSettings,
ServerNamespace as ApiServerNamespace, ServerSettings as ApiServerSettings,
};
use fabro_config::{ServerSettings, parse_settings_layer};
use fabro_config::ServerSettingsBuilder;
use fabro_types::ServerSettings;
use fabro_types::settings::server::ObjectStoreSettings;
use fabro_types::settings::{FeaturesNamespace, ServerNamespace};
@ -18,7 +19,7 @@ fn server_settings_family_reuses_domain_types() {
#[test]
fn server_settings_json_matches_openapi_shape() {
let layer = parse_settings_layer(
let settings = ServerSettingsBuilder::from_toml(
r#"
_version = 1
@ -53,8 +54,7 @@ slug = "fabro-dev"
session_sandboxes = true
"#,
)
.expect("settings fixture should parse");
let settings = ServerSettings::from_layer(&layer).expect("settings should resolve");
.expect("settings should resolve");
let json = serde_json::to_value(&settings).expect("server settings should serialize");
assert_eq!(json["server"]["listen"]["type"], "tcp");

View file

@ -0,0 +1,54 @@
use std::any::{TypeId, type_name};
use fabro_api::types::WorkflowSettings as ApiWorkflowSettings;
use fabro_config::WorkflowSettingsBuilder;
use fabro_types::WorkflowSettings;
#[test]
fn workflow_settings_family_reuses_domain_types() {
assert_same_type::<ApiWorkflowSettings, WorkflowSettings>();
}
#[test]
fn workflow_settings_json_matches_openapi_shape() {
let settings = WorkflowSettingsBuilder::from_toml(
r#"
_version = 1
[project]
directory = "workspace"
[workflow]
name = "Ship"
graph = "ship.fabro"
[run]
goal = "Ship it"
[run.execution]
approval = "auto"
"#,
)
.expect("settings should resolve");
let json = serde_json::to_value(&settings).expect("workflow settings should serialize");
assert_eq!(json["project"]["directory"], "workspace");
assert_eq!(json["workflow"]["graph"], "ship.fabro");
assert_eq!(json["run"]["goal"]["type"], "inline");
assert_eq!(json["run"]["goal"]["value"], "Ship it");
assert_eq!(json["run"]["execution"]["approval"], "auto");
let round_trip: ApiWorkflowSettings =
serde_json::from_value(json).expect("workflow settings should deserialize");
assert_eq!(round_trip, settings);
}
fn assert_same_type<T: 'static, U: 'static>() {
assert_eq!(
TypeId::of::<T>(),
TypeId::of::<U>(),
"{} should be the same type as {}",
type_name::<T>(),
type_name::<U>()
);
}

View file

@ -14,6 +14,7 @@ doctest = false
workspace = true
[dependencies]
fabro-config = { path = "../fabro-config" }
fabro-store = { path = "../fabro-store" }
fabro-types = { path = "../fabro-types" }
git2.workspace = true

View file

@ -1,7 +1,8 @@
use std::fmt::Write;
use fabro_config::GitAuthorLayer;
use fabro_types::settings::InterpString;
use fabro_types::settings::run::{GitAuthorLayer, GitAuthorSettings};
use fabro_types::settings::run::GitAuthorSettings;
/// Resolved git author identity for checkpoint commits.
#[derive(Debug, Clone, PartialEq)]

View file

@ -154,8 +154,7 @@ mod tests {
use std::collections::HashMap;
use chrono::{TimeZone, Utc};
use fabro_types::settings::SettingsLayer;
use fabro_types::{Graph, fixtures};
use fabro_types::{Graph, WorkflowSettings, fixtures};
use super::*;
@ -185,7 +184,7 @@ mod tests {
fn test_run_spec(run_id: fabro_types::RunId) -> RunSpec {
RunSpec {
run_id,
settings: SettingsLayer::default(),
settings: WorkflowSettings::default(),
graph: Graph::new("test"),
workflow_slug: None,
working_directory: PathBuf::from("/tmp"),

View file

@ -3,9 +3,8 @@ use std::path::{Path, PathBuf};
use clap::{Args, Subcommand, ValueEnum};
use fabro_agent::cli::AgentArgs;
use fabro_types::settings::cli::{
CliLayer, CliLoggingLayer, CliOutputLayer, CliUpdatesLayer, OutputFormat, OutputVerbosity,
};
use fabro_config::{CliLayer, CliLoggingLayer, CliOutputLayer, CliUpdatesLayer};
use fabro_types::settings::cli::{OutputFormat, OutputVerbosity};
use fabro_util::printer::Printer;
pub(crate) const LONG_VERSION: &str = concat!(

View file

@ -3,9 +3,10 @@ use std::sync::Arc;
use anyhow::{Context as _, Result, bail};
use fabro_auth::{CredentialSource, EnvCredentialSource, VaultCredentialSource};
use fabro_config::{Storage, UserSettings};
use fabro_types::settings::cli::{CliLayer, OutputFormat, OutputVerbosity};
use fabro_types::settings::{Combine, SettingsLayer};
use fabro_config::{CliLayer, Storage};
use fabro_types::settings::RunNamespace;
use fabro_types::settings::cli::{OutputFormat, OutputVerbosity};
use fabro_types::{ServerSettings, UserSettings};
use fabro_util::printer::Printer;
use fabro_vault::Vault;
use tokio::sync::{OnceCell, RwLock as AsyncRwLock};
@ -14,7 +15,8 @@ use crate::args::{
ServerConnectionArgs, ServerTargetArgs, printer_from_verbosity, require_no_json_override,
};
use crate::server_client::Client;
use crate::{local_server, server_client, user_config};
use crate::user_config::LoadedSettings;
use crate::{server_client, user_config};
#[derive(Clone, Debug)]
pub(crate) enum ServerMode {
@ -34,17 +36,26 @@ pub(crate) struct CommandContext {
cwd: PathBuf,
base_config_path: PathBuf,
cli_layer: CliLayer,
machine_settings: SettingsLayer,
storage_dir: PathBuf,
run_settings: std::result::Result<RunNamespace, String>,
server_settings: std::result::Result<ServerSettings, String>,
user_settings: UserSettings,
server_mode: ServerMode,
server: OnceCell<Arc<Client>>,
llm_source: OnceCell<Arc<dyn CredentialSource>>,
}
struct ResolvedCommandSettings {
storage_dir: PathBuf,
run_settings: std::result::Result<RunNamespace, String>,
server_settings: std::result::Result<ServerSettings, String>,
user_settings: UserSettings,
}
impl CommandContext {
pub(crate) fn from_disk(cli_layer: &CliLayer, process_local_json: bool) -> Result<Self> {
let (machine_settings, user_settings) = load_merged_settings(cli_layer, &ServerMode::None)?;
let printer = printer_from_verbosity(user_settings.cli.output.verbosity);
let resolved_settings = load_merged_settings(cli_layer, &ServerMode::None)?;
let printer = printer_from_verbosity(resolved_settings.user_settings.cli.output.verbosity);
let cwd = std::env::current_dir().context("Failed to get current directory")?;
let base_config_path = user_config::active_settings_path(None);
@ -54,8 +65,10 @@ impl CommandContext {
cwd,
base_config_path,
cli_layer: cli_layer.clone(),
machine_settings,
user_settings,
storage_dir: resolved_settings.storage_dir,
run_settings: resolved_settings.run_settings,
server_settings: resolved_settings.server_settings,
user_settings: resolved_settings.user_settings,
server_mode: ServerMode::None,
server: OnceCell::new(),
llm_source: OnceCell::new(),
@ -91,8 +104,20 @@ impl CommandContext {
&self.cwd
}
pub(crate) fn machine_settings(&self) -> &SettingsLayer {
&self.machine_settings
pub(crate) fn storage_dir(&self) -> &Path {
&self.storage_dir
}
pub(crate) fn server_settings(&self) -> Result<&ServerSettings> {
self.server_settings
.as_ref()
.map_err(|err| anyhow::anyhow!("{err}"))
}
pub(crate) fn run_settings(&self) -> Result<&RunNamespace> {
self.run_settings
.as_ref()
.map_err(|err| anyhow::anyhow!("{err}"))
}
pub(crate) fn user_settings(&self) -> &UserSettings {
@ -110,7 +135,8 @@ impl CommandContext {
pub(crate) async fn server(&self) -> Result<Arc<Client>> {
let server_mode = self.server_mode.clone();
let base_config_path = self.base_config_path.clone();
let machine_settings = self.machine_settings.clone();
let storage_dir = self.storage_dir.clone();
let user_settings = self.user_settings.clone();
let client = self
.server
@ -126,7 +152,8 @@ impl CommandContext {
};
server_client::connect_server_with_settings(
&target,
&machine_settings,
&user_settings,
&storage_dir,
&base_config_path,
)
.await
@ -138,20 +165,16 @@ impl CommandContext {
}
pub(crate) async fn llm_source(&self) -> Result<Arc<dyn CredentialSource>> {
let machine_settings = self.machine_settings.clone();
let storage_dir = self.storage_dir.clone();
let source = self
.llm_source
.get_or_try_init(|| async move {
let source: Arc<dyn CredentialSource> =
match local_server::storage_dir(&machine_settings) {
Ok(storage_dir) => {
let vault = Vault::load(Storage::new(&storage_dir).secrets_path())
.context("Failed to load vault for LLM credentials")?;
Arc::new(VaultCredentialSource::new(Arc::new(AsyncRwLock::new(
vault,
))))
}
match Vault::load(Storage::new(&storage_dir).secrets_path()) {
Ok(vault) => Arc::new(VaultCredentialSource::new(Arc::new(
AsyncRwLock::new(vault),
))),
Err(_) => Arc::new(EnvCredentialSource::new()),
};
Ok::<Arc<dyn CredentialSource>, anyhow::Error>(source)
@ -165,8 +188,7 @@ impl CommandContext {
// Always reload settings for the requested derivation mode so the result
// depends only on the requested mode, not on whichever derived context
// happened to call into this helper.
let (machine_settings, user_settings) =
load_merged_settings(&self.cli_layer, &server_mode)?;
let resolved_settings = load_merged_settings(&self.cli_layer, &server_mode)?;
Ok(Self {
printer: self.printer,
@ -174,8 +196,10 @@ impl CommandContext {
cwd: self.cwd.clone(),
base_config_path: self.base_config_path.clone(),
cli_layer: self.cli_layer.clone(),
machine_settings,
user_settings,
storage_dir: resolved_settings.storage_dir,
run_settings: resolved_settings.run_settings,
server_settings: resolved_settings.server_settings,
user_settings: resolved_settings.user_settings,
server_mode,
server: OnceCell::new(),
llm_source: OnceCell::new(),
@ -186,42 +210,43 @@ impl CommandContext {
fn load_merged_settings(
cli_layer: &CliLayer,
server_mode: &ServerMode,
) -> Result<(SettingsLayer, UserSettings)> {
let disk_settings = match server_mode {
ServerMode::None | ServerMode::ByTarget { .. } => user_config::load_settings()?,
) -> Result<ResolvedCommandSettings> {
let loaded_settings = match server_mode {
ServerMode::None | ServerMode::ByTarget { .. } => {
user_config::load_resolved_settings(None, None, Some(cli_layer))?
}
ServerMode::ByStorageDir {
storage_dir_override,
..
} => user_config::load_settings_with_storage_dir(storage_dir_override.as_deref())?,
} => user_config::load_resolved_settings(
None,
storage_dir_override.as_deref(),
Some(cli_layer),
)?,
};
merge_settings_layer(disk_settings, cli_layer)
Ok(resolve_command_settings(loaded_settings))
}
fn merge_settings_layer(
disk_settings: SettingsLayer,
cli_layer: &CliLayer,
) -> Result<(SettingsLayer, UserSettings)> {
let machine_settings = SettingsLayer {
cli: Some(cli_layer.clone()),
..SettingsLayer::default()
fn resolve_command_settings(loaded_settings: LoadedSettings) -> ResolvedCommandSettings {
ResolvedCommandSettings {
storage_dir: loaded_settings.storage_dir,
run_settings: loaded_settings.run_settings,
server_settings: loaded_settings.server_settings,
user_settings: loaded_settings.user_settings,
}
.combine(disk_settings);
let user_settings = UserSettings::from_layer(&machine_settings)?;
Ok((machine_settings, user_settings))
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use fabro_config::parse_settings_layer;
use fabro_config::user::apply_storage_dir_override;
use fabro_types::settings::InterpString;
use fabro_types::settings::cli::{CliLayer, CliOutputLayer, OutputFormat, OutputVerbosity};
use fabro_config::{CliLayer, CliOutputLayer};
use fabro_types::settings::cli::{OutputFormat, OutputVerbosity};
use fabro_util::printer::Printer;
use tokio::sync::OnceCell;
use super::{CommandContext, ServerMode, merge_settings_layer};
use super::{CommandContext, ServerMode, resolve_command_settings};
use crate::user_config;
fn cli_layer_with_json_and_verbose() -> CliLayer {
CliLayer {
@ -235,17 +260,20 @@ mod tests {
fn synthetic_context(process_local_json: bool, printer: Printer) -> CommandContext {
let cli_layer = cli_layer_with_json_and_verbose();
let (machine_settings, user_settings) =
merge_settings_layer(parse_settings_layer("_version = 1\n").unwrap(), &cli_layer)
.expect("settings should merge");
let resolved_settings = resolve_command_settings(
user_config::load_resolved_settings_from_toml("_version = 1\n", None, Some(&cli_layer))
.expect("settings should resolve"),
);
CommandContext {
printer,
process_local_json,
cwd: PathBuf::from("/tmp/workspace"),
base_config_path: PathBuf::from("/tmp/settings.toml"),
cli_layer,
machine_settings,
user_settings,
storage_dir: resolved_settings.storage_dir,
run_settings: resolved_settings.run_settings,
server_settings: resolved_settings.server_settings,
user_settings: resolved_settings.user_settings,
server_mode: ServerMode::None,
server: OnceCell::new(),
llm_source: OnceCell::new(),
@ -268,47 +296,98 @@ mod tests {
#[test]
fn storage_dir_override_only_changes_storage_root_in_merged_settings() {
let cli_layer = cli_layer_with_json_and_verbose();
let base_disk_settings = parse_settings_layer(
r#"
let base_settings = resolve_command_settings(
user_config::load_resolved_settings_from_toml(
r#"
_version = 1
[server.storage]
root = "/srv/fabro/default"
"#,
)
.expect("settings fixture should parse");
let override_disk_settings = apply_storage_dir_override(
base_disk_settings.clone(),
Some(std::path::Path::new("/srv/fabro/override")),
None,
Some(&cli_layer),
)
.expect("base settings should resolve"),
);
let connection_settings = resolve_command_settings(
user_config::load_resolved_settings_from_toml(
r#"
_version = 1
[server.storage]
root = "/srv/fabro/default"
"#,
Some(std::path::Path::new("/srv/fabro/override")),
Some(&cli_layer),
)
.expect("connection settings should resolve"),
);
let (base_settings, base_user_settings) =
merge_settings_layer(base_disk_settings, &cli_layer)
.expect("base settings should merge");
let (connection_settings, connection_user_settings) =
merge_settings_layer(override_disk_settings, &cli_layer)
.expect("connection settings should merge");
assert_eq!(
base_settings.user_settings,
connection_settings.user_settings
);
assert_eq!(
base_settings.user_settings.cli.output.format,
OutputFormat::Json
);
assert_eq!(
base_settings.storage_dir,
PathBuf::from("/srv/fabro/default")
);
assert_eq!(
connection_settings.storage_dir,
PathBuf::from("/srv/fabro/override")
);
assert_eq!(base_settings.run_settings.unwrap().agent.mcps.len(), 0);
assert_eq!(
connection_settings.run_settings.unwrap().agent.mcps.len(),
0
);
assert!(base_settings.server_settings.is_err());
assert!(connection_settings.server_settings.is_err());
}
assert_eq!(base_user_settings, connection_user_settings);
assert_eq!(base_user_settings.cli.output.format, OutputFormat::Json);
assert_eq!(
base_settings
.server
.as_ref()
.and_then(|server| server.storage.as_ref())
.and_then(|storage| storage.root.as_ref())
.map(InterpString::as_source),
Some("/srv/fabro/default".to_string())
#[test]
fn storage_dir_stays_available_when_server_settings_do_not_resolve() {
let resolved = resolve_command_settings(
user_config::load_resolved_settings_from_toml(
r#"
_version = 1
[server.storage]
root = "/srv/fabro"
"#,
None,
Some(&CliLayer::default()),
)
.expect("settings should resolve"),
);
assert_eq!(
connection_settings
.server
.as_ref()
.and_then(|server| server.storage.as_ref())
.and_then(|storage| storage.root.as_ref())
.map(InterpString::as_source),
Some("/srv/fabro/override".to_string())
assert_eq!(resolved.storage_dir, PathBuf::from("/srv/fabro"));
assert!(resolved.run_settings.is_ok());
assert!(resolved.server_settings.is_err());
}
#[test]
fn run_settings_include_run_agent_mcps() {
let resolved = resolve_command_settings(
user_config::load_resolved_settings_from_toml(
r#"
_version = 1
[run.agent.mcps.demo]
type = "stdio"
command = ["demo-mcp"]
"#,
None,
Some(&CliLayer::default()),
)
.expect("settings should resolve"),
);
let run_settings = resolved.run_settings.expect("run settings should resolve");
assert!(run_settings.agent.mcps.contains_key("demo"));
}
#[test]

View file

@ -46,7 +46,7 @@ pub(super) async fn login_command(args: AuthLoginArgs, base_ctx: &CommandContext
#[cfg(unix)]
{
let target = user_config::resolve_server_target(&args.server, base_ctx.machine_settings())?;
let target = user_config::resolve_server_target(&args.server, base_ctx.user_settings())?;
let web_url = browser_origin(&target)?;
let pkce = fabro_oauth::generate_pkce();
let state = fabro_oauth::generate_state();

View file

@ -34,7 +34,7 @@ pub(super) async fn logout_command(args: AuthLogoutArgs, base_ctx: &CommandConte
return Ok(());
}
let target = user_config::resolve_server_target(&args.server, base_ctx.machine_settings())?;
let target = user_config::resolve_server_target(&args.server, base_ctx.user_settings())?;
let Some(entry) = store.get(&target)? else {
fabro_util::printerr!(printer, "Not logged in to {}.", target);
return Ok(());

View file

@ -43,7 +43,7 @@ pub(super) fn status_command(args: &AuthStatusArgs, ctx: &CommandContext) -> Res
let store = AuthStore::default();
let now = Utc::now();
let rows = if args.server.as_deref().is_some() {
let target = user_config::resolve_server_target(&args.server, ctx.machine_settings())?;
let target = user_config::resolve_server_target(&args.server, ctx.user_settings())?;
filter_rows(&store, &target, now)?
} else {
all_rows(&store, now)?

View file

@ -10,7 +10,8 @@
use std::io::Write;
use fabro_api::types::ServerSettings;
use fabro_config::UserSettings;
use fabro_config::UserSettingsBuilder;
use fabro_types::UserSettings;
use serde::Serialize;
use crate::args::SettingsArgs;
@ -25,7 +26,7 @@ struct RenderedConfig {
pub(crate) async fn execute(args: &SettingsArgs, base_ctx: &CommandContext) -> anyhow::Result<()> {
let ctx = base_ctx.with_target(&args.target)?;
let user = fabro_config::UserSettings::resolve()?;
let user = UserSettingsBuilder::load_default()?;
let server = ctx
.server()
.await?

View file

@ -12,102 +12,19 @@ use fabro_llm::providers::common::{LineReader, parse_retry_after};
use fabro_llm::types::{
FinishReason, Message, Request, Response as LlmResponse, StreamEvent, TokenCounts,
};
use fabro_mcp::config::{McpServerSettings, McpTransport};
use fabro_mcp::config::McpServerSettings;
use fabro_types::settings::InterpString;
use fabro_types::settings::cli::OutputFormat as SettingsOutputFormat;
use fabro_types::settings::run::McpEntryLayer;
use fabro_util::exit::{ErrorExt, ExitClass};
use futures::stream;
use serde::Deserialize;
use crate::args::ExecArgs;
use crate::command_context::CommandContext;
#[cfg(feature = "sleep_inhibitor")]
use crate::sleep_inhibitor;
use crate::{server_client, user_config};
fn runtime_mcp_server(name: &str, entry: &McpEntryLayer) -> McpServerSettings {
let transport = match entry {
McpEntryLayer::Stdio {
script,
command,
env,
..
} => {
let command = if let Some(script) = script {
vec!["sh".to_string(), "-c".to_string(), script.as_source()]
} else {
command
.as_ref()
.map(|command| command.iter().map(InterpString::as_source).collect())
.unwrap_or_default()
};
McpTransport::Stdio {
command,
env: env
.iter()
.map(|(key, value)| (key.clone(), value.as_source()))
.collect(),
}
}
McpEntryLayer::Http { url, headers, .. } => McpTransport::Http {
url: url.as_source(),
headers: headers
.iter()
.map(|(key, value)| (key.clone(), value.as_source()))
.collect(),
},
McpEntryLayer::Sandbox {
script,
command,
port,
env,
..
} => {
let command = if let Some(script) = script {
vec!["sh".to_string(), "-c".to_string(), script.as_source()]
} else {
command
.as_ref()
.map(|command| command.iter().map(InterpString::as_source).collect())
.unwrap_or_default()
};
McpTransport::Sandbox {
command,
port: *port,
env: env
.iter()
.map(|(key, value)| (key.clone(), value.as_source()))
.collect(),
}
}
};
let (startup_timeout_secs, tool_timeout_secs) = match entry {
McpEntryLayer::Http {
startup_timeout,
tool_timeout,
..
}
| McpEntryLayer::Stdio {
startup_timeout,
tool_timeout,
..
}
| McpEntryLayer::Sandbox {
startup_timeout,
tool_timeout,
..
} => (
startup_timeout.map_or(10, |duration| duration.as_std().as_secs()),
tool_timeout.map_or(60, |duration| duration.as_std().as_secs()),
),
};
McpServerSettings {
name: name.to_string(),
transport,
startup_timeout_secs,
tool_timeout_secs,
}
}
struct AuthenticatedFabroServerAdapter {
client: server_client::Client,
base_url: String,
@ -361,9 +278,8 @@ pub(crate) async fn execute(mut args: ExecArgs, ctx: &CommandContext) -> AnyResu
use fabro_types::settings::run::AgentPermissions;
let cli = &ctx.user_settings().cli;
let raw_settings = user_config::load_settings()?;
#[cfg(feature = "sleep_inhibitor")]
let _sleep_guard = crate::sleep_inhibitor::guard(cli.exec.prevent_idle_sleep);
let _sleep_guard = sleep_inhibitor::guard(cli.exec.prevent_idle_sleep);
let provider_str = cli
.exec
.model
@ -390,45 +306,12 @@ pub(crate) async fn execute(mut args: ExecArgs, ctx: &CommandContext) -> AnyResu
// v2 MCPs live under `cli.exec.agent.mcps` (owner-specific) or
// `run.agent.mcps`. For `fabro exec` we use the cli.exec path, falling
// back to run.agent.mcps if unset.
let mcp_servers: Vec<McpServerSettings> = if !cli.exec.agent.mcps.is_empty() {
cli.exec
.agent
.mcps
.values()
.map(|server| McpServerSettings {
name: server.name.clone(),
transport: server.transport.clone(),
startup_timeout_secs: server.startup_timeout_secs,
tool_timeout_secs: server.tool_timeout_secs,
})
.collect()
} else if let Some(mcps) = raw_settings
.cli
.as_ref()
.and_then(|cli| cli.exec.as_ref())
.and_then(|exec| exec.agent.as_ref())
.map(|agent| &agent.mcps)
.filter(|mcps| !mcps.is_empty())
{
mcps.iter()
.map(|(name, entry)| runtime_mcp_server(name, entry))
.collect()
} else {
fabro_config::resolve_run_from_file(&raw_settings)
.map(|settings| {
settings
.agent
.mcps
.values()
.map(|server| McpServerSettings {
name: server.name.clone(),
transport: server.transport.clone(),
startup_timeout_secs: server.startup_timeout_secs,
tool_timeout_secs: server.tool_timeout_secs,
})
.collect()
})
let mcp_servers: Vec<McpServerSettings> = if cli.exec.agent.mcps.is_empty() {
ctx.run_settings()
.map(|settings| settings.agent.mcps.values().cloned().collect())
.unwrap_or_default()
} else {
cli.exec.agent.mcps.values().cloned().collect()
};
if let Some(target) = server_target {
tracing::info!(transport = "server", "Agent session starting");

View file

@ -11,9 +11,7 @@ use std::io::Write;
use anyhow::{Context, bail};
use fabro_api::types;
use fabro_config::load::load_settings_user;
use fabro_config::user::active_settings_path;
use fabro_types::settings::SettingsLayer;
use fabro_util::terminal::Styles;
use tracing::debug;
@ -37,10 +35,10 @@ pub(crate) async fn run(
let built = build_run_manifest(ManifestBuildInput {
workflow: args.workflow.clone(),
cwd: ctx.cwd().to_path_buf(),
args_layer: SettingsLayer::default(),
run_overrides: None,
cli_overrides: None,
args: None,
run_id: None,
user_layer: load_settings_user()?,
user_settings_path: Some(active_settings_path(None)),
})?;
let client = ctx.server().await?;

View file

@ -32,7 +32,7 @@ use fabro_install::{
use fabro_model::Provider;
use fabro_server::serve;
use fabro_store::ArtifactStore;
use fabro_types::settings::SettingsLayer;
use fabro_types::ServerSettings;
use fabro_types::settings::server::ServerAuthMethod;
use fabro_util::printer::Printer;
use fabro_util::terminal::Styles;
@ -58,7 +58,7 @@ use crate::shared::provider_auth::{
ApiKeySource, authenticate_provider, authenticate_provider_with_api_key_source,
authenticate_provider_with_method, prompt_confirm, prompt_password, provider_display_name,
};
use crate::{local_server, server_client, user_config};
use crate::{local_server, server_client};
const GITHUB_TOKEN_SECRET_KEY: &str = "GITHUB_TOKEN";
const GITHUB_APP_PRIVATE_KEY_KEY: &str = "GITHUB_APP_PRIVATE_KEY";
@ -1263,11 +1263,10 @@ fn persist_github_install_changes(
}
async fn write_artifact_store_metadata(
settings: &SettingsLayer,
settings: &ServerSettings,
fabro_version: &str,
) -> Result<()> {
let resolved = fabro_config::ServerSettings::from_layer(settings)?;
let (object_store, prefix) = serve::build_artifact_object_store(&resolved.server)?;
let (object_store, prefix) = serve::build_artifact_object_store(&settings.server)?;
let artifact_store = ArtifactStore::new(object_store, prefix);
artifact_store.write_metadata(fabro_version).await?;
Ok(())
@ -1464,16 +1463,11 @@ async fn run_install_github_inner(
let existing_config_contents =
std::fs::read_to_string(&config_path).context("failed to read existing settings.toml")?;
let parsed_settings = user_config::apply_storage_dir_override(
fabro_config::parse_settings_layer(&existing_config_contents)
.context("failed to parse existing settings.toml")?,
args.storage_dir.as_deref(),
);
let storage_dir = local_server::storage_dir(&parsed_settings).unwrap_or_else(|_| {
args.storage_dir
.clone_path()
.unwrap_or_else(default_storage_dir)
});
let storage_dir = args
.storage_dir
.clone_path()
.or_else(|| local_server::storage_dir_from_toml(&existing_config_contents).ok())
.unwrap_or_else(default_storage_dir);
let server_was_running =
ServerDaemon::load_running(&Storage::new(&storage_dir).runtime_directory())?.is_some();
let mut doc: toml::Value = toml::from_str(&existing_config_contents)
@ -1555,11 +1549,9 @@ async fn run_install_github_inner(
s.green.apply_to(""),
bind
);
let methods = fabro_config::parse_settings_layer(&settings_toml)
let methods = fabro_config::ServerSettingsBuilder::from_toml(&settings_toml)
.ok()
.and_then(|layer| layer.server)
.and_then(|srv| srv.auth)
.and_then(|auth| auth.methods)
.map(|settings| settings.server.auth.methods)
.unwrap_or_default();
let token = methods
.contains(&ServerAuthMethod::DevToken)
@ -1614,8 +1606,9 @@ async fn run_install_inner(args: &InstallArgs, ctx: &CommandContext) -> Result<(
let web_url = &args.web_url;
let s = Styles::detect_stderr();
let emoji = console::Emoji("⚒️ ", "");
let cli_settings = user_config::load_settings_with_storage_dir(args.storage_dir.as_deref())?;
let storage_dir = local_server::storage_dir(&cli_settings)?;
let local_config =
local_server::LocalServerConfig::load_with_storage_dir(args.storage_dir.as_deref())?;
let storage_dir = local_config.storage_dir().to_path_buf();
let server_was_running =
ServerDaemon::load_running(&Storage::new(&storage_dir).runtime_directory())?.is_some();
let fabro_dir = fabro_util::Home::from_env().root().to_path_buf();
@ -1777,12 +1770,7 @@ async fn run_install_inner(args: &InstallArgs, ctx: &CommandContext) -> Result<(
toml::to_string_pretty(&doc)?
};
let install_settings = user_config::apply_storage_dir_override(
fabro_config::parse_settings_layer(&settings_toml)
.context("failed to parse generated settings.toml")?,
args.storage_dir.as_deref(),
);
fabro_config::ServerSettings::from_layer(&install_settings)?;
let install_server_settings = fabro_config::ServerSettingsBuilder::from_toml(&settings_toml)?;
// Secrets and auth material
{
@ -1793,7 +1781,12 @@ async fn run_install_inner(args: &InstallArgs, ctx: &CommandContext) -> Result<(
s.green.apply_to("")
);
let dev_token = if fabro_config::dev_token_auth_enabled(&install_settings) {
let dev_token = if install_server_settings
.server
.auth
.methods
.contains(&ServerAuthMethod::DevToken)
{
let token = dev_token::read_or_mint_dev_token_for_install(
&fabro_util::Home::from_env().dev_token_path(),
)?;
@ -1832,7 +1825,7 @@ async fn run_install_inner(args: &InstallArgs, ctx: &CommandContext) -> Result<(
server_was_running,
)
.await?;
if let Err(err) = write_artifact_store_metadata(&install_settings, FABRO_VERSION).await {
if let Err(err) = write_artifact_store_metadata(&install_server_settings, FABRO_VERSION).await {
fabro_util::printerr!(
printer,
" {} failed to write artifact store metadata: {err}",
@ -1868,13 +1861,7 @@ async fn run_install_inner(args: &InstallArgs, ctx: &CommandContext) -> Result<(
s.green.apply_to(""),
bind
);
let methods = install_settings
.server
.as_ref()
.and_then(|srv| srv.auth.as_ref())
.and_then(|auth| auth.methods.as_ref())
.map(Vec::as_slice)
.unwrap_or_default();
let methods = install_server_settings.server.auth.methods.as_slice();
let token = methods
.contains(&ServerAuthMethod::DevToken)
.then(|| {
@ -2002,16 +1989,10 @@ mod tests {
#[test]
fn config_toml_roundtrips() {
use fabro_types::settings::SettingsLayer;
let toml_str = format_config_toml();
let cfg: SettingsLayer = fabro_config::parse_settings_layer(&toml_str)
.expect("generated config should parse as v2");
let methods = cfg
.server
.as_ref()
.and_then(|s| s.auth.as_ref())
.and_then(|a| a.methods.clone())
.expect("server.auth.methods should be set");
let cfg = fabro_config::ServerSettingsBuilder::from_toml(&toml_str)
.expect("generated config should resolve");
let methods = cfg.server.auth.methods;
assert_eq!(methods, vec![
fabro_types::settings::ServerAuthMethod::DevToken
]);
@ -2019,65 +2000,60 @@ mod tests {
#[test]
fn config_toml_has_auth_strategies() {
use fabro_types::settings::SettingsLayer;
let toml_str = format_config_toml();
let cfg: SettingsLayer = fabro_config::parse_settings_layer(&toml_str).unwrap();
let auth = cfg
.server
.as_ref()
.and_then(|s| s.auth.as_ref())
.expect("server.auth should be set");
assert_eq!(
auth.methods,
Some(vec![fabro_types::settings::ServerAuthMethod::DevToken])
);
let cfg = fabro_config::ServerSettingsBuilder::from_toml(&toml_str)
.expect("generated config should resolve");
assert_eq!(cfg.server.auth.methods, vec![
fabro_types::settings::ServerAuthMethod::DevToken
]);
}
#[test]
fn config_toml_has_tcp_listen_address() {
use fabro_types::settings::SettingsLayer;
use fabro_types::settings::server::ServerListenLayer;
let toml_str = format_config_toml();
let cfg: SettingsLayer = fabro_config::parse_settings_layer(&toml_str).unwrap();
let listen = cfg
.server
.as_ref()
.and_then(|s| s.listen.as_ref())
.expect("server.listen should be set");
match listen {
ServerListenLayer::Tcp { address } => {
assert_eq!(
address
.as_ref()
.map(fabro_types::settings::InterpString::as_source),
Some("127.0.0.1:32276".to_string())
);
}
ServerListenLayer::Unix { .. } => panic!("expected tcp listen"),
}
let cfg: toml::Value = toml::from_str(&toml_str).expect("generated config should parse");
assert_eq!(
cfg.get("server")
.and_then(toml::Value::as_table)
.and_then(|server| server.get("listen"))
.and_then(toml::Value::as_table)
.and_then(|listen| listen.get("type"))
.and_then(toml::Value::as_str),
Some("tcp")
);
assert_eq!(
cfg.get("server")
.and_then(toml::Value::as_table)
.and_then(|server| server.get("listen"))
.and_then(toml::Value::as_table)
.and_then(|listen| listen.get("address"))
.and_then(toml::Value::as_str),
Some("127.0.0.1:32276")
);
}
#[test]
fn config_toml_has_cli_target_matching_listen_address() {
use fabro_types::settings::SettingsLayer;
use fabro_types::settings::cli::CliTargetLayer;
let toml_str = format_config_toml();
let cfg: SettingsLayer = fabro_config::parse_settings_layer(&toml_str).unwrap();
let target = cfg
.cli
.as_ref()
.and_then(|c| c.target.as_ref())
.expect("cli.target should be set");
match target {
CliTargetLayer::Http { url } => {
assert_eq!(
url.as_ref()
.map(fabro_types::settings::InterpString::as_source),
Some("http://127.0.0.1:32276".to_string())
);
}
CliTargetLayer::Unix { .. } => panic!("expected http target"),
}
let cfg: toml::Value = toml::from_str(&toml_str).expect("generated config should parse");
assert_eq!(
cfg.get("cli")
.and_then(toml::Value::as_table)
.and_then(|cli| cli.get("target"))
.and_then(toml::Value::as_table)
.and_then(|target| target.get("type"))
.and_then(toml::Value::as_str),
Some("http")
);
assert_eq!(
cfg.get("cli")
.and_then(toml::Value::as_table)
.and_then(|cli| cli.get("target"))
.and_then(toml::Value::as_table)
.and_then(|target| target.get("url"))
.and_then(toml::Value::as_str),
Some("http://127.0.0.1:32276")
);
}
#[test]
@ -2116,13 +2092,27 @@ name = "custom"
);
}
fn parse_install_settings(source: &str) -> SettingsLayer {
fabro_config::parse_settings_layer(source).expect("install settings fixture should parse")
fn auth_methods(source: &str) -> Option<Vec<String>> {
toml::from_str::<toml::Value>(source)
.expect("install settings fixture should parse")
.get("server")
.and_then(toml::Value::as_table)
.and_then(|server| server.get("auth"))
.and_then(toml::Value::as_table)
.and_then(|auth| auth.get("methods"))
.and_then(toml::Value::as_array)
.map(|methods| {
methods
.iter()
.filter_map(toml::Value::as_str)
.map(str::to_string)
.collect()
})
}
#[test]
fn dev_token_auth_enabled_when_methods_include_dev_token() {
let settings = parse_install_settings(
let methods = auth_methods(
r#"
_version = 1
@ -2130,12 +2120,12 @@ _version = 1
methods = ["dev-token"]
"#,
);
assert!(fabro_config::dev_token_auth_enabled(&settings));
assert_eq!(methods, Some(vec!["dev-token".to_string()]));
}
#[test]
fn dev_token_auth_enabled_when_mixed_with_github() {
let settings = parse_install_settings(
let methods = auth_methods(
r#"
_version = 1
@ -2143,12 +2133,15 @@ _version = 1
methods = ["dev-token", "github"]
"#,
);
assert!(fabro_config::dev_token_auth_enabled(&settings));
assert_eq!(
methods,
Some(vec!["dev-token".to_string(), "github".to_string()])
);
}
#[test]
fn dev_token_auth_enabled_false_for_github_only() {
let settings = parse_install_settings(
let methods = auth_methods(
r#"
_version = 1
@ -2156,19 +2149,19 @@ _version = 1
methods = ["github"]
"#,
);
assert!(!fabro_config::dev_token_auth_enabled(&settings));
assert_eq!(methods, Some(vec!["github".to_string()]));
}
#[test]
fn dev_token_auth_enabled_false_when_methods_absent() {
let settings = parse_install_settings(
let methods = auth_methods(
"
_version = 1
[server.auth]
",
);
assert!(!fabro_config::dev_token_auth_enabled(&settings));
assert_eq!(methods, None);
}
#[test]
@ -2947,7 +2940,7 @@ client_id = "client-id"
#[tokio::test]
async fn write_artifact_store_metadata_creates_marker_in_resolved_store() {
let dir = tempfile::tempdir().unwrap();
let settings = fabro_config::parse_settings_layer(&format!(
let settings = fabro_config::ServerSettingsBuilder::from_toml(&format!(
r#"
_version = 1

View file

@ -21,7 +21,7 @@ pub(crate) fn run(args: &ParseArgs) -> anyhow::Result<()> {
}
fn run_to(args: &ParseArgs, mut out: impl Write) -> anyhow::Result<()> {
let (dot_path, _cfg) = resolve_workflow(&args.workflow)?;
let dot_path = resolve_workflow(&args.workflow)?;
let source = read_workflow_file(&dot_path)?;
let ast = parse_ast(&source)?;
serde_json::to_writer_pretty(&mut out, &ast)?;

View file

@ -11,6 +11,7 @@ use crate::command_context::CommandContext;
use crate::commands::rebuild::rebuild_run_store;
use crate::shared::print_json_pretty;
use crate::shared::repo::ensure_matching_repo_origin;
#[allow(
deprecated,
reason = "boundary-exempt(pr-api): remove with follow-up #1 when PR ops move server-side"

View file

@ -13,7 +13,6 @@ use fabro_types::settings::InterpString;
use crate::args::{PrCommand, PrNamespace, ServerTargetArgs};
use crate::command_context::CommandContext;
use crate::shared::github::build_github_credentials;
use crate::user_config;
const GITHUB_CREDENTIALS_REQUIRED: &str =
"GitHub credentials required — run `fabro install` or set GITHUB_TOKEN";
@ -33,11 +32,8 @@ pub(crate) async fn dispatch(ns: PrNamespace, base_ctx: &CommandContext) -> Resu
reason = "boundary-exempt(pr-api): remove with follow-up #1 when PR ops move server-side"
)]
fn load_github_credentials_required(base_ctx: &CommandContext) -> Result<GitHubCredentials> {
let server_settings = fabro_config::ServerSettings::from_layer(base_ctx.machine_settings())
.map_err(anyhow::Error::from)?;
let vault = user_config::storage_dir(base_ctx.machine_settings())
.ok()
.and_then(|dir| fabro_vault::Vault::load(Storage::new(&dir).secrets_path()).ok());
let server_settings = base_ctx.server_settings()?;
let vault = fabro_vault::Vault::load(Storage::new(base_ctx.storage_dir()).secrets_path()).ok();
let creds = build_github_credentials(
server_settings.server.integrations.github.strategy,
server_settings

View file

@ -1,5 +1,4 @@
use anyhow::bail;
use fabro_config::load::load_settings_user;
use fabro_config::user::active_settings_path;
use fabro_util::terminal::Styles;
@ -8,7 +7,7 @@ use crate::command_context::CommandContext;
use crate::commands::run::output::{
api_check_report_to_local, api_diagnostics_to_local, print_preflight_workflow_summary,
};
use crate::commands::run::overrides::preflight_args_layer;
use crate::commands::run::overrides::preflight_args_overrides;
use crate::manifest_builder::{ManifestBuildInput, build_run_manifest, preflight_manifest_args};
use crate::shared::print_json_pretty;
@ -20,14 +19,15 @@ pub(crate) async fn execute(
let printer = base_ctx.printer();
let ctx = base_ctx.with_target(&args.target)?;
args.verbose = args.verbose || ctx.verbose();
let cli_args_config = preflight_args_overrides(&args)?;
let manifest = build_run_manifest(ManifestBuildInput {
workflow: args.workflow.clone(),
cwd: ctx.cwd().to_path_buf(),
args_layer: preflight_args_layer(&args)?,
run_overrides: cli_args_config.run,
cli_overrides: cli_args_config.cli,
args: preflight_manifest_args(&args),
run_id: None,
user_layer: load_settings_user()?,
user_settings_path: Some(active_settings_path(None)),
})?;
let client = ctx.server().await?;

View file

@ -84,10 +84,10 @@ pub(crate) async fn attach_run_with_client(
printer: Printer,
) -> Result<ExitCode> {
let state = client.get_run_state(run_id).await?;
let auto_approve = state.spec.as_ref().is_some_and(|record| {
fabro_config::resolve_run_from_file(&record.settings)
.is_ok_and(|settings| settings.execution.approval == ApprovalMode::Auto)
});
let auto_approve = state
.spec
.as_ref()
.is_some_and(|record| record.settings.run.execution.approval == ApprovalMode::Auto);
let events = client.list_run_events(run_id, None, None).await?;
let replay_events = events.clone();
let next_seq = events.last().map_or(1, |event| event.seq.saturating_add(1));

View file

@ -4,6 +4,8 @@ use fabro_util::terminal::Styles;
use crate::args::RunArgs;
use crate::command_context::CommandContext;
use crate::shared::print_json_pretty;
#[cfg(feature = "sleep_inhibitor")]
use crate::sleep_inhibitor;
pub(crate) async fn execute(mut args: RunArgs, base_ctx: &CommandContext) -> Result<()> {
let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr()));
@ -25,7 +27,7 @@ pub(crate) async fn execute(mut args: RunArgs, base_ctx: &CommandContext) -> Res
}
#[cfg(feature = "sleep_inhibitor")]
let _sleep_guard = crate::sleep_inhibitor::guard(prevent_idle_sleep);
let _sleep_guard = sleep_inhibitor::guard(prevent_idle_sleep);
#[cfg(not(feature = "sleep_inhibitor"))]
let _ = prevent_idle_sleep;

View file

@ -1,10 +1,9 @@
use fabro_config::load::load_settings_user;
use fabro_config::user::active_settings_path;
use fabro_types::RunId;
use fabro_util::terminal::Styles;
use super::output::{api_diagnostics_to_local, print_preflight_workflow_summary};
use super::overrides::run_args_layer;
use super::overrides::run_args_overrides;
use crate::args::RunArgs;
use crate::command_context::CommandContext;
use crate::manifest_builder::{ManifestBuildInput, build_run_manifest, run_manifest_args};
@ -27,7 +26,7 @@ pub(crate) async fn create_run(
.workflow
.as_ref()
.ok_or_else(|| anyhow::anyhow!("--workflow is required"))?;
let cli_args_config = run_args_layer(args)?;
let cli_args_config = run_args_overrides(args)?;
let cwd = ctx.cwd().to_path_buf();
let run_id = args
.run_id
@ -39,10 +38,10 @@ pub(crate) async fn create_run(
let built = build_run_manifest(ManifestBuildInput {
workflow: workflow_path.clone(),
cwd,
args_layer: cli_args_config,
run_overrides: cli_args_config.run,
cli_overrides: cli_args_config.cli,
args: run_manifest_args(args),
run_id,
user_layer: load_settings_user()?,
user_settings_path: Some(active_settings_path(None)),
})?;
let client = ctx.server().await?;

View file

@ -4,6 +4,8 @@ use fabro_util::terminal::Styles;
use crate::args::{AttachArgs, RunCommands, RunWorkerArgs, StartArgs};
use crate::command_context::CommandContext;
use crate::shared::print_json_pretty;
#[cfg(feature = "sleep_inhibitor")]
use crate::sleep_inhibitor;
pub(crate) mod attach;
pub(crate) mod command;
@ -106,7 +108,7 @@ pub(crate) async fn dispatch(
#[cfg(feature = "sleep_inhibitor")]
let _sleep_guard = {
let ctx = base_ctx.with_target(&args.server)?;
crate::sleep_inhibitor::guard(ctx.user_settings().cli.exec.prevent_idle_sleep)
sleep_inhibitor::guard(ctx.user_settings().cli.exec.prevent_idle_sleep)
};
Box::pin(resume::resume_command(args, styles, base_ctx)).await
}

View file

@ -2,17 +2,23 @@ use std::collections::HashMap;
use std::path::{Path, PathBuf};
use anyhow::{Result, anyhow};
use fabro_sandbox::SandboxProvider;
use fabro_types::settings::cli::{CliLayer, CliOutputLayer, OutputVerbosity};
use fabro_types::settings::interp::InterpString;
use fabro_types::settings::run::{
ApprovalMode, RunExecutionLayer, RunGoalLayer, RunLayer, RunMode, RunModelLayer,
use fabro_config::{
CliLayer, CliOutputLayer, ReplaceMap, RunExecutionLayer, RunGoalLayer, RunLayer, RunModelLayer,
RunSandboxLayer,
};
use fabro_types::settings::{ReplaceMap, SettingsLayer};
use fabro_sandbox::SandboxProvider;
use fabro_types::settings::cli::OutputVerbosity;
use fabro_types::settings::interp::InterpString;
use fabro_types::settings::run::{ApprovalMode, RunMode};
use crate::args::{PreflightArgs, RunArgs};
#[derive(Clone, Debug, Default)]
pub(crate) struct ManifestSettingsOverrides {
pub(crate) run: Option<RunLayer>,
pub(crate) cli: Option<CliLayer>,
}
fn sparse_flag(value: bool) -> Option<bool> {
value.then_some(true)
}
@ -116,7 +122,7 @@ fn current_dir_or_dot() -> PathBuf {
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
}
pub(crate) fn run_args_layer(args: &RunArgs) -> Result<SettingsLayer> {
pub(crate) fn run_args_overrides(args: &RunArgs) -> Result<ManifestSettingsOverrides> {
let model = model_from_args(args.model.as_deref(), args.provider.as_deref());
let sandbox = sandbox_layer(
args.sandbox.map(Into::into),
@ -140,14 +146,13 @@ pub(crate) fn run_args_layer(args: &RunArgs) -> Result<SettingsLayer> {
..RunLayer::default()
};
Ok(SettingsLayer {
Ok(ManifestSettingsOverrides {
run: Some(run),
cli: cli_layer_for_verbose(args.verbose),
..SettingsLayer::default()
})
}
pub(crate) fn preflight_args_layer(args: &PreflightArgs) -> Result<SettingsLayer> {
pub(crate) fn preflight_args_overrides(args: &PreflightArgs) -> Result<ManifestSettingsOverrides> {
let model = model_from_args(args.model.as_deref(), args.provider.as_deref());
let sandbox = args.sandbox.map(|s| RunSandboxLayer {
provider: Some(SandboxProvider::from(s).to_string()),
@ -164,10 +169,9 @@ pub(crate) fn preflight_args_layer(args: &PreflightArgs) -> Result<SettingsLayer
..RunLayer::default()
};
Ok(SettingsLayer {
Ok(ManifestSettingsOverrides {
run: Some(run),
cli: cli_layer_for_verbose(args.verbose),
..SettingsLayer::default()
})
}

View file

@ -13,12 +13,15 @@ use std::time::Duration;
use anyhow::{Context, Result, anyhow};
use async_trait::async_trait;
use fabro_config::Storage;
use fabro_config::{ServerSettingsBuilder, Storage};
use fabro_interview::{ControlInterviewer, WorkerControlEnvelope, WorkerControlMessage};
use fabro_store::{EventEnvelope, RunProjection, RunProjectionReducer};
use fabro_types::settings::InterpString;
use fabro_types::settings::run::RunMode;
use fabro_types::settings::{InterpString, SettingsLayer};
use fabro_types::{ActorRef, ArtifactUpload, EventBody, FailureReason, RunBlobId, RunEvent, RunId};
use fabro_types::{
ActorRef, ArtifactUpload, EventBody, FailureReason, RunBlobId, RunEvent, RunId,
WorkflowSettings,
};
use fabro_vault::Vault;
use fabro_workflow::artifact_upload::{ArtifactSink, StageArtifactUploader};
use fabro_workflow::event::{Emitter, RunEventSink};
@ -492,26 +495,25 @@ fn stamp_system_worker(mut event: RunEvent) -> RunEvent {
}
fn maybe_build_github_credentials(
settings: &SettingsLayer,
settings: &WorkflowSettings,
vault: Option<&fabro_vault::Vault>,
) -> Result<Option<fabro_github::GitHubCredentials>> {
let resolved_run = fabro_config::resolve_run_from_file(settings).ok();
let resolved_server = fabro_config::resolve_server_from_file(settings).ok();
let required_github_credentials = resolved_run.as_ref().is_some_and(|settings| {
settings.execution.mode != RunMode::DryRun && settings.sandbox.provider == "daytona"
}) || resolved_server
.as_ref()
.is_some_and(|settings| !settings.integrations.github.permissions.is_empty());
let pull_request_enabled = resolved_run.as_ref().is_some_and(|settings| {
settings.execution.mode != RunMode::DryRun && settings.pull_request.is_some()
});
let resolved_run = &settings.run;
let resolved_server = ServerSettingsBuilder::load_default().ok();
let required_github_credentials = (resolved_run.execution.mode != RunMode::DryRun
&& resolved_run.sandbox.provider == "daytona")
|| resolved_server
.as_ref()
.is_some_and(|settings| !settings.server.integrations.github.permissions.is_empty());
let pull_request_enabled =
resolved_run.execution.mode != RunMode::DryRun && resolved_run.pull_request.is_some();
let strategy = resolved_server
.as_ref()
.map(|settings| settings.integrations.github.strategy)
.map(|settings| settings.server.integrations.github.strategy)
.unwrap_or_default();
let app_id = resolved_server
.as_ref()
.and_then(|settings| settings.integrations.github.app_id.as_ref())
.and_then(|settings| settings.server.integrations.github.app_id.as_ref())
.map(InterpString::as_source);
if required_github_credentials {

View file

@ -50,12 +50,12 @@ pub(crate) async fn dispatch(
return run_install_mode(bootstrap, printer).await;
}
let settings = user_config::load_settings_with_config_and_storage_dir(
let local_config = local_server::LocalServerConfig::load(
serve_args.config.as_deref(),
storage_dir.as_deref(),
)?;
let storage_dir = local_server::storage_dir(&settings)?;
let bind_addr = local_server::bind_request(&settings, serve_args.bind.as_deref())?;
let storage_dir = local_config.storage_dir().to_path_buf();
let bind_addr = local_config.bind_request(serve_args.bind.as_deref())?;
let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr()));
Box::pin(start::execute(
bind_addr,
@ -72,8 +72,9 @@ pub(crate) async fn dispatch(
storage_dir,
timeout,
}) => {
let settings = user_config::load_settings_with_storage_dir(storage_dir.as_deref())?;
let storage_dir = local_server::storage_dir(&settings)?;
let local_config =
local_server::LocalServerConfig::load_with_storage_dir(storage_dir.as_deref())?;
let storage_dir = local_config.storage_dir().to_path_buf();
stop::execute(&storage_dir, Duration::from_secs(timeout), printer).await
}
ServerCommand::Restart(ServerRestartArgs {
@ -97,13 +98,13 @@ pub(crate) async fn dispatch(
return run_install_mode(bootstrap, printer).await;
}
let settings = user_config::load_settings_with_config_and_storage_dir(
let local_config = local_server::LocalServerConfig::load(
serve_args.config.as_deref(),
storage_dir.as_deref(),
)?;
let storage_dir = local_server::storage_dir(&settings)?;
let storage_dir = local_config.storage_dir().to_path_buf();
stop::stop_server(&storage_dir, Duration::from_secs(timeout)).await?;
let bind_addr = local_server::bind_request(&settings, serve_args.bind.as_deref())?;
let bind_addr = local_config.bind_request(serve_args.bind.as_deref())?;
let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr()));
Box::pin(start::execute(
bind_addr,
@ -117,15 +118,16 @@ pub(crate) async fn dispatch(
.await
}
ServerCommand::Status(ServerStatusArgs { storage_dir, json }) => {
let settings = user_config::load_settings_with_storage_dir(storage_dir.as_deref())?;
let storage_dir = local_server::storage_dir(&settings)?;
let local_config =
local_server::LocalServerConfig::load_with_storage_dir(storage_dir.as_deref())?;
let storage_dir = local_config.storage_dir().to_path_buf();
status::execute(&storage_dir, json, printer)
}
ServerCommand::Serve(ServerServeArgs {
storage_dir,
serve_args,
}) => {
let settings = user_config::load_settings_with_config_and_storage_dir(
let local_config = local_server::LocalServerConfig::load(
serve_args.config.as_deref(),
storage_dir.as_deref(),
)?;
@ -135,8 +137,8 @@ pub(crate) async fn dispatch(
.clone()
.unwrap_or_else(|| user_config::active_settings_path(None)),
);
let storage_dir = local_server::storage_dir(&settings)?;
let bind_addr = local_server::bind_request(&settings, serve_args.bind.as_deref())?;
let storage_dir = local_config.storage_dir().to_path_buf();
let bind_addr = local_config.bind_request(serve_args.bind.as_deref())?;
let _ = printer;
let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr()));
Box::pin(foreground::serve_with_daemon_record(

View file

@ -10,7 +10,7 @@ use anyhow::{Context, Result, anyhow, bail};
use fabro_config::RuntimeDirectory;
use fabro_config::bind::{Bind, BindRequest};
use fabro_config::daemon::ServerDaemon;
use fabro_config::user::{FABRO_CONFIG_ENV, default_settings_path, load_settings_config};
use fabro_config::user::{FABRO_CONFIG_ENV, default_settings_path};
use fabro_server::jwt_auth::auth_method_name;
use fabro_server::serve::{DEFAULT_TCP_PORT, ServeArgs, resolve_runtime_server_settings_for_start};
use fabro_server::{process_env_snapshot, validate_startup};
@ -148,8 +148,7 @@ async fn ensure_server_running_with_bind(
let bind_request = if let Some(bind_request) = bind_request {
bind_request
} else {
let settings = load_settings_config(Some(config_path))?;
local_server::bind_request(&settings, None)?
local_server::LocalServerConfig::load(Some(config_path), None)?.bind_request(None)?
};
match execute_daemon(
@ -216,9 +215,9 @@ fn server_max_concurrent_runs_override() -> Option<usize> {
}
fn configured_auth_methods(config_path: Option<&Path>) -> Vec<ServerAuthMethod> {
load_settings_config(config_path)
local_server::LocalServerConfig::load(config_path, None)
.ok()
.map(|settings| local_server::auth_methods(&settings))
.map(|settings| settings.auth_methods().to_vec())
.unwrap_or_default()
}

View file

@ -57,10 +57,11 @@ pub(crate) async fn run_uninstall(args: &UninstallArgs, ctx: &CommandContext) ->
return Ok(());
}
let storage_dir = user_config::load_settings()
let storage_dir = local_server::LocalServerConfig::load_with_storage_dir(None)
.ok()
.and_then(|settings| local_server::storage_dir(&settings).ok())
.unwrap_or_else(user_config::default_storage_dir);
.map_or_else(user_config::default_storage_dir, |settings| {
settings.storage_dir().to_path_buf()
});
let inventory = build_inventory(&home_root, &storage_dir)?;

View file

@ -1,7 +1,5 @@
use anyhow::bail;
use fabro_config::load::load_settings_user;
use fabro_config::user::active_settings_path;
use fabro_types::settings::SettingsLayer;
use fabro_util::terminal::Styles;
use crate::args::ValidateArgs;
@ -20,10 +18,10 @@ pub(crate) async fn run(
let built = build_run_manifest(ManifestBuildInput {
workflow: args.workflow.clone(),
cwd: ctx.cwd().to_path_buf(),
args_layer: SettingsLayer::default(),
run_overrides: None,
cli_overrides: None,
args: None,
run_id: None,
user_layer: load_settings_user()?,
user_settings_path: Some(active_settings_path(None)),
})?;
let client = ctx.server().await?;

View file

@ -18,7 +18,7 @@ pub(crate) async fn version_command(args: &VersionArgs, base_ctx: &CommandContex
let client = client_info();
let printer = base_ctx.printer();
let ctx = base_ctx.with_target(&args.target)?;
let server_target = user_config::resolve_server_target(&args.target, ctx.machine_settings())?;
let server_target = user_config::resolve_server_target(&args.target, ctx.user_settings())?;
let server_address = format_server_target(&server_target);
let server_info = match ctx.server().await {
Ok(server) => match server.get_system_info().await {

View file

@ -16,14 +16,14 @@ pub(super) fn create_command(args: &WorkflowCreateArgs, base_ctx: &CommandContex
let printer = base_ctx.printer();
let cwd = std::env::current_dir()?;
let Some((config_path, config)) = discover_project_config(&cwd)? else {
let Some(config_path) = discover_project_config(&cwd)? else {
bail!(
"No .fabro/project.toml found in {cwd} or any parent directory",
cwd = cwd.display()
);
};
let fabro_root = resolve_fabro_root(&config_path, &config);
let fabro_root = resolve_fabro_root(&config_path);
let created = write_workflow_scaffold(args, &fabro_root)?;
if base_ctx.json_output() {

View file

@ -19,14 +19,14 @@ pub(super) fn list_command(_args: &WorkflowListArgs, base_ctx: &CommandContext)
let styles = Styles::detect_stderr();
let cwd = std::env::current_dir()?;
let Some((config_path, config)) = discover_project_config(&cwd)? else {
let Some(config_path) = discover_project_config(&cwd)? else {
bail!(
"No .fabro/project.toml found in {cwd} or any parent directory",
cwd = cwd.display()
);
};
let fabro_root = resolve_fabro_root(&config_path, &config);
let fabro_root = resolve_fabro_root(&config_path);
let project_wf_dir = fabro_root.join("workflows");
let user_wf_dir = Some(fabro_util::Home::from_env().workflows_dir());

View file

@ -1,48 +1,140 @@
//! Helpers for CLI code that manages the local Fabro server on this host.
//!
//! This module is the only generic CLI lifecycle surface allowed to read
//! `[server.*]` settings. User-facing CLI commands outside same-host server
//! lifecycle should not call into it.
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use anyhow::Result;
use fabro_config::bind::BindRequest;
use fabro_server::serve::resolve_bind_request_from_settings;
use fabro_types::settings::{ServerAuthMethod, SettingsLayer};
use fabro_config::user::default_storage_dir;
use fabro_server::serve::resolve_bind_request_from_server_settings;
use fabro_types::ServerSettings;
use fabro_types::settings::{InterpString, ServerAuthMethod};
pub(crate) fn storage_dir(settings: &SettingsLayer) -> Result<PathBuf> {
storage_dir_with_lookup(settings, &|name| std::env::var(name).ok())
use crate::user_config;
pub(crate) struct LocalServerConfig {
storage_dir: PathBuf,
auth_methods: Vec<ServerAuthMethod>,
config_log_level: Option<String>,
server_settings: std::result::Result<ServerSettings, String>,
}
pub(crate) fn storage_dir_with_lookup(
settings: &SettingsLayer,
impl LocalServerConfig {
pub(crate) fn load(config_path: Option<&Path>, storage_dir: Option<&Path>) -> Result<Self> {
let settings = user_config::load_resolved_settings(config_path, storage_dir, None)?;
Ok(Self::from_loaded_settings(settings))
}
pub(crate) fn load_with_storage_dir(storage_dir: Option<&Path>) -> Result<Self> {
let settings = user_config::load_resolved_settings(None, storage_dir, None)?;
Ok(Self::from_loaded_settings(settings))
}
fn from_loaded_settings(settings: user_config::LoadedSettings) -> Self {
let server_settings = settings.server_settings;
let auth_methods = server_settings
.as_ref()
.map(|resolved| resolved.server.auth.methods.clone())
.unwrap_or_default();
Self {
storage_dir: settings.storage_dir,
auth_methods,
config_log_level: settings.config_log_level,
server_settings,
}
}
pub(crate) fn storage_dir(&self) -> &Path {
&self.storage_dir
}
pub(crate) fn auth_methods(&self) -> &[ServerAuthMethod] {
&self.auth_methods
}
pub(crate) fn config_log_level(&self) -> Option<&str> {
self.config_log_level.as_deref()
}
pub(crate) fn bind_request(&self, cli_override: Option<&str>) -> Result<BindRequest> {
let settings = self
.server_settings
.as_ref()
.map_err(|err| anyhow::anyhow!("{err}"))?;
resolve_bind_request_from_server_settings(settings, cli_override)
}
}
pub(crate) fn storage_dir_from_toml(source: &str) -> Result<PathBuf> {
storage_dir_from_toml_with_lookup(source, &|name| std::env::var(name).ok())
}
fn storage_dir_from_toml_with_lookup(
source: &str,
lookup: &dyn Fn(&str) -> Option<String>,
) -> Result<PathBuf> {
let storage_root = fabro_config::resolve_storage_root(settings);
let document: toml::Value = toml::from_str(source)
.map_err(|err| anyhow::anyhow!("failed to parse settings file: {err}"))?;
let storage_root = string_at_path(&document, &["server", "storage", "root"]).map_or_else(
|| InterpString::parse(&default_storage_dir().to_string_lossy()),
|root| InterpString::parse(&root),
);
let resolved_root = storage_root
.resolve(lookup)
.map_err(|err| anyhow::anyhow!("failed to resolve {}: {err}", storage_root.as_source()))?;
Ok(PathBuf::from(resolved_root.value))
}
pub(crate) fn bind_request(
settings: &SettingsLayer,
cli_override: Option<&str>,
) -> Result<BindRequest> {
resolve_bind_request_from_settings(settings, cli_override)
fn string_at_path(document: &toml::Value, path: &[&str]) -> Option<String> {
let mut current = document;
for segment in path {
current = current.get(*segment)?;
}
current.as_str().map(str::to_owned)
}
pub(crate) fn auth_methods(settings: &SettingsLayer) -> Vec<ServerAuthMethod> {
fabro_config::ServerSettings::from_layer(settings)
.map(|resolved| resolved.server.auth.methods)
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
pub(crate) fn config_log_level(settings: &SettingsLayer) -> Option<String> {
settings
.server
.as_ref()
.and_then(|server| server.logging.as_ref())
.and_then(|logging| logging.level.clone())
use fabro_config::user::default_storage_dir;
use super::{storage_dir_from_toml, storage_dir_from_toml_with_lookup};
#[test]
fn storage_dir_from_toml_reads_explicit_root_without_full_server_resolution() {
let path = storage_dir_from_toml(
r#"
_version = 1
[server.storage]
root = "/srv/fabro"
"#,
)
.expect("storage root should resolve");
assert_eq!(path, PathBuf::from("/srv/fabro"));
}
#[test]
fn storage_dir_from_toml_defaults_without_auth_methods() {
let path = storage_dir_from_toml("_version = 1\n").expect("default storage dir");
assert_eq!(path, default_storage_dir());
}
#[test]
fn storage_dir_from_toml_resolves_env_interpolation() {
let path = storage_dir_from_toml_with_lookup(
r#"
_version = 1
[server.storage]
root = "{{ env.FABRO_STORAGE_ROOT }}"
"#,
&|name| (name == "FABRO_STORAGE_ROOT").then_some("/srv/fabro".to_string()),
)
.expect("storage root should resolve");
assert_eq!(path, PathBuf::from("/srv/fabro"));
}
}

View file

@ -442,9 +442,8 @@ async fn prepare_server_bootstrap(
storage_dir: Option<&std::path::Path>,
foreground: bool,
) -> Result<PreTracingBootstrap> {
let settings =
user_config::load_settings_with_config_and_storage_dir(config_path, storage_dir)?;
let storage_dir = local_server::storage_dir(&settings)?;
let local_config = local_server::LocalServerConfig::load(config_path, storage_dir)?;
let storage_dir = local_config.storage_dir().to_path_buf();
let runtime_directory = fabro_config::RuntimeDirectory::new(storage_dir.clone());
let foreground_server_log_bootstrap = if foreground {
Some(commands::server::start::prepare_foreground_server_log(&runtime_directory).await?)
@ -456,7 +455,7 @@ async fn prepare_server_bootstrap(
sink: logging::InternalLogSink::Server {
path: runtime_directory.log_path(),
},
config_log_level: local_server::config_log_level(&settings),
config_log_level: local_config.config_log_level().map(str::to_owned),
foreground_server_log_bootstrap,
})
}

View file

@ -8,15 +8,14 @@ use std::path::{Component, Path, PathBuf};
use anyhow::{Context, Result, anyhow};
use fabro_api::types;
use fabro_config::load::load_settings_for_workflow;
use fabro_config::project::{self, discover_project_config, resolve_workflow_path};
use fabro_config::run::{parse_run_config, resolve_run_goal};
use fabro_config::run::{resolve_run_goal_from_layer, resolve_run_goal_from_namespace};
use fabro_config::{CliLayer, DaytonaDockerfileLayer, RunLayer, WorkflowSettingsBuilder};
use fabro_graphviz::graph::AttrValue;
use fabro_graphviz::parser;
use fabro_sandbox::daytona::detect_repo_info;
use fabro_types::RunId;
use fabro_types::settings::run::{DaytonaDockerfileLayer, ResolvedGoalSource, ResolvedRunGoal};
use fabro_types::settings::{Combine, SettingsLayer};
use fabro_types::settings::run::{ResolvedGoalSource, ResolvedRunGoal};
use fabro_types::{RunId, WorkflowSettings};
use fabro_workflow::git::{GitSyncStatus, head_sha, sync_status};
use crate::args::{PreflightArgs, RunArgs};
@ -25,12 +24,10 @@ use crate::args::{PreflightArgs, RunArgs};
pub(crate) struct ManifestBuildInput {
pub workflow: PathBuf,
pub cwd: PathBuf,
pub args_layer: SettingsLayer,
pub run_overrides: Option<RunLayer>,
pub cli_overrides: Option<CliLayer>,
pub args: Option<types::ManifestArgs>,
pub run_id: Option<RunId>,
/// User-level settings layer. Production callers load via
/// `load_settings_user()`; tests pass `SettingsLayer::default()`.
pub user_layer: SettingsLayer,
/// Path to the user settings file (for inclusion in
/// `RunManifest.configs`). `None` skips the user config entry.
pub user_settings_path: Option<PathBuf>,
@ -56,14 +53,43 @@ struct WorkflowScanInput {
}
pub(crate) fn build_run_manifest(input: ManifestBuildInput) -> Result<BuiltManifest> {
let workflow_layer = load_settings_for_workflow(&input.workflow, &input.cwd)?;
let merged_settings = input
.args_layer
.clone()
.combine(workflow_layer)
.combine(input.user_layer);
let root_resolution = resolve_workflow_path(&input.workflow, &input.cwd)?;
if root_resolution.workflow_toml_path.is_none()
&& !root_resolution.resolved_workflow_path.is_file()
{
return Err(fabro_config::Error::WorkflowNotFound(
root_resolution.resolved_workflow_path.display().to_string(),
)
.into());
}
let workflow_parent = root_resolution
.resolved_workflow_path
.parent()
.unwrap_or_else(|| Path::new("."));
let project_config = discover_project_config(workflow_parent)?;
let mut workflow_settings_builder = WorkflowSettingsBuilder::new();
if let Some(run) = input.run_overrides.clone() {
workflow_settings_builder = workflow_settings_builder.run_overrides(run);
}
if let Some(cli) = input.cli_overrides.clone() {
workflow_settings_builder = workflow_settings_builder.cli_overrides(cli);
}
if let Some(path) = root_resolution.workflow_toml_path.as_ref() {
workflow_settings_builder = workflow_settings_builder.workflow_file(path)?;
}
if let Some(path) = project_config.as_ref() {
workflow_settings_builder = workflow_settings_builder.project_file(path)?;
}
if let Some(path) = input
.user_settings_path
.as_ref()
.filter(|path| path.is_file())
{
workflow_settings_builder = workflow_settings_builder.user_file(path)?;
}
let workflow_settings = workflow_settings_builder
.build()
.map_err(|errors| anyhow!("failed to resolve manifest settings: {errors}"))?;
let target_path = root_resolution.dot_path.clone();
let target_logical_path = to_logical_path(&target_path, &input.cwd)?;
let target_logical_path_string = logical_path_string(&target_logical_path);
@ -82,12 +108,7 @@ pub(crate) fn build_run_manifest(input: ManifestBuildInput) -> Result<BuiltManif
.ok_or_else(|| anyhow!("root workflow missing from manifest bundle"))?;
let mut configs = Vec::new();
if let Some((path, _config)) = discover_project_config(
root_resolution
.resolved_workflow_path
.parent()
.unwrap_or_else(|| Path::new(".")),
)? {
if let Some(path) = project_config {
let source = std::fs::read_to_string(&path)
.with_context(|| format!("Failed to read {}", path.display()))?;
configs.push(types::ManifestConfig {
@ -106,11 +127,12 @@ pub(crate) fn build_run_manifest(input: ManifestBuildInput) -> Result<BuiltManif
});
}
let working_directory = project::resolve_working_directory(&merged_settings, &input.cwd);
let working_directory =
project::resolve_working_directory_from_run(&workflow_settings.run, &input.cwd);
let goal = resolve_manifest_goal(
&input.args_layer,
&merged_settings,
input.run_overrides.as_ref(),
&workflow_settings,
&root_source,
&target_path,
&working_directory,
@ -327,11 +349,19 @@ fn collect_workflow_config_files(
config: &types::ManifestWorkflowConfig,
files: &mut HashMap<String, types::ManifestFileEntry>,
) -> Result<()> {
let config_layer = parse_run_config(&config.source)?;
let dockerfile = config_layer
.run
let mut document: toml::Table = config
.source
.parse()
.map_err(|err| anyhow!("Failed to parse run config TOML: {err}"))?;
let run = document
.remove("run")
.map(toml::Value::try_into::<RunLayer>)
.transpose()
.map_err(|err| anyhow!("Failed to parse run config TOML: {err}"))?
.unwrap_or_default();
let dockerfile = run
.sandbox
.as_ref()
.and_then(|run| run.sandbox.as_ref())
.and_then(|sandbox| sandbox.daytona.as_ref())
.and_then(|daytona| daytona.snapshot.as_ref())
.and_then(|snapshot| snapshot.dockerfile.as_ref());
@ -389,24 +419,26 @@ fn collect_bundled_file(
}
fn resolve_manifest_goal(
args_layer: &SettingsLayer,
settings: &SettingsLayer,
run_overrides: Option<&RunLayer>,
settings: &WorkflowSettings,
root_source: &str,
root_dot_path: &Path,
working_directory: &Path,
) -> Result<Option<types::ManifestGoal>> {
// Precedence 1: CLI args (`--goal` / `--goal-file`). These are already
// resolved to absolute paths by `overrides::goal_layer_from_args`.
if let Some(resolved) = resolve_run_goal(args_layer, working_directory)
.context("failed to resolve --goal-file contents")?
{
return Ok(Some(resolved_goal_to_manifest(resolved)));
if let Some(run_overrides) = run_overrides {
if let Some(resolved) = resolve_run_goal_from_layer(run_overrides, working_directory)
.context("failed to resolve --goal-file contents")?
{
return Ok(Some(resolved_goal_to_manifest(resolved)));
}
}
// Precedence 2: merged config `run.goal`. Config-sourced `goal.file`
// paths were rewritten to absolute by `load_settings_path` at the
// directory of the config file that declared them.
if let Some(resolved) = resolve_run_goal(settings, working_directory)
if let Some(resolved) = resolve_run_goal_from_namespace(&settings.run, working_directory)
.context("failed to resolve run.goal.file contents")?
{
return Ok(Some(resolved_goal_to_manifest(resolved)));
@ -607,10 +639,10 @@ mod tests {
let built = build_run_manifest(ManifestBuildInput {
workflow: PathBuf::from(".fabro/workflows/demo/workflow.toml"),
cwd: project.to_path_buf(),
args_layer: SettingsLayer::default(),
run_overrides: None,
cli_overrides: None,
args: None,
run_id: None,
user_layer: SettingsLayer::default(),
user_settings_path: None,
})
.unwrap();
@ -687,10 +719,10 @@ file = "prompts/goal.md"
let built = build_run_manifest(ManifestBuildInput {
workflow: PathBuf::from(".fabro/workflows/demo/workflow.toml"),
cwd: project.to_path_buf(),
args_layer: SettingsLayer::default(),
run_overrides: None,
cli_overrides: None,
args: None,
run_id: None,
user_layer: SettingsLayer::default(),
user_settings_path: None,
})
.unwrap();
@ -740,10 +772,10 @@ file = "prompts/goal.md"
let built = build_run_manifest(ManifestBuildInput {
workflow: PathBuf::from(".fabro/workflows/demo/workflow.toml"),
cwd: project.to_path_buf(),
args_layer: SettingsLayer::default(),
run_overrides: None,
cli_overrides: None,
args: None,
run_id: None,
user_layer: SettingsLayer::default(),
user_settings_path: None,
})
.unwrap();
@ -803,10 +835,10 @@ working_dir = "repos/target"
let built = build_run_manifest(ManifestBuildInput {
workflow: PathBuf::from(".fabro/workflows/demo/workflow.toml"),
cwd: workspace.to_path_buf(),
args_layer: SettingsLayer::default(),
run_overrides: None,
cli_overrides: None,
args: None,
run_id: None,
user_layer: SettingsLayer::default(),
user_settings_path: None,
})
.unwrap();

View file

@ -11,14 +11,13 @@ use fabro_client::{
pub(crate) use fabro_client::{Client, RunEventStream};
use fabro_config::bind::Bind;
pub(crate) use fabro_types::RunProjection;
use fabro_types::settings::SettingsLayer;
use fabro_types::UserSettings;
use fabro_util::dev_token::validate_dev_token_format;
use fabro_util::{Home, dev_token};
use tokio::time::sleep;
use crate::args::ServerTargetArgs;
use crate::commands::server::start;
use crate::local_server;
use crate::user_config::{self, cli_http_client_builder};
#[derive(Debug)]
@ -67,14 +66,15 @@ pub(crate) async fn connect_server_target_with_bearer(
pub(crate) async fn connect_server_with_settings(
args: &ServerTargetArgs,
settings: &SettingsLayer,
settings: &UserSettings,
storage_dir: &Path,
base_config_path: &Path,
) -> Result<Client> {
if let Some(target) = user_config::resolve_nondefault_server_target(args, settings)? {
if let Some(path) = target.as_unix_socket_path() {
return connect_managed_unix_socket_api_client_bundle(
path,
&local_server::storage_dir(settings)?,
storage_dir,
base_config_path,
)
.await;
@ -82,7 +82,7 @@ pub(crate) async fn connect_server_with_settings(
return connect_target_api_client_bundle(&target).await;
}
connect_local_api_client_bundle(&local_server::storage_dir(settings)?, base_config_path).await
connect_local_api_client_bundle(storage_dir, base_config_path).await
}
async fn connect_managed_unix_socket_api_client_bundle(

View file

@ -3,9 +3,9 @@ use tracing::debug;
pub(crate) struct DummySleepInhibitor;
impl DummySleepInhibitor {
pub(crate) fn acquire() -> Option<Self> {
pub(crate) fn acquire() -> Self {
debug!("Sleep inhibitor: using dummy backend (no-op)");
Some(DummySleepInhibitor)
Self
}
}

View file

@ -4,37 +4,37 @@
reason = "FFI bindings preserve IOKit naming and include symbols referenced only on macOS."
)]
use core_foundation::string::CFString;
use core_foundation::string::{CFString, CFStringRef};
// IOKit power management assertion types
pub type IOPMAssertionID = u32;
pub const kIOPMAssertionIDInvalid: IOPMAssertionID = 0;
pub(super) type IOPMAssertionID = u32;
pub(super) const kIOPMAssertionIDInvalid: IOPMAssertionID = 0;
// IOReturn type
pub type IOReturn = i32;
pub const kIOReturnSuccess: IOReturn = 0;
pub(super) type IOReturn = i32;
pub(super) const kIOReturnSuccess: IOReturn = 0;
#[link(name = "IOKit", kind = "framework")]
extern "C" {
pub fn IOPMAssertionCreateWithName(
assertion_type: core_foundation::string::CFStringRef,
pub(super) fn IOPMAssertionCreateWithName(
assertion_type: CFStringRef,
assertion_level: u32,
reason_for_activity: core_foundation::string::CFStringRef,
reason_for_activity: CFStringRef,
assertion_id: *mut IOPMAssertionID,
) -> IOReturn;
pub fn IOPMAssertionRelease(assertion_id: IOPMAssertionID) -> IOReturn;
pub(super) fn IOPMAssertionRelease(assertion_id: IOPMAssertionID) -> IOReturn;
}
// Assertion level
pub const kIOPMAssertionLevelOn: u32 = 255;
pub(super) const kIOPMAssertionLevelOn: u32 = 255;
/// Create the CFString for "PreventUserIdleSystemSleep".
pub fn prevent_idle_sleep_type() -> CFString {
pub(super) fn prevent_idle_sleep_type() -> CFString {
CFString::new("PreventUserIdleSystemSleep")
}
/// Create a CFString reason.
pub fn assertion_reason() -> CFString {
pub(super) fn assertion_reason() -> CFString {
CFString::new("Fabro workflow running")
}

View file

@ -6,7 +6,10 @@
use core_foundation::base::TCFType;
use tracing::{debug, warn};
use super::iokit_bindings::*;
use super::iokit_bindings::{
IOPMAssertionCreateWithName, IOPMAssertionID, IOPMAssertionRelease, assertion_reason,
kIOPMAssertionIDInvalid, kIOPMAssertionLevelOn, kIOReturnSuccess, prevent_idle_sleep_type,
};
pub(crate) struct MacOSSleepInhibitor {
assertion_id: IOPMAssertionID,
@ -23,7 +26,7 @@ impl MacOSSleepInhibitor {
assertion_type.as_concrete_TypeRef(),
kIOPMAssertionLevelOn,
reason.as_concrete_TypeRef(),
&mut assertion_id,
&raw mut assertion_id,
)
};

View file

@ -16,7 +16,7 @@ mod dummy;
use tracing::debug;
/// RAII guard that prevents idle system sleep while held.
pub struct SleepInhibitorGuard {
pub(crate) struct SleepInhibitorGuard {
_inner: InnerGuard,
}
@ -39,7 +39,7 @@ enum InnerGuard {
/// If `enabled` is `true`, attempts to acquire a platform-specific sleep
/// inhibitor. Falls back to a dummy (no-op) backend if the platform backend
/// is unavailable.
pub fn guard(enabled: bool) -> Option<SleepInhibitorGuard> {
pub(crate) fn guard(enabled: bool) -> Option<SleepInhibitorGuard> {
if !enabled {
debug!("Sleep inhibitor: disabled by configuration");
return None;
@ -63,9 +63,8 @@ pub fn guard(enabled: bool) -> Option<SleepInhibitorGuard> {
}
}
// Fallback to dummy
dummy::DummySleepInhibitor::acquire().map(|inner| SleepInhibitorGuard {
_inner: InnerGuard::Dummy(inner),
Some(SleepInhibitorGuard {
_inner: InnerGuard::Dummy(dummy::DummySleepInhibitor::acquire()),
})
}

View file

@ -3,31 +3,154 @@ use std::str::FromStr;
use anyhow::Result;
pub(crate) use fabro_client::ServerTarget;
pub(crate) use fabro_config::user::*;
pub(crate) use fabro_config::user::{FABRO_CONFIG_ENV, active_settings_path, default_storage_dir};
use fabro_config::user::{default_settings_path, default_socket_path};
use fabro_config::{
CliLayer, ParseError, RunSettingsBuilder, ServerSettingsBuilder, UserSettingsBuilder,
};
use fabro_types::settings::cli::CliTargetSettings;
use fabro_types::settings::{CliNamespace, SettingsLayer};
use fabro_types::settings::{CliNamespace, InterpString, RunNamespace};
use fabro_types::{ServerSettings, UserSettings};
use fabro_util::version::FABRO_VERSION;
use tracing::debug;
use crate::args::ServerTargetArgs;
use crate::local_server;
pub(crate) fn load_settings() -> anyhow::Result<SettingsLayer> {
load_settings_with_config_and_storage_dir(None, None)
pub(crate) struct LoadedSettings {
pub(crate) storage_dir: PathBuf,
pub(crate) config_log_level: Option<String>,
pub(crate) run_settings: std::result::Result<RunNamespace, String>,
pub(crate) server_settings: std::result::Result<ServerSettings, String>,
pub(crate) user_settings: UserSettings,
}
pub(crate) fn load_settings_with_storage_dir(
storage_dir: Option<&Path>,
) -> anyhow::Result<SettingsLayer> {
load_settings_with_config_and_storage_dir(None, storage_dir)
}
pub(crate) fn load_settings_with_config_and_storage_dir(
pub(crate) fn load_resolved_settings(
config_path: Option<&Path>,
storage_dir: Option<&Path>,
) -> anyhow::Result<SettingsLayer> {
let layer = load_settings_config(config_path)?;
Ok(apply_storage_dir_override(layer, storage_dir))
cli_layer: Option<&CliLayer>,
) -> anyhow::Result<LoadedSettings> {
let document = load_settings_document(config_path)?;
let storage_override = storage_dir.map(Path::to_path_buf);
let storage_dir = storage_dir_from_document(&document, storage_dir)?;
let config_log_level = config_log_level_from_document(&document);
let run_settings = load_run_settings(config_path).map_err(|err| err.to_string());
let server_settings = load_server_settings(config_path)
.map(|settings| match storage_override.as_deref() {
Some(dir) => settings.with_storage_override(dir),
None => settings,
})
.map_err(|err| err.to_string());
let user_settings = load_user_settings(config_path, cli_layer)?;
Ok(LoadedSettings {
storage_dir,
config_log_level,
run_settings,
server_settings,
user_settings,
})
}
fn load_settings_document(config_path: Option<&Path>) -> anyhow::Result<toml::Value> {
load_settings_document_with_lookup(config_path, |name| std::env::var_os(name))
}
#[expect(
clippy::disallowed_methods,
reason = "sync settings load during CLI startup; not on a Tokio path"
)]
fn load_settings_document_with_lookup(
config_path: Option<&Path>,
lookup: impl Fn(&str) -> Option<std::ffi::OsString>,
) -> anyhow::Result<toml::Value> {
let config_path = config_path
.map(Path::to_path_buf)
.or_else(|| lookup(FABRO_CONFIG_ENV).map(PathBuf::from));
let path = if let Some(path) = config_path {
path
} else {
let default_path = default_settings_path();
if !default_path.is_file() {
return Ok(toml::Value::Table(toml::Table::new()));
}
default_path
};
let contents = std::fs::read_to_string(&path)
.map_err(|source| fabro_config::Error::read_file(&path, source))?;
let table: toml::Table = toml::from_str(&contents).map_err(|source| {
fabro_config::Error::parse_file(
"Failed to parse settings file",
&path,
ParseError::Toml(source.to_string()),
)
})?;
Ok(toml::Value::Table(table))
}
fn load_run_settings(config_path: Option<&Path>) -> anyhow::Result<RunNamespace> {
Ok(match config_path {
Some(path) => RunSettingsBuilder::load_from(path)?,
None => RunSettingsBuilder::load_default()?,
})
}
fn load_server_settings(config_path: Option<&Path>) -> anyhow::Result<ServerSettings> {
Ok(match config_path {
Some(path) => ServerSettingsBuilder::load_from(path)?,
None => ServerSettingsBuilder::load_default()?,
})
}
fn load_user_settings(
config_path: Option<&Path>,
cli_layer: Option<&CliLayer>,
) -> anyhow::Result<UserSettings> {
Ok(match (config_path, cli_layer) {
(Some(path), Some(cli_layer)) => {
UserSettingsBuilder::load_from_with_cli_overrides(path, cli_layer)?
}
(Some(path), None) => UserSettingsBuilder::load_from(path)?,
(None, Some(cli_layer)) => UserSettingsBuilder::load_default_with_cli_overrides(cli_layer)?,
(None, None) => UserSettingsBuilder::load_default()?,
})
}
fn config_log_level_from_document(document: &toml::Value) -> Option<String> {
string_at_path(document, &["server", "logging", "level"])
}
fn storage_dir_from_document(
document: &toml::Value,
storage_dir: Option<&Path>,
) -> anyhow::Result<PathBuf> {
storage_dir_from_document_with_lookup(document, storage_dir, &|name| std::env::var(name).ok())
}
fn storage_dir_from_document_with_lookup(
document: &toml::Value,
storage_dir: Option<&Path>,
lookup: &dyn Fn(&str) -> Option<String>,
) -> anyhow::Result<PathBuf> {
if let Some(dir) = storage_dir {
return Ok(dir.to_path_buf());
}
let storage_root = string_at_path(document, &["server", "storage", "root"]).map_or_else(
|| InterpString::parse(&default_storage_dir().to_string_lossy()),
|root| InterpString::parse(&root),
);
let resolved_root = storage_root.resolve(lookup)?;
Ok(PathBuf::from(resolved_root.value))
}
fn string_at_path(document: &toml::Value, path: &[&str]) -> Option<String> {
let mut current = document;
for segment in path {
current = current.get(*segment)?;
}
current.as_str().map(str::to_owned)
}
/// Pull the resolved CLI target configuration out of `[cli.target]`.
@ -40,9 +163,8 @@ fn cli_target_from_settings(settings: &CliNamespace) -> Option<String> {
}
}
fn configured_server_target(settings: &SettingsLayer) -> Result<Option<ServerTarget>> {
let user_settings = fabro_config::UserSettings::from_layer(settings)?;
let Some(value) = cli_target_from_settings(&user_settings.cli) else {
fn configured_server_target(settings: &UserSettings) -> Result<Option<ServerTarget>> {
let Some(value) = cli_target_from_settings(&settings.cli) else {
return Ok(None);
};
parse_server_target(&value).map(Some)
@ -52,13 +174,6 @@ pub(crate) fn default_server_target() -> ServerTarget {
ServerTarget::unix_socket_path(default_socket_path()).expect("default socket path is absolute")
}
#[deprecated(
note = "use local_server::storage_dir for lifecycle; PR commands must move to server-side API"
)]
pub(crate) fn storage_dir(settings: &SettingsLayer) -> anyhow::Result<PathBuf> {
local_server::storage_dir(settings)
}
fn parse_server_target(value: &str) -> Result<ServerTarget> {
ServerTarget::from_str(value)
}
@ -69,14 +184,14 @@ fn explicit_server_target(args: &ServerTargetArgs) -> Result<Option<ServerTarget
pub(crate) fn resolve_nondefault_server_target(
args: &ServerTargetArgs,
settings: &SettingsLayer,
settings: &UserSettings,
) -> Result<Option<ServerTarget>> {
Ok(explicit_server_target(args)?.or(configured_server_target(settings)?))
}
pub(crate) fn resolve_server_target(
args: &ServerTargetArgs,
settings: &SettingsLayer,
settings: &UserSettings,
) -> Result<ServerTarget> {
Ok(resolve_nondefault_server_target(args, settings)?.unwrap_or_else(default_server_target))
}
@ -92,13 +207,44 @@ pub(crate) fn cli_http_client_builder() -> fabro_http::HttpClientBuilder {
}
#[cfg(test)]
#[allow(
deprecated,
reason = "the storage_dir tests are exercising the deprecated helper by definition"
)]
pub(crate) fn load_resolved_settings_from_toml(
source: &str,
storage_dir: Option<&Path>,
cli_layer: Option<&CliLayer>,
) -> anyhow::Result<LoadedSettings> {
let document: toml::Value = toml::from_str(source)
.map_err(|err| anyhow::anyhow!("failed to parse settings file: {err}"))?;
let storage_override = storage_dir.map(Path::to_path_buf);
let storage_dir = storage_dir_from_document(&document, storage_dir)?;
let config_log_level = config_log_level_from_document(&document);
let run_settings = RunSettingsBuilder::from_toml(source).map_err(|err| err.to_string());
let server_settings = ServerSettingsBuilder::from_toml(source)
.map(|settings| match storage_override.as_deref() {
Some(dir) => settings.with_storage_override(dir),
None => settings,
})
.map_err(|err| err.to_string());
let user_settings = match cli_layer {
Some(cli_layer) => UserSettingsBuilder::from_toml_with_cli_overrides(source, cli_layer)?,
None => UserSettingsBuilder::from_toml(source)?,
};
Ok(LoadedSettings {
storage_dir,
config_log_level,
run_settings,
server_settings,
user_settings,
})
}
#[cfg(test)]
mod tests {
use fabro_config::parse_settings_layer;
use std::path::PathBuf;
use fabro_config::UserSettingsBuilder;
use fabro_config::user::default_storage_dir;
use fabro_types::UserSettings;
use super::*;
use crate::args::ServerTargetArgs;
@ -109,8 +255,8 @@ mod tests {
}
}
fn parse_v2(source: &str) -> SettingsLayer {
parse_settings_layer(source).expect("fixture should parse")
fn parse_user_settings(source: &str) -> UserSettings {
UserSettingsBuilder::from_toml(source).expect("fixture should resolve")
}
#[test]
@ -141,7 +287,7 @@ mod tests {
#[test]
fn resolve_server_target_uses_configured_server_target() {
let settings = parse_v2(
let settings = parse_user_settings(
r#"
_version = 1
@ -158,7 +304,7 @@ url = "https://config.example.com"
#[test]
fn resolve_server_target_explicit_target_overrides_config_target() {
let settings = parse_v2(
let settings = parse_user_settings(
r#"
_version = 1
@ -179,7 +325,7 @@ url = "https://config.example.com"
#[test]
fn resolve_server_target_defaults_to_default_unix_socket_target() {
let settings = SettingsLayer::default();
let settings = UserSettings::default();
assert_eq!(
resolve_server_target(&server_target_args(None), &settings).unwrap(),
ServerTarget::unix_socket_path(dirs::home_dir().unwrap().join(".fabro/fabro.sock"))
@ -189,7 +335,7 @@ url = "https://config.example.com"
#[test]
fn explicit_server_target_overrides_config_target() {
let settings = parse_v2(
let settings = parse_user_settings(
r#"
_version = 1
@ -219,43 +365,81 @@ url = "https://config.example.com"
#[test]
fn storage_dir_defaults_without_server_auth_methods() {
let settings = SettingsLayer::default();
let document = toml::Value::Table(toml::Table::new());
assert_eq!(storage_dir(&settings).unwrap(), default_storage_dir());
assert_eq!(
storage_dir_from_document(&document, None).unwrap(),
default_storage_dir()
);
}
#[test]
fn storage_dir_uses_explicit_server_storage_root() {
let settings = parse_v2(
let document: toml::Value = toml::from_str(
r#"
_version = 1
[server.storage]
root = "/srv/fabro"
"#,
);
)
.expect("fixture should parse");
assert_eq!(storage_dir(&settings).unwrap(), PathBuf::from("/srv/fabro"));
assert_eq!(
storage_dir_from_document(&document, None).unwrap(),
PathBuf::from("/srv/fabro")
);
}
#[test]
fn storage_dir_resolves_env_interpolated_root() {
let settings = parse_v2(
let document: toml::Value = toml::from_str(
r#"
_version = 1
[server.storage]
root = "{{ env.FABRO_STORAGE_ROOT }}"
"#,
);
)
.expect("fixture should parse");
let temp = tempfile::tempdir().unwrap();
assert_eq!(
local_server::storage_dir_with_lookup(&settings, &|name| {
storage_dir_from_document_with_lookup(&document, None, &|name| {
(name == "FABRO_STORAGE_ROOT").then(|| temp.path().display().to_string())
})
.unwrap(),
temp.path()
);
}
#[test]
#[expect(
clippy::disallowed_methods,
reason = "unit test writes a temporary settings fixture with sync std::fs::write"
)]
fn load_settings_document_uses_fabro_config_env_for_storage_root() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("settings.toml");
std::fs::write(
&config_path,
r#"
_version = 1
[server.storage]
root = "/srv/fabro"
"#,
)
.unwrap();
let document = load_settings_document_with_lookup(None, |_| {
Some(config_path.clone().into_os_string())
})
.expect("settings document should load");
assert_eq!(
storage_dir_from_document(&document, None).unwrap(),
PathBuf::from("/srv/fabro")
);
}
}

View file

@ -570,53 +570,78 @@ fn attach_json_errors_without_prompting_for_human_input() {
},
"run_dir": "[RUN_DIR]",
"settings": {
"workflow": {
"graph": "workflow.fabro"
},
"cli": {
"exec": {
"prevent_idle_sleep": false
},
"output": {
"format": "text",
"verbosity": "normal"
},
"target": {
"path": "[CLI_SOCKET]",
"type": "unix"
},
"updates": {
"check": true
}
},
"features": {
"session_sandboxes": false
},
"project": {
"directory": "."
"description": null,
"directory": ".",
"metadata": {},
"name": null
},
"run": {
"agent": {
"mcps": {},
"permissions": null
},
"artifacts": {
"include": []
},
"checkpoint": {
"exclude_globs": []
},
"execution": {
"approval": "prompt",
"mode": "normal",
"retros": false
},
"goal": "Wait for approval",
"git": {
"author": null
},
"goal": {
"type": "inline",
"value": "Wait for approval"
},
"hooks": [],
"inputs": {},
"interviews": {
"discord": null,
"provider": null,
"slack": null,
"teams": null
},
"metadata": {},
"model": {
"fallbacks": [],
"name": "gpt-5.4",
"provider": "openai"
},
"notifications": {},
"prepare": {
"timeout": "5m"
"commands": [],
"timeout_ms": 300000
},
"pull_request": null,
"sandbox": {
"daytona": null,
"devcontainer": false,
"env": {},
"local": {
"worktree_mode": "clean"
},
"preserve": false,
"provider": "local"
}
},
"scm": {
"github": null,
"owner": null,
"provider": null,
"repository": null
},
"working_dir": null
},
"workflow": {
"description": null,
"graph": "workflow.fabro",
"metadata": {},
"name": null
}
},
"workflow_slug": "human-gate",

View file

@ -5,9 +5,7 @@
use std::path::PathBuf;
use fabro_config::parse_settings_layer;
use fabro_test::{fabro_snapshot, test_context};
use fabro_types::settings::SettingsLayer;
use httpmock::MockServer;
use predicates::prelude::*;
@ -50,9 +48,8 @@ fn server_storage_root(settings: &serde_json::Value) -> &str {
.expect("server.storage.root")
}
fn server_settings_layer_fixture() -> SettingsLayer {
parse_settings_layer(
r#"
fn server_settings_toml_fixture() -> &'static str {
r#"
_version = 1
[server.auth]
@ -68,13 +65,11 @@ provider = "openai"
[run.inputs]
server_only = "1"
shared = "server"
"#,
)
.expect("server settings fixture should parse")
"#
}
fn resolved_server_settings_fixture() -> serde_json::Value {
let settings = fabro_config::ServerSettings::from_layer(&server_settings_layer_fixture())
let settings = fabro_config::ServerSettingsBuilder::from_toml(server_settings_toml_fixture())
.expect("server settings fixture should resolve");
serde_json::to_value(settings).expect("resolved settings payload should serialize")
}
@ -357,10 +352,6 @@ fn create_explicit_workflow_path_uses_project_config_relative_to_workflow() {
run_spec["settings"]["run"]["execution"]["approval"].as_str(),
Some("auto")
);
assert_eq!(
run_spec["settings"]["server"]["storage"]["root"].as_str(),
Some(storage_dir.to_str().unwrap())
);
assert_eq!(
run_spec["settings"]["run"]["sandbox"]["preserve"].as_bool(),
Some(true)
@ -371,8 +362,8 @@ fn create_explicit_workflow_path_uses_project_config_relative_to_workflow() {
);
// v2 R30: run.prepare.steps replaces the whole ordered list across layers.
assert_eq!(
run_spec["settings"]["run"]["prepare"]["steps"],
serde_json::json!([{"script": "workflow-setup"}])
run_spec["settings"]["run"]["prepare"]["commands"],
serde_json::json!(["workflow-setup"])
);
}

View file

@ -6,10 +6,8 @@ use serde_json::json;
use super::support::{fixture, output_stdout, resolve_run, run_count_for_test_case, run_state};
use crate::support::{fabro_json_snapshot, unique_run_id};
fn resolved_run(
settings: &fabro_types::settings::SettingsLayer,
) -> fabro_types::settings::RunNamespace {
fabro_config::resolve_run_from_file(settings).expect("run settings should resolve")
fn resolved_run(settings: &fabro_types::WorkflowSettings) -> fabro_types::settings::RunNamespace {
settings.run.clone()
}
fn run_status_response(run_id: &str, status: &str) -> serde_json::Value {
@ -365,7 +363,6 @@ fn create_persists_requested_overrides_into_store() {
});
let settings = &run_spec.settings;
let resolved_run = resolved_run(settings);
let cli_settings = fabro_config::resolve_cli_from_file(settings).expect("cli settings");
let compact = json!({
"workflow_slug": run_spec.workflow_slug,
"settings": {
@ -376,7 +373,6 @@ fn create_persists_requested_overrides_into_store() {
"dry_run": resolved_run.execution.mode == fabro_types::settings::run::RunMode::DryRun,
"auto_approve": resolved_run.execution.approval == fabro_types::settings::run::ApprovalMode::Auto,
"no_retro": !resolved_run.execution.retros,
"verbose": cli_settings.output.verbosity == fabro_types::settings::cli::OutputVerbosity::Verbose,
"llm": {
"model": resolved_run.model.name.as_ref().map(fabro_types::settings::InterpString::as_source),
"provider": resolved_run.model.provider.as_ref().map(fabro_types::settings::InterpString::as_source),
@ -397,7 +393,6 @@ fn create_persists_requested_overrides_into_store() {
"dry_run": true,
"auto_approve": true,
"no_retro": true,
"verbose": true,
"llm": {
"model": "gpt-5",
"provider": "openai"

View file

@ -133,7 +133,10 @@ fn inspect_created_run_shows_run_spec_without_start_or_conclusion() {
"kind": "submitted"
},
"run_spec": {
"goal": "Run tests and report results",
"goal": {
"type": "inline",
"value": "Run tests and report results"
},
"workflow_name": "Simple",
"workflow_slug": "simple",
"sandbox_provider": "local",
@ -169,7 +172,10 @@ fn inspect_completed_run_shows_run_start_conclusion_checkpoint() {
"reason": "completed"
},
"run_spec": {
"goal": "Run tests and report results",
"goal": {
"type": "inline",
"value": "Run tests and report results"
},
"workflow_name": "Simple",
"workflow_slug": "simple",
"sandbox_provider": "local",
@ -238,7 +244,10 @@ fn inspect_completed_run_reads_store_without_disk_metadata_files() {
"reason": "completed"
},
"run_spec": {
"goal": "Run tests and report results",
"goal": {
"type": "inline",
"value": "Run tests and report results"
},
"workflow_name": "Simple",
"workflow_slug": "simple",
"sandbox_provider": "local",
@ -292,7 +301,10 @@ fn inspect_git_backed_run_exposes_checkpoint_and_sandbox_state() {
"reason": "completed"
},
"run_spec": {
"goal": "Edit a tracked file",
"goal": {
"type": "inline",
"value": "Edit a tracked file"
},
"workflow_name": "Flow",
"workflow_slug": "flow",
"llm_provider": "openai",

View file

@ -340,7 +340,7 @@ digraph CachedGraph {
}
#[test]
fn runner_uses_snapshotted_app_id_for_github_credentials() {
fn runner_local_dry_runs_ignore_github_app_configuration() {
let context = auth_context();
let run_id = unique_run_id();
let workflow_path = context.temp_dir.join("workflow.fabro");
@ -354,7 +354,7 @@ _version = 1
methods = [\"dev-token\"]
[server.integrations.github]
app_id = \"snapshotted-app-id\"
app_id = \"fixture-app-id\"
",
);
context.write_temp(
@ -382,21 +382,6 @@ digraph GitHubApp {
.success();
let run_dir = context.find_run_dir(&run_id);
let state = run_state(&run_dir);
let run = state.spec.as_ref().expect("run spec should exist");
let resolved_server = fabro_config::resolve_server_from_file(&run.settings).unwrap();
fabro_json_snapshot!(
context,
serde_json::json!({
"app_id": resolved_server.integrations.github.app_id.map(|value| value.as_source()),
}),
@r#"
{
"app_id": "snapshotted-app-id"
}
"#
);
context.write_home(".fabro/settings.toml", "_version = 1\n");
let server = server_target(&context.storage_dir);

View file

@ -17,13 +17,13 @@ use axum::extract::{Request, State as AxumState};
use axum::middleware::{self, Next};
use axum::response::Response as AxumResponse;
use chrono::{Duration as ChronoDuration, Utc};
use fabro_config::{parse_settings_layer, resolve_server_from_file};
use fabro_config::{RunLayer, ServerSettingsBuilder};
use fabro_server::auth::GithubEndpoints;
use fabro_server::ip_allowlist::IpAllowlistConfig;
use fabro_server::jwt_auth::resolve_auth_mode_with_lookup;
use fabro_server::server::{
RouterOptions, build_router_with_options,
create_app_state_with_env_lookup_and_server_secret_env,
create_app_state_with_runtime_settings_and_env_lookup_and_server_secret_env,
};
use fabro_test::{GitHubAppState, TestContext, apply_test_isolation};
use serde_json::Value;
@ -68,7 +68,7 @@ impl RealAuthHarness {
let (api_listener, api_base_url) = bind_listener().await;
let settings = auth_settings(&api_base_url, &github_client_id, auth_methods);
let resolved = resolve_server_from_file(&settings).expect("settings should resolve");
let resolved = settings.server.clone();
let dev_token = dev_token.map(str::to_string);
let auth_mode = resolve_auth_mode_with_lookup(&resolved, |name| match name {
"SESSION_SECRET" => Some(TEST_SESSION_SECRET.to_string()),
@ -90,8 +90,13 @@ impl RealAuthHarness {
if let Some(token) = dev_token.clone() {
secrets.insert("FABRO_DEV_TOKEN".to_string(), token);
}
let state =
create_app_state_with_env_lookup_and_server_secret_env(settings, 5, |_| None, &secrets);
let state = create_app_state_with_runtime_settings_and_env_lookup_and_server_secret_env(
settings,
RunLayer::default(),
5,
|_| None,
&secrets,
);
let github_base = github_base_url(&twin.base_url);
let router = build_router_with_options(
state,
@ -336,13 +341,13 @@ fn auth_settings(
api_base_url: &str,
github_client_id: &str,
auth_methods: &[&str],
) -> fabro_types::settings::SettingsLayer {
) -> fabro_types::ServerSettings {
let auth_methods = auth_methods
.iter()
.map(|method| format!("\"{method}\""))
.collect::<Vec<_>>()
.join(", ");
parse_settings_layer(&format!(
ServerSettingsBuilder::from_toml(&format!(
r#"
_version = 1
@ -359,7 +364,7 @@ url = "{api_base_url}"
client_id = "{github_client_id}"
"#
))
.expect("test settings should parse")
.expect("test settings should resolve")
}
fn github_base_url(base_url: &str) -> fabro_http::Url {

View file

@ -20,6 +20,7 @@ workspace = true
anyhow.workspace = true
clap = { workspace = true, optional = true }
chrono.workspace = true
fabro-macros = { path = "../fabro-macros" }
fabro-proc = { path = "../fabro-proc" }
fabro-types = { path = "../fabro-types" }
fabro-util = { path = "../fabro-util" }

View file

@ -0,0 +1,543 @@
use std::fmt;
use std::path::Path;
use fabro_types::settings::{ProjectNamespace, RunNamespace, WorkflowNamespace};
use fabro_types::{ServerSettings, UserSettings, WorkflowSettings};
use crate::defaults::DEFAULTS_LAYER;
use crate::load::load_settings_path;
use crate::resolve::{
ResolveError, resolve_cli, resolve_features, resolve_project, resolve_run, resolve_server,
resolve_workflow,
};
use crate::user::load_settings_config;
use crate::{CliLayer, Combine, Error, Result, RunLayer, ServerLayer, SettingsLayer, run};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolveErrors(pub Vec<ResolveError>);
impl ResolveErrors {
#[must_use]
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn iter(&self) -> std::slice::Iter<'_, ResolveError> {
self.0.iter()
}
#[must_use]
pub fn into_inner(self) -> Vec<ResolveError> {
self.0
}
}
impl<'a> IntoIterator for &'a ResolveErrors {
type Item = &'a ResolveError;
type IntoIter = std::slice::Iter<'a, ResolveError>;
fn into_iter(self) -> Self::IntoIter {
self.iter()
}
}
impl fmt::Display for ResolveErrors {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let rendered = self
.0
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("; ");
f.write_str(&rendered)
}
}
impl std::error::Error for ResolveErrors {}
impl From<Vec<ResolveError>> for ResolveErrors {
fn from(value: Vec<ResolveError>) -> Self {
Self(value)
}
}
impl From<ResolveErrors> for Vec<ResolveError> {
fn from(value: ResolveErrors) -> Self {
value.0
}
}
pub struct ServerSettingsBuilder;
impl ServerSettingsBuilder {
pub fn load_default() -> Result<ServerSettings> {
let layer = load_settings_config(None)?;
Self::from_layer(&layer)
}
pub fn load_from(path: &Path) -> Result<ServerSettings> {
let layer = load_settings_path(path)?;
Self::from_layer(&layer)
}
pub fn from_toml(source: &str) -> Result<ServerSettings> {
let layer = source
.parse::<SettingsLayer>()
.map_err(|err| Error::parse("Failed to parse settings file", err))?;
Self::from_layer(&layer)
}
pub(crate) fn from_layer(layer: &SettingsLayer) -> Result<ServerSettings> {
let layer = layer.clone().combine(DEFAULTS_LAYER.clone());
let mut errors = Vec::new();
let server = resolve_server(&layer.server.clone().unwrap_or_default(), &mut errors);
let features = resolve_features(&layer.features.clone().unwrap_or_default(), &mut errors);
finish_result(
ServerSettings { server, features },
"failed to resolve server settings",
errors,
)
}
}
pub struct UserSettingsBuilder;
impl UserSettingsBuilder {
pub fn load_default() -> Result<UserSettings> {
let layer = load_settings_config(None)?;
Self::from_layer(&layer)
}
pub fn load_default_with_cli_overrides(cli: &CliLayer) -> Result<UserSettings> {
let layer = load_settings_config(None)?;
Self::from_layer_with_cli_overrides(&layer, cli)
}
pub fn load_from(path: &Path) -> Result<UserSettings> {
let layer = load_settings_path(path)?;
Self::from_layer(&layer)
}
pub fn load_from_with_cli_overrides(path: &Path, cli: &CliLayer) -> Result<UserSettings> {
let layer = load_settings_path(path)?;
Self::from_layer_with_cli_overrides(&layer, cli)
}
pub fn from_toml(source: &str) -> Result<UserSettings> {
let layer = source
.parse::<SettingsLayer>()
.map_err(|err| Error::parse("Failed to parse settings file", err))?;
Self::from_layer(&layer)
}
pub fn from_toml_with_cli_overrides(source: &str, cli: &CliLayer) -> Result<UserSettings> {
let layer = source
.parse::<SettingsLayer>()
.map_err(|err| Error::parse("Failed to parse settings file", err))?;
Self::from_layer_with_cli_overrides(&layer, cli)
}
pub(crate) fn from_layer(layer: &SettingsLayer) -> Result<UserSettings> {
let layer = layer.clone().combine(DEFAULTS_LAYER.clone());
let mut errors = Vec::new();
let cli = resolve_cli(&layer.cli.clone().unwrap_or_default(), &mut errors);
let features = resolve_features(&layer.features.clone().unwrap_or_default(), &mut errors);
finish_result(
UserSettings { cli, features },
"failed to resolve user settings",
errors,
)
}
pub(crate) fn from_layer_with_cli_overrides(
layer: &SettingsLayer,
cli: &CliLayer,
) -> Result<UserSettings> {
Self::from_layer(
&SettingsLayer {
cli: Some(cli.clone()),
..SettingsLayer::default()
}
.combine(layer.clone()),
)
}
}
pub struct RunSettingsBuilder;
impl RunSettingsBuilder {
pub fn load_default() -> Result<RunNamespace> {
let layer = load_settings_config(None)?;
Self::from_layer(&layer)
}
pub fn load_from(path: &Path) -> Result<RunNamespace> {
let layer = load_settings_path(path)?;
Self::from_layer(&layer)
}
pub fn from_toml(source: &str) -> Result<RunNamespace> {
let layer = source
.parse::<SettingsLayer>()
.map_err(|err| Error::parse("Failed to parse settings file", err))?;
Self::from_layer(&layer)
}
pub(crate) fn from_layer(layer: &SettingsLayer) -> Result<RunNamespace> {
let layer = layer.clone().combine(DEFAULTS_LAYER.clone());
let mut errors = Vec::new();
let run = resolve_run(&layer.run.clone().unwrap_or_default(), &mut errors);
finish_result(run, "failed to resolve run settings", errors)
}
pub fn from_run_layer(run: &RunLayer) -> Result<RunNamespace> {
Self::from_layer(&SettingsLayer {
run: Some(run.clone()),
..SettingsLayer::default()
})
}
}
#[derive(Clone)]
pub struct ServerRuntimeSettings {
pub server_settings: ServerSettings,
pub manifest_run_defaults: RunLayer,
pub manifest_run_settings: std::result::Result<RunNamespace, String>,
}
pub fn load_server_runtime_settings(
path: Option<&Path>,
run_overrides: Option<RunLayer>,
server_overrides: Option<ServerLayer>,
) -> Result<ServerRuntimeSettings> {
let layer = match path {
Some(path) => load_settings_path(path)?,
None => load_settings_config(None)?,
};
resolve_server_runtime_settings(layer, run_overrides, server_overrides)
}
#[cfg(test)]
pub fn server_runtime_settings_from_toml(
source: &str,
run_overrides: Option<RunLayer>,
server_overrides: Option<ServerLayer>,
) -> Result<ServerRuntimeSettings> {
let layer = source
.parse::<SettingsLayer>()
.map_err(|err| Error::parse("Failed to parse settings file", err))?;
resolve_server_runtime_settings(layer, run_overrides, server_overrides)
}
fn resolve_server_runtime_settings(
mut layer: SettingsLayer,
run_overrides: Option<RunLayer>,
server_overrides: Option<ServerLayer>,
) -> Result<ServerRuntimeSettings> {
if let Some(run) = run_overrides {
layer = SettingsLayer {
run: Some(run),
..SettingsLayer::default()
}
.combine(layer);
}
if let Some(server) = server_overrides {
layer = SettingsLayer {
server: Some(server),
..SettingsLayer::default()
}
.combine(layer);
}
let manifest_run_defaults = layer.run.clone().unwrap_or_default();
Ok(ServerRuntimeSettings {
server_settings: ServerSettingsBuilder::from_layer(&layer)?,
manifest_run_settings: RunSettingsBuilder::from_run_layer(&manifest_run_defaults)
.map_err(|err| err.to_string()),
manifest_run_defaults,
})
}
#[derive(Clone, Debug, Default)]
pub struct WorkflowSettingsBuilder {
args: SettingsLayer,
workflow: SettingsLayer,
project: SettingsLayer,
user: SettingsLayer,
server: SettingsLayer,
}
impl WorkflowSettingsBuilder {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn from_toml(source: &str) -> Result<WorkflowSettings> {
let layer = source
.parse::<SettingsLayer>()
.map_err(|err| Error::parse("Failed to parse settings file", err))?;
Self::from_layer(&layer)
.map_err(|errors| Error::resolve("failed to resolve workflow settings", errors.into()))
}
#[must_use]
pub(crate) fn args_layer(mut self, layer: SettingsLayer) -> Self {
self.args = layer.combine(self.args);
self
}
#[must_use]
pub(crate) fn workflow_layer(mut self, layer: SettingsLayer) -> Self {
self.workflow = layer;
self
}
#[must_use]
pub fn workflow_run_layer(self, run: RunLayer) -> Self {
self.workflow_layer(SettingsLayer {
run: Some(run),
..SettingsLayer::default()
})
}
pub fn workflow_toml(self, source: &str) -> Result<Self> {
let layer = source
.parse::<SettingsLayer>()
.map_err(|err| Error::parse("Failed to parse settings file", err))?;
Ok(self.workflow_layer(layer))
}
pub fn workflow_file(self, path: &Path) -> Result<Self> {
Ok(self.workflow_layer(run::load_run_config(path)?))
}
#[must_use]
pub(crate) fn project_layer(mut self, layer: SettingsLayer) -> Self {
self.project = layer;
self
}
pub fn project_toml(self, source: &str) -> Result<Self> {
let layer = source
.parse::<SettingsLayer>()
.map_err(|err| Error::parse("Failed to parse settings file", err))?;
Ok(self.project_layer(layer))
}
pub fn project_file(self, path: &Path) -> Result<Self> {
Ok(self.project_layer(load_settings_path(path)?))
}
#[must_use]
pub(crate) fn user_layer(mut self, layer: SettingsLayer) -> Self {
self.user = layer;
self
}
pub fn user_toml(self, source: &str) -> Result<Self> {
let layer = source
.parse::<SettingsLayer>()
.map_err(|err| Error::parse("Failed to parse settings file", err))?;
Ok(self.user_layer(layer))
}
pub fn user_file(self, path: &Path) -> Result<Self> {
Ok(self.user_layer(load_settings_path(path)?))
}
#[must_use]
pub(crate) fn server_layer(mut self, layer: SettingsLayer) -> Self {
self.server = layer;
self
}
#[must_use]
pub fn server_run_defaults(self, run: RunLayer) -> Self {
self.server_layer(SettingsLayer {
run: Some(run),
..SettingsLayer::default()
})
}
#[must_use]
pub fn run_overrides(self, run: RunLayer) -> Self {
self.args_layer(SettingsLayer {
run: Some(run),
..SettingsLayer::default()
})
}
#[must_use]
pub fn cli_overrides(self, cli: CliLayer) -> Self {
self.args_layer(SettingsLayer {
cli: Some(cli),
..SettingsLayer::default()
})
}
#[must_use]
pub(crate) fn build_layer(self) -> SettingsLayer {
let server_defaults = SettingsLayer {
version: self.server.version,
run: self.server.run,
..SettingsLayer::default()
};
let mut layer = self
.args
.combine(self.workflow)
.combine(self.project)
.combine(self.user)
.combine(server_defaults);
layer = layer.combine(DEFAULTS_LAYER.clone());
layer.server = None;
layer.cli = None;
layer.features = None;
layer
}
pub fn build(self) -> std::result::Result<WorkflowSettings, ResolveErrors> {
Self::from_layer(&self.build_layer())
}
pub(crate) fn from_layer(
layer: &SettingsLayer,
) -> std::result::Result<WorkflowSettings, ResolveErrors> {
let layer = layer.clone().combine(DEFAULTS_LAYER.clone());
let mut errors = Vec::new();
let project = resolve_project(&layer.project.clone().unwrap_or_default(), &mut errors);
let workflow = resolve_workflow(&layer.workflow.clone().unwrap_or_default(), &mut errors);
let run = resolve_run(&layer.run.clone().unwrap_or_default(), &mut errors);
finish_dense_result(
WorkflowSettings {
project,
workflow,
run,
},
errors,
)
}
pub(crate) fn project_from_layer(
layer: &SettingsLayer,
) -> std::result::Result<ProjectNamespace, ResolveErrors> {
let layer = layer.clone().combine(DEFAULTS_LAYER.clone());
let mut errors = Vec::new();
let project = resolve_project(&layer.project.clone().unwrap_or_default(), &mut errors);
finish_dense_result(project, errors)
}
pub(crate) fn workflow_from_layer(
layer: &SettingsLayer,
) -> std::result::Result<WorkflowNamespace, ResolveErrors> {
let layer = layer.clone().combine(DEFAULTS_LAYER.clone());
let mut errors = Vec::new();
let workflow = resolve_workflow(&layer.workflow.clone().unwrap_or_default(), &mut errors);
finish_dense_result(workflow, errors)
}
}
fn finish_result<T>(value: T, context: &'static str, errors: Vec<ResolveError>) -> Result<T> {
if errors.is_empty() {
Ok(value)
} else {
Err(Error::resolve(context, errors))
}
}
fn finish_dense_result<T>(
value: T,
errors: Vec<ResolveError>,
) -> std::result::Result<T, ResolveErrors> {
if errors.is_empty() {
Ok(value)
} else {
Err(errors.into())
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use fabro_types::settings::InterpString;
use fabro_types::settings::cli::OutputVerbosity;
use fabro_types::settings::run::{ApprovalMode, RunMode};
use super::{RunSettingsBuilder, WorkflowSettingsBuilder};
use crate::{CliLayer, CliOutputLayer, ReplaceMap, RunExecutionLayer, RunLayer, RunModelLayer};
#[test]
fn run_settings_builder_resolves_run_namespace() {
let settings = RunSettingsBuilder::from_toml(
r#"
_version = 1
[run.execution]
mode = "dry_run"
[run.agent.mcps.demo]
type = "stdio"
command = ["demo-mcp"]
"#,
)
.expect("run settings should resolve");
assert_eq!(settings.execution.mode, RunMode::DryRun);
assert!(settings.agent.mcps.contains_key("demo"));
}
#[test]
fn workflow_builder_preserves_run_overrides_when_cli_overrides_are_added() {
let settings = WorkflowSettingsBuilder::new()
.run_overrides(RunLayer {
metadata: ReplaceMap::from(HashMap::from([("env".to_string(), "cli".to_string())])),
model: Some(RunModelLayer {
provider: Some(InterpString::parse("openai")),
name: Some(InterpString::parse("gpt-5")),
fallbacks: Vec::new(),
}),
execution: Some(RunExecutionLayer {
mode: Some(RunMode::DryRun),
approval: Some(ApprovalMode::Auto),
retros: Some(false),
}),
..RunLayer::default()
})
.cli_overrides(CliLayer {
output: Some(CliOutputLayer {
verbosity: Some(OutputVerbosity::Verbose),
..CliOutputLayer::default()
}),
..CliLayer::default()
})
.build()
.expect("settings should resolve");
assert_eq!(
settings.run.metadata.get("env").map(String::as_str),
Some("cli")
);
assert_eq!(
settings
.run
.model
.provider
.as_ref()
.map(InterpString::as_source),
Some("openai".to_string())
);
assert_eq!(
settings
.run
.model
.name
.as_ref()
.map(InterpString::as_source),
Some("gpt-5".to_string())
);
assert_eq!(settings.run.execution.mode, RunMode::DryRun);
assert_eq!(settings.run.execution.approval, ApprovalMode::Auto);
assert!(!settings.run.execution.retros);
}
}

View file

@ -1,96 +0,0 @@
use std::collections::HashMap;
use fabro_types::settings::{
CliNamespace, FeaturesNamespace, ProjectNamespace, RunNamespace, ServerNamespace,
SettingsLayer, WorkflowNamespace,
};
use serde::{Deserialize, Serialize};
use crate::user::load_settings_config;
use crate::{
Error, ResolveError, Result, apply_builtin_defaults, resolve_cli, resolve_features,
resolve_project, resolve_run, resolve_server, resolve_workflow,
};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ServerSettings {
pub server: ServerNamespace,
pub features: FeaturesNamespace,
}
impl ServerSettings {
pub fn from_layer(layer: &SettingsLayer) -> Result<Self> {
let layer = apply_builtin_defaults(layer.clone());
let mut errors = Vec::new();
let server = resolve_server(&layer.server.clone().unwrap_or_default(), &mut errors);
let features = resolve_features(&layer.features.clone().unwrap_or_default(), &mut errors);
if errors.is_empty() {
Ok(Self { server, features })
} else {
Err(Error::resolve("failed to resolve server settings", errors))
}
}
pub fn resolve() -> Result<Self> {
let layer = load_settings_config(None)?;
Self::from_layer(&layer)
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct UserSettings {
pub cli: CliNamespace,
pub features: FeaturesNamespace,
}
impl UserSettings {
pub fn from_layer(layer: &SettingsLayer) -> Result<Self> {
let layer = apply_builtin_defaults(layer.clone());
let mut errors = Vec::new();
let cli = resolve_cli(&layer.cli.clone().unwrap_or_default(), &mut errors);
let features = resolve_features(&layer.features.clone().unwrap_or_default(), &mut errors);
if errors.is_empty() {
Ok(Self { cli, features })
} else {
Err(Error::resolve("failed to resolve user settings", errors))
}
}
pub fn resolve() -> Result<Self> {
let layer = load_settings_config(None)?;
Self::from_layer(&layer)
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct WorkflowSettings {
pub project: ProjectNamespace,
pub workflow: WorkflowNamespace,
pub run: RunNamespace,
}
impl WorkflowSettings {
pub fn from_layer(layer: &SettingsLayer) -> std::result::Result<Self, Vec<ResolveError>> {
let layer = apply_builtin_defaults(layer.clone());
let mut errors = Vec::new();
let project = resolve_project(&layer.project.clone().unwrap_or_default(), &mut errors);
let workflow = resolve_workflow(&layer.workflow.clone().unwrap_or_default(), &mut errors);
let run = resolve_run(&layer.run.clone().unwrap_or_default(), &mut errors);
if errors.is_empty() {
Ok(Self {
project,
workflow,
run,
})
} else {
Err(errors)
}
}
pub fn combined_labels(&self) -> HashMap<String, String> {
let mut labels = self.project.metadata.clone();
labels.extend(self.workflow.metadata.clone());
labels.extend(self.run.metadata.clone());
labels
}
}

View file

@ -1,20 +1,9 @@
use std::sync::LazyLock;
use fabro_types::settings::{Combine, SettingsLayer};
use crate::SettingsLayer;
use crate::parse_settings_layer;
static DEFAULTS_LAYER: LazyLock<SettingsLayer> = LazyLock::new(|| {
parse_settings_layer(include_str!("defaults.toml"))
pub(crate) static DEFAULTS_LAYER: LazyLock<SettingsLayer> = LazyLock::new(|| {
include_str!("defaults.toml")
.parse::<SettingsLayer>()
.expect("embedded defaults.toml must parse as a valid SettingsLayer")
});
#[must_use]
pub fn defaults_layer() -> &'static SettingsLayer {
&DEFAULTS_LAYER
}
#[must_use]
pub fn apply_builtin_defaults(layer: SettingsLayer) -> SettingsLayer {
layer.combine(defaults_layer().clone())
}

View file

@ -1,394 +0,0 @@
//! Effective settings resolution: combine layers into one resolved
//! [`SettingsLayer`].
//!
//! Shared layered domains (`project`, `workflow`, `run`, `features`) merge
//! across all three config files (settings.toml, .fabro/project.toml,
//! workflow.toml).
//! Owner-specific domains (`cli`, `server`) are consumed only from the local
//! `~/.fabro/settings.toml` plus explicit process-local overrides. Their
//! stanzas in `.fabro/project.toml` and `workflow.toml` remain schema-valid but
//! inert.
use fabro_types::settings::run::{RunExecutionLayer, RunLayer};
use fabro_types::settings::server::ServerLayer;
use fabro_types::settings::{Combine, SettingsLayer};
use crate::{Error, Result, apply_builtin_defaults};
#[derive(Clone, Debug, Default)]
pub struct EffectiveSettingsLayers {
pub args: SettingsLayer,
pub workflow: SettingsLayer,
pub project: SettingsLayer,
pub user: SettingsLayer,
}
impl EffectiveSettingsLayers {
#[must_use]
pub fn new(
args: SettingsLayer,
workflow: SettingsLayer,
project: SettingsLayer,
user: SettingsLayer,
) -> Self {
Self {
args,
workflow,
project,
user,
}
}
}
/// Materialize layered configuration down to a single effective
/// [`SettingsLayer`].
///
/// Precedence, lowest to highest:
///
/// 1. `server_settings.run` / `server_settings.features` — the server's
/// `~/.fabro/settings.toml` contributes its run-level defaults (for example,
/// a server-wide `run.execution.mode = "dry_run"`). Client layers win when
/// they set the same field.
/// 2. `user` — the manifest's `User` configs.
/// 3. `project` — the manifest's `Project` configs, with `cli`/`server`
/// stripped.
/// 4. `workflow` — the manifest's workflow config, with `cli`/`server`
/// stripped.
/// 5. `args` — process-local CLI overrides.
/// 6. A small subset of `server_settings.server` (storage, scheduler,
/// artifacts, web, api) plus `server_settings.features` are applied
/// authoritatively on top. The remaining server-ops fields (listen, auth,
/// ip_allowlist, slatedb, logging, integrations) stay on the server and do
/// not flow into the run's persisted settings — callers that need them
/// should consult the server's resolved settings directly.
pub fn materialize_settings_layer(
layers: EffectiveSettingsLayers,
server_settings: Option<&SettingsLayer>,
) -> Result<SettingsLayer> {
let EffectiveSettingsLayers {
args,
mut workflow,
mut project,
user,
} = layers;
let server_settings = server_settings.ok_or(Error::MissingServerSettings)?;
// Owner-specific domains (cli, server) may only come from the local
// ~/.fabro/settings.toml, never from .fabro/project.toml or workflow.toml.
// The user layer keeps its cli/server fields.
strip_owner_domains(&mut workflow);
strip_owner_domains(&mut project);
// Server's run/features stanzas act as base defaults; client layers win.
// Server's server/cli stanzas are handled authoritatively below.
let mut server_defaults = server_settings.clone();
server_defaults.cli = None;
server_defaults.server = None;
let combined = args
.combine(workflow)
.combine(project)
.combine(user)
.combine(server_defaults);
let settings = enforce_server_authority(combined, server_settings);
Ok(apply_builtin_defaults(settings))
}
fn strip_owner_domains(file: &mut SettingsLayer) {
file.cli = None;
file.server = None;
}
/// Apply server-owned fields on top of a client-combined [`SettingsLayer`].
///
/// Only the fields runs genuinely need are copied from the server:
/// `storage`, `scheduler`, `artifacts`, `web`, `api`. Operational config
/// (`listen`, `auth`, `ip_allowlist`, `slatedb`, `logging`, `integrations`)
/// stays on the server — callers that need those fields should read
/// `AppState::server_settings()` rather than re-resolving from run settings.
fn enforce_server_authority(mut settings: SettingsLayer, server: &SettingsLayer) -> SettingsLayer {
if let Some(server_layer) = server.server.clone() {
let client = settings.server.get_or_insert_with(ServerLayer::default);
if let Some(storage) = server_layer.storage {
client.storage = Some(storage);
}
if let Some(scheduler) = server_layer.scheduler {
client.scheduler = Some(scheduler);
}
if let Some(artifacts) = server_layer.artifacts {
client.artifacts = Some(artifacts);
}
if let Some(web) = server_layer.web {
client.web = Some(web);
}
if let Some(api) = server_layer.api {
client.api = Some(api);
}
}
if let Some(features) = server.features.clone() {
settings.features = Some(features);
}
// Ensure a run.execution table exists so downstream consumers that check
// for explicit dry-run defaults see a well-formed layer.
settings
.run
.get_or_insert_with(RunLayer::default)
.execution
.get_or_insert_with(RunExecutionLayer::default);
settings
}
#[cfg(test)]
mod tests {
use fabro_types::settings::cli::OutputFormat;
use fabro_types::settings::run::{ApprovalMode, RunGoalLayer};
use fabro_types::settings::server::{ServerLayer, ServerSchedulerLayer, ServerStorageLayer};
use fabro_types::settings::{InterpString, SettingsLayer};
use super::{EffectiveSettingsLayers, materialize_settings_layer};
use crate::parse::parse_settings_layer;
fn layer(source: &str) -> SettingsLayer {
parse_settings_layer(source).expect("v2 fixture should parse")
}
#[test]
fn materialize_settings_layer_merges_layers_and_applies_server_authority() {
let settings = materialize_settings_layer(
EffectiveSettingsLayers::new(
SettingsLayer::default(),
SettingsLayer::default(),
layer(
r#"
_version = 1
[run.model]
name = "project-model"
[run.inputs]
project_only = "1"
shared = "project"
"#,
),
layer(
r#"
_version = 1
[server.storage]
root = "/tmp/local-storage"
[run.model]
provider = "openai"
[run.inputs]
user_only = "1"
shared = "user"
"#,
),
),
Some(&layer(
r#"
_version = 1
[server.storage]
root = "/srv/fabro"
[server.scheduler]
max_concurrent_runs = 7
"#,
)),
)
.unwrap();
assert_eq!(
settings
.run
.as_ref()
.and_then(|run| run.model.as_ref())
.and_then(|model| model.name.as_ref())
.map(InterpString::as_source)
.as_deref(),
Some("project-model")
);
// Per R22, run.inputs replaces wholesale. The winning layer is the
// highest-precedence layer that sets `inputs` (project here, since it
// wins over user).
let inputs = settings
.run
.as_ref()
.and_then(|run| run.inputs.as_ref())
.unwrap();
assert!(inputs.contains_key("project_only"));
assert_eq!(
inputs.get("shared").and_then(|value| value.as_str()),
Some("project")
);
assert!(
!inputs.contains_key("user_only"),
"project.inputs should replace user.inputs wholesale"
);
assert_eq!(
settings
.server
.as_ref()
.and_then(|server| server.storage.as_ref())
.and_then(|storage| storage.root.as_ref())
.map(InterpString::as_source)
.as_deref(),
Some("/srv/fabro")
);
assert_eq!(
settings
.server
.as_ref()
.and_then(|server| server.scheduler.as_ref())
.and_then(|scheduler| scheduler.max_concurrent_runs),
Some(7)
);
assert_eq!(
settings
.project
.as_ref()
.and_then(|project| project.directory.as_deref()),
Some(".")
);
assert_eq!(
settings
.workflow
.as_ref()
.and_then(|workflow| workflow.graph.as_deref()),
Some("workflow.fabro")
);
assert_eq!(
settings
.run
.as_ref()
.and_then(|run| run.execution.as_ref())
.and_then(|execution| execution.approval),
Some(ApprovalMode::Prompt)
);
}
#[test]
fn materialize_settings_layer_preserves_client_values_with_empty_server_layer() {
let settings = materialize_settings_layer(
EffectiveSettingsLayers::new(
SettingsLayer::default(),
layer(
r#"
_version = 1
[run]
goal = "workflow goal"
[run.model]
name = "workflow-model"
"#,
),
layer(
r#"
_version = 1
[run.model]
name = "project-model"
"#,
),
layer(
r#"
_version = 1
[run.model]
provider = "openai"
"#,
),
),
Some(&SettingsLayer::default()),
)
.unwrap();
assert_eq!(
match settings.run.as_ref().and_then(|run| run.goal.as_ref()) {
Some(RunGoalLayer::Inline(value)) => Some(value.as_source()),
_ => None,
}
.as_deref(),
Some("workflow goal")
);
assert_eq!(
settings
.run
.as_ref()
.and_then(|run| run.model.as_ref())
.and_then(|model| model.name.as_ref())
.map(InterpString::as_source)
.as_deref(),
Some("workflow-model")
);
assert_eq!(
settings
.run
.as_ref()
.and_then(|run| run.model.as_ref())
.and_then(|model| model.provider.as_ref())
.map(InterpString::as_source)
.as_deref(),
Some("openai")
);
}
#[test]
fn materialize_settings_layer_applies_server_owned_overrides() {
let server_settings = SettingsLayer {
server: Some(ServerLayer {
storage: Some(ServerStorageLayer {
root: Some(InterpString::parse("/srv/fabro")),
}),
scheduler: Some(ServerSchedulerLayer {
max_concurrent_runs: Some(7),
}),
..ServerLayer::default()
}),
..SettingsLayer::default()
};
let settings =
materialize_settings_layer(EffectiveSettingsLayers::default(), Some(&server_settings))
.unwrap();
assert_eq!(
settings
.server
.as_ref()
.and_then(|server| server.storage.as_ref())
.and_then(|storage| storage.root.as_ref())
.map(InterpString::as_source)
.as_deref(),
Some("/srv/fabro")
);
assert_eq!(
settings
.server
.as_ref()
.and_then(|server| server.scheduler.as_ref())
.and_then(|scheduler| scheduler.max_concurrent_runs),
Some(7)
);
assert_eq!(
settings
.run
.as_ref()
.and_then(|run| run.sandbox.as_ref())
.and_then(|sandbox| sandbox.provider.as_deref()),
Some("local")
);
assert_eq!(
settings
.cli
.as_ref()
.and_then(|cli| cli.output.as_ref())
.and_then(|output| output.format),
Some(OutputFormat::Text)
);
}
}

View file

@ -0,0 +1,108 @@
//! Sparse `[cli]` settings layer definitions.
use fabro_types::settings::InterpString;
use fabro_types::settings::cli::{CliAuthStrategy, OutputFormat, OutputVerbosity};
use fabro_types::settings::run::AgentPermissions;
use serde::{Deserialize, Serialize};
use super::maps::StickyMap;
use super::run::McpEntryLayer;
/// A sparse `[cli]` layer as it appears in a single settings file.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
pub struct CliLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target: Option<CliTargetLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth: Option<CliAuthLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exec: Option<CliExecLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output: Option<CliOutputLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub updates: Option<CliUpdatesLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub logging: Option<CliLoggingLayer>,
}
/// `[cli.target]` — explicit transport selection.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, tag = "type", rename_all = "lowercase")]
pub enum CliTargetLayer {
Http {
#[serde(default)]
url: Option<InterpString>,
},
Unix {
#[serde(default)]
path: Option<InterpString>,
},
}
/// `[cli.auth]` — explicit auth strategy selection.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CliAuthLayer {
/// `none` explicitly disables inherited auth.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub strategy: Option<CliAuthStrategy>,
}
/// `[cli.exec]` — `fabro exec` defaults.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
pub struct CliExecLayer {
/// Prevent idle sleep on macOS while an exec run is in flight.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prevent_idle_sleep: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<CliExecModelLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent: Option<CliExecAgentLayer>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
pub struct CliExecModelLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<InterpString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<InterpString>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
pub struct CliExecAgentLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub permissions: Option<AgentPermissions>,
/// Agent-scoped MCP entries for `fabro exec`.
#[serde(default, skip_serializing_if = "StickyMap::is_empty")]
pub mcps: StickyMap<McpEntryLayer>,
}
/// `[cli.output]` — generic CLI output defaults.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
pub struct CliOutputLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub format: Option<OutputFormat>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub verbosity: Option<OutputVerbosity>,
}
/// `[cli.updates]` — upgrade check toggle.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
pub struct CliUpdatesLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub check: Option<bool>,
}
/// `[cli.logging]` — process-owned logging configuration for the CLI.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CliLoggingLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub level: Option<String>,
}

View file

@ -1,25 +1,31 @@
use std::collections::HashMap;
use super::cli::{
CliAuthLayer, CliAuthStrategy, CliLoggingLayer, CliTargetLayer, OutputFormat, OutputVerbosity,
use fabro_types::settings::cli::{CliAuthStrategy, OutputFormat, OutputVerbosity};
use fabro_types::settings::run::{
AgentPermissions, ApprovalMode, DaytonaNetworkLayer, MergeStrategy, RunMode, WorktreeMode,
};
use super::duration::Duration;
use fabro_types::settings::server::{
GithubIntegrationStrategy, ObjectStoreProvider, ServerAuthMethod, WebhookStrategy,
};
use fabro_types::settings::{Duration, InterpString, Size};
use super::cli::{CliAuthLayer, CliLoggingLayer, CliTargetLayer};
use super::features::FeaturesLayer;
use super::interp::InterpString;
use super::run::{
AgentPermissions, ApprovalMode, DaytonaNetworkLayer, DaytonaSnapshotLayer, HookAgentMarker,
HookEntry, HookTlsMode, InterviewProviderLayer, LocalSandboxLayer, MergeStrategy,
ModelRefOrSplice, NotificationProviderLayer, RunArtifactsLayer, RunCheckpointLayer,
RunGoalLayer, RunMode, RunPrepareLayer, ScmGitHubLayer, StringOrSplice, WorktreeMode,
DaytonaSnapshotLayer, HookAgentMarker, HookEntry, HookTlsMode, InterviewProviderLayer,
LocalSandboxLayer, ModelRefOrSplice, NotificationProviderLayer, RunArtifactsLayer,
RunCheckpointLayer, RunGoalLayer, RunPrepareLayer, ScmGitHubLayer, StringOrSplice,
};
use super::server::{
GithubIntegrationStrategy, ObjectStoreLocalLayer, ObjectStoreProvider, ObjectStoreS3Layer,
ServerApiLayer, ServerAuthGithubLayer, ServerAuthMethod, ServerListenLayer, ServerLoggingLayer,
WebhookStrategy,
ObjectStoreLocalLayer, ObjectStoreS3Layer, ServerApiLayer, ServerAuthGithubLayer,
ServerListenLayer, ServerLoggingLayer,
};
use super::size::Size;
pub trait Combine {
/// Internal merge trait used by sparse config layers inside `fabro-config`.
///
/// The `fabro_macros::Combine` derive expands against this trait via an
/// absolute path, so deriving `Combine` only works for types defined here.
pub(crate) trait Combine {
/// Combine two values, preferring the values in `self`.
#[must_use]
fn combine(self, other: Self) -> Self;
@ -137,7 +143,7 @@ impl Combine for RunCheckpointLayer {
/// An element of a splice-aware sequence: either a regular value or the
/// `...` marker that asks the combiner to expand the fallback list inline.
pub trait SpliceMarker {
trait SpliceMarker {
fn is_splice(&self) -> bool;
}

View file

@ -0,0 +1,14 @@
//! Sparse `[features]` settings layer definitions.
use serde::{Deserialize, Serialize};
/// A sparse `[features]` layer as it appears in a single settings file.
///
/// Every field is an `Option<bool>` so layers can independently set or
/// override a flag without forcing a default that hides an unset value.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FeaturesLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_sandboxes: Option<bool>,
}

View file

@ -0,0 +1,37 @@
mod cli;
mod combine;
mod features;
mod maps;
mod project;
mod run;
mod server;
mod settings;
mod splice_array;
mod workflow;
pub use cli::{
CliAuthLayer, CliExecAgentLayer, CliExecLayer, CliExecModelLayer, CliLayer, CliLoggingLayer,
CliOutputLayer, CliTargetLayer, CliUpdatesLayer,
};
pub(crate) use combine::Combine;
pub use features::FeaturesLayer;
pub use maps::{MergeMap, ReplaceMap, StickyMap};
pub use project::ProjectLayer;
pub use run::{
DaytonaDockerfileLayer, DaytonaSandboxLayer, DaytonaSnapshotLayer, GitAuthorLayer,
HookAgentMarker, HookEntry, HookTlsMode, InterviewProviderLayer, InterviewsLayer,
LocalSandboxLayer, McpEntryLayer, ModelRefOrSplice, NotificationProviderLayer,
NotificationRouteLayer, PrepareStep, RunAgentLayer, RunArtifactsLayer, RunCheckpointLayer,
RunExecutionLayer, RunGitLayer, RunGoalLayer, RunLayer, RunModelLayer, RunPrepareLayer,
RunPullRequestLayer, RunSandboxLayer, RunScmLayer, ScmGitHubLayer, StringOrSplice,
};
pub use server::{
DiscordIntegrationLayer, GithubIntegrationLayer, IntegrationWebhooksLayer,
ObjectStoreLocalLayer, ObjectStoreS3Layer, ServerApiLayer, ServerArtifactsLayer,
ServerAuthGithubLayer, ServerAuthLayer, ServerIntegrationsLayer, ServerIpAllowlistLayer,
ServerIpAllowlistOverrideLayer, ServerLayer, ServerListenLayer, ServerLoggingLayer,
ServerSchedulerLayer, ServerSlateDbLayer, ServerStorageLayer, ServerWebLayer,
SlackIntegrationLayer, TeamsIntegrationLayer,
};
pub(crate) use settings::SettingsLayer;
pub use workflow::WorkflowLayer;

View file

@ -0,0 +1,21 @@
//! Sparse `[project]` settings layer definitions.
use serde::{Deserialize, Serialize};
use super::maps::ReplaceMap;
/// A sparse `[project]` layer as it appears in a single settings file.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
pub struct ProjectLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// The Fabro-managed project directory inside the repo. Defaults to
/// `.` after layering when unspecified.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub directory: Option<String>,
#[serde(default, skip_serializing_if = "ReplaceMap::is_empty")]
pub metadata: ReplaceMap<String>,
}

View file

@ -0,0 +1,492 @@
//! Sparse `[run]` settings layer definitions.
use std::collections::HashMap;
use fabro_types::settings::run::{
AgentPermissions, ApprovalMode, DaytonaNetworkLayer, HookEvent, MergeStrategy, RunMode,
WorktreeMode,
};
use fabro_types::settings::{Duration, InterpString, ModelRef, Size};
use serde::{Deserialize, Serialize};
use super::maps::{MergeMap, ReplaceMap, StickyMap};
use super::splice_array::SPLICE_MARKER;
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
pub struct RunLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub goal: Option<RunGoalLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub working_dir: Option<InterpString>,
/// Flat string-to-string map. Replaces wholesale across layers.
#[serde(default, skip_serializing_if = "ReplaceMap::is_empty")]
pub metadata: ReplaceMap<String>,
/// Run inputs: typed scalar values. Replaces wholesale across layers.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub inputs: Option<HashMap<String, toml::Value>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<RunModelLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub git: Option<RunGitLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prepare: Option<RunPrepareLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub execution: Option<RunExecutionLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub checkpoint: Option<RunCheckpointLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sandbox: Option<RunSandboxLayer>,
#[serde(default, skip_serializing_if = "MergeMap::is_empty")]
pub notifications: MergeMap<NotificationRouteLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub interviews: Option<InterviewsLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent: Option<RunAgentLayer>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub hooks: Vec<HookEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scm: Option<RunScmLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pull_request: Option<RunPullRequestLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub artifacts: Option<RunArtifactsLayer>,
}
/// The source of a run's goal, either inline literal text or a reference to
/// a file on disk.
///
/// TOML surface:
///
/// ```toml
/// # Inline form
/// [run]
/// goal = "Diagnose and fix CI build failures"
///
/// # File form
/// [run.goal]
/// file = "prompts/fix_build.md"
/// ```
///
/// Relative paths inside the `file` variant are resolved against the
/// directory of the config file that declared them at load time (see
/// `fabro_config::resolve_goal_file_paths`). `{{ env.NAME }}` interpolation is
/// supported inside the `file` path; env-tokenized relative paths stay
/// unresolved until consume time and are then resolved against the run's
/// effective working directory.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged, deny_unknown_fields)]
pub enum RunGoalLayer {
Inline(InterpString),
File { file: InterpString },
}
/// `[run.model]` — provider-neutral default model selection.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
pub struct RunModelLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<InterpString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<InterpString>,
/// Ordered list of fallback model references. Supports `...` splice marker
/// at layering time — see [`super::splice_array`].
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub fallbacks: Vec<ModelRefOrSplice>,
}
/// A single `fallbacks` entry: either a parsed `ModelRef` or the splice marker.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ModelRefOrSplice {
ModelRef(ModelRef),
Splice,
}
impl Serialize for ModelRefOrSplice {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
match self {
Self::ModelRef(m) => m.serialize(serializer),
Self::Splice => serializer.serialize_str(SPLICE_MARKER),
}
}
}
impl<'de> Deserialize<'de> for ModelRefOrSplice {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
use serde::de::Error;
let raw = String::deserialize(deserializer)?;
if raw == SPLICE_MARKER {
return Ok(Self::Splice);
}
let model = raw.parse::<ModelRef>().map_err(D::Error::custom)?;
Ok(Self::ModelRef(model))
}
}
/// `[run.git]` — local git behavior such as commit author.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
pub struct RunGitLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub author: Option<GitAuthorLayer>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
pub struct GitAuthorLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<InterpString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub email: Option<InterpString>,
}
/// `[run.prepare]` — ordered list of preparation steps. Whole list replaces
/// across layers.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RunPrepareLayer {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub steps: Vec<PrepareStep>,
/// Optional timeout applied to each prepare step.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout: Option<Duration>,
}
/// A single prepare step. Exactly one of `script` or `command` must be set.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PrepareStep {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub script: Option<InterpString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub command: Option<Vec<InterpString>>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub env: HashMap<String, InterpString>,
}
/// `[run.execution]` — run posture knobs.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
pub struct RunExecutionLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mode: Option<RunMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub approval: Option<ApprovalMode>,
/// Positive-form: `true` runs retros, `false` skips them.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retros: Option<bool>,
}
/// `[run.checkpoint]` — checkpoint policy.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RunCheckpointLayer {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub exclude_globs: Vec<String>,
}
/// `[run.sandbox]` — sandbox selection and execution-environment surface.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
pub struct RunSandboxLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preserve: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub devcontainer: Option<bool>,
/// Sticky merge-by-key across layers.
#[serde(default, skip_serializing_if = "StickyMap::is_empty")]
pub env: StickyMap<InterpString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub local: Option<LocalSandboxLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub daytona: Option<DaytonaSandboxLayer>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LocalSandboxLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worktree_mode: Option<WorktreeMode>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
pub struct DaytonaSandboxLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_stop_interval: Option<i32>,
/// Sticky merge-by-key (provider-native labels).
#[serde(default, skip_serializing_if = "StickyMap::is_empty")]
pub labels: StickyMap<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub snapshot: Option<DaytonaSnapshotLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub network: Option<DaytonaNetworkLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub skip_clone: Option<bool>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DaytonaSnapshotLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cpu: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memory: Option<Size>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub disk: Option<Size>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dockerfile: Option<DaytonaDockerfileLayer>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged, deny_unknown_fields)]
pub enum DaytonaDockerfileLayer {
Inline(String),
Path { path: String },
}
/// `[run.notifications.<name>]` — a keyed notification route.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
pub struct NotificationRouteLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
/// Raw Fabro event names. Splice marker supported at layering time.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub events: Vec<StringOrSplice>,
/// Provider-specific destination subtables. First-pass chat providers.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub slack: Option<NotificationProviderLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub discord: Option<NotificationProviderLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub teams: Option<NotificationProviderLayer>,
}
/// A single string array entry that may be the splice marker.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StringOrSplice {
Value(String),
Splice,
}
impl Serialize for StringOrSplice {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
match self {
Self::Value(s) => serializer.serialize_str(s),
Self::Splice => serializer.serialize_str(SPLICE_MARKER),
}
}
}
impl<'de> Deserialize<'de> for StringOrSplice {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
if s == SPLICE_MARKER {
Ok(Self::Splice)
} else {
Ok(Self::Value(s))
}
}
}
/// Provider-specific destination fields for a notification route.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct NotificationProviderLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub channel: Option<InterpString>,
}
/// `[run.interviews]` — external interview delivery.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
pub struct InterviewsLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub slack: Option<InterviewProviderLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub discord: Option<InterviewProviderLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub teams: Option<InterviewProviderLayer>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct InterviewProviderLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub channel: Option<InterpString>,
}
/// `[run.agent]` — agent knobs only (permissions, MCPs).
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
pub struct RunAgentLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub permissions: Option<AgentPermissions>,
/// Agent-scoped MCP server entries, keyed by name.
#[serde(default, skip_serializing_if = "StickyMap::is_empty")]
pub mcps: StickyMap<McpEntryLayer>,
}
/// A single MCP entry. `type` selects the transport; `script`/`command` are
/// mutually exclusive for process-launching transports. Non-launching HTTP
/// transports use neither field.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, tag = "type", rename_all = "snake_case")]
pub enum McpEntryLayer {
Http {
#[serde(default)]
enabled: Option<bool>,
url: InterpString,
#[serde(default)]
headers: HashMap<String, InterpString>,
#[serde(default)]
startup_timeout: Option<Duration>,
#[serde(default)]
tool_timeout: Option<Duration>,
},
Stdio {
#[serde(default)]
enabled: Option<bool>,
#[serde(default)]
script: Option<InterpString>,
#[serde(default)]
command: Option<Vec<InterpString>>,
#[serde(default)]
env: HashMap<String, InterpString>,
#[serde(default)]
startup_timeout: Option<Duration>,
#[serde(default)]
tool_timeout: Option<Duration>,
},
Sandbox {
#[serde(default)]
enabled: Option<bool>,
#[serde(default)]
script: Option<InterpString>,
#[serde(default)]
command: Option<Vec<InterpString>>,
port: u16,
#[serde(default)]
env: HashMap<String, InterpString>,
#[serde(default)]
startup_timeout: Option<Duration>,
#[serde(default)]
tool_timeout: Option<Duration>,
},
}
/// A run hook entry. Exactly one of `script`, `command`, `url`, `prompt`, or
/// `agent` fields determines the hook behavior. The `id` field, when set, is
/// used for cross-layer replace-by-id merging.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct HookEntry {
/// Optional merge identity. Hooks with the same `id` replace in place.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
/// Display-only human name.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
pub event: HookEvent,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub matcher: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub blocking: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout: Option<Duration>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sandbox: Option<bool>,
// Exactly one of the following groups is expected:
#[serde(default, skip_serializing_if = "Option::is_none")]
pub script: Option<InterpString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub command: Option<Vec<InterpString>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<InterpString>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub headers: HashMap<String, InterpString>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allowed_env_vars: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tls: Option<HookTlsMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prompt: Option<InterpString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<InterpString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_tool_rounds: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent: Option<HookAgentMarker>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HookTlsMode {
#[default]
Verify,
NoVerify,
Off,
}
/// Reserved marker for hook entries that use the `agent` hook type. Having
/// this as its own field rather than a flag lets `HookEntry` remain a flat
/// struct without a discriminator.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HookAgentMarker {
#[default]
Enabled,
}
/// `[run.scm]` — remote SCM host/provider behavior.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
pub struct RunScmLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub owner: Option<InterpString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repository: Option<InterpString>,
/// Provider-specific SCM leaves. First-pass providers.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub github: Option<ScmGitHubLayer>,
}
/// `[run.scm.github]` — GitHub-specific SCM leaf. Intentionally minimal in
/// the first pass; additional branch/checkout context stays on `run` or
/// `run.pull_request` until a concrete use case lands.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ScmGitHubLayer;
/// `[run.pull_request]` — provider-neutral PR behavior.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
pub struct RunPullRequestLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub draft: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_merge: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub merge_strategy: Option<MergeStrategy>,
}
/// `[run.artifacts]` — run artifact collection policy.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RunArtifactsLayer {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub include: Vec<String>,
}

View file

@ -0,0 +1,260 @@
//! Sparse `[server]` settings layer definitions.
use fabro_types::settings::server::{
GithubIntegrationStrategy, ObjectStoreProvider, ServerAuthMethod, WebhookStrategy,
};
use fabro_types::settings::{Duration, InterpString};
use serde::{Deserialize, Serialize};
use super::maps::StickyMap;
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
pub struct ServerLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub listen: Option<ServerListenLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api: Option<ServerApiLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub web: Option<ServerWebLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth: Option<ServerAuthLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ip_allowlist: Option<ServerIpAllowlistLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub storage: Option<ServerStorageLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub artifacts: Option<ServerArtifactsLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub slatedb: Option<ServerSlateDbLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scheduler: Option<ServerSchedulerLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub logging: Option<ServerLoggingLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub integrations: Option<ServerIntegrationsLayer>,
}
/// `[server.listen]` — shared bind transport.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, tag = "type", rename_all = "lowercase")]
pub enum ServerListenLayer {
Tcp {
#[serde(default)]
address: Option<InterpString>,
},
Unix {
#[serde(default)]
path: Option<InterpString>,
},
}
/// `[server.api]` — API surface settings.
///
/// `url` is an optional public URL; it is **not** derived from `server.listen`.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ServerApiLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<InterpString>,
}
/// `[server.web]` — web surface settings.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
pub struct ServerWebLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<InterpString>,
}
/// `[server.auth]` — cohesive server auth surface.
///
/// When absent or resolved to no enabled API or web auth configuration, the
/// default server startup posture is fail-closed. Demo and test helpers may
/// explicitly opt in to insecure configurations.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
pub struct ServerAuthLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub methods: Option<Vec<ServerAuthMethod>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub github: Option<ServerAuthGithubLayer>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ServerAuthGithubLayer {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allowed_usernames: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
pub struct ServerIpAllowlistLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub entries: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub trusted_proxy_count: Option<u32>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
pub struct ServerIpAllowlistOverrideLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub entries: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub trusted_proxy_count: Option<u32>,
}
/// `[server.storage]` — single managed local disk root.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
pub struct ServerStorageLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub root: Option<InterpString>,
}
/// `[server.artifacts]` — object-store-backed artifact storage.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
pub struct ServerArtifactsLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<ObjectStoreProvider>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prefix: Option<InterpString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub local: Option<ObjectStoreLocalLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub s3: Option<ObjectStoreS3Layer>,
}
/// `[server.slatedb]` — SlateDB bottomless storage plus tunables.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
pub struct ServerSlateDbLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<ObjectStoreProvider>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prefix: Option<InterpString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub flush_interval: Option<Duration>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub local: Option<ObjectStoreLocalLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub s3: Option<ObjectStoreS3Layer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub disk_cache: Option<bool>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ObjectStoreLocalLayer {
/// Overrides the default root, which otherwise falls back to
/// `{server.storage.root}/objects/{domain}`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub root: Option<InterpString>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ObjectStoreS3Layer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bucket: Option<InterpString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub region: Option<InterpString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub endpoint: Option<InterpString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path_style: Option<bool>,
}
/// `[server.scheduler]` — server-managed execution policy.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
pub struct ServerSchedulerLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_concurrent_runs: Option<usize>,
}
/// `[server.logging]` — process-owned logging configuration for the server.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ServerLoggingLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub level: Option<String>,
}
/// `[server.integrations.<provider>]` — cohesive integration surface for chat
/// platforms and git providers (GitHub App, webhooks, etc.). First-pass
/// integrations enumerate known providers rather than using a flatten-HashMap
/// shape so strict unknown-field validation still holds.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
pub struct ServerIntegrationsLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub github: Option<GithubIntegrationLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub slack: Option<SlackIntegrationLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub discord: Option<DiscordIntegrationLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub teams: Option<TeamsIntegrationLayer>,
}
/// `[server.integrations.github]` — GitHub App, credentials, and inbound
/// webhooks.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
pub struct GithubIntegrationLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub strategy: Option<GithubIntegrationStrategy>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub app_id: Option<InterpString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_id: Option<InterpString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub slug: Option<InterpString>,
#[serde(default, skip_serializing_if = "StickyMap::is_empty")]
pub permissions: StickyMap<InterpString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub webhooks: Option<IntegrationWebhooksLayer>,
}
/// `[server.integrations.slack]` — Slack workspace credentials and defaults.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
pub struct SlackIntegrationLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_channel: Option<InterpString>,
}
/// `[server.integrations.discord]` — Discord workspace configuration.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
pub struct DiscordIntegrationLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
}
/// `[server.integrations.teams]` — Microsoft Teams configuration.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
pub struct TeamsIntegrationLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
pub struct IntegrationWebhooksLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub strategy: Option<WebhookStrategy>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ip_allowlist: Option<ServerIpAllowlistOverrideLayer>,
}

View file

@ -5,6 +5,8 @@
//! unset in the source stay `None`/empty and are layered later by
//! `fabro-config`.
use std::str::FromStr;
use serde::{Deserialize, Serialize};
use super::cli::CliLayer;
@ -13,10 +15,11 @@ use super::project::ProjectLayer;
use super::run::RunLayer;
use super::server::ServerLayer;
use super::workflow::WorkflowLayer;
use crate::parse::{ParseError, parse_settings};
/// A sparse settings layer before merge/resolve.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
pub struct SettingsLayer {
pub(crate) struct SettingsLayer {
#[serde(default, rename = "_version", skip_serializing_if = "Option::is_none")]
pub version: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@ -33,13 +36,75 @@ pub struct SettingsLayer {
pub features: Option<FeaturesLayer>,
}
#[cfg(any(test, feature = "test-support"))]
impl FromStr for SettingsLayer {
type Err = ParseError;
fn from_str(source: &str) -> Result<Self, Self::Err> {
parse_settings(source)
}
}
impl From<CliLayer> for SettingsLayer {
fn from(cli: CliLayer) -> Self {
Self {
cli: Some(cli),
..Self::default()
}
}
}
impl From<FeaturesLayer> for SettingsLayer {
fn from(features: FeaturesLayer) -> Self {
Self {
features: Some(features),
..Self::default()
}
}
}
impl From<ProjectLayer> for SettingsLayer {
fn from(project: ProjectLayer) -> Self {
Self {
project: Some(project),
..Self::default()
}
}
}
impl From<RunLayer> for SettingsLayer {
fn from(run: RunLayer) -> Self {
Self {
run: Some(run),
..Self::default()
}
}
}
impl From<ServerLayer> for SettingsLayer {
fn from(server: ServerLayer) -> Self {
Self {
server: Some(server),
..Self::default()
}
}
}
impl From<WorkflowLayer> for SettingsLayer {
fn from(workflow: WorkflowLayer) -> Self {
Self {
workflow: Some(workflow),
..Self::default()
}
}
}
#[cfg(test)]
impl SettingsLayer {
/// A default layer that resolves cleanly: populates `server.auth.methods`
/// with `["dev-token"]`. Use anywhere a test needs a starter
/// `SettingsLayer` that the strict resolver will accept.
#[must_use]
pub fn test_default() -> Self {
pub(crate) fn test_default() -> Self {
let mut layer = Self::default();
layer.ensure_test_auth_methods();
layer
@ -48,8 +113,10 @@ impl SettingsLayer {
/// If `server.auth.methods` is unset, populate it with `["dev-token"]`.
/// Existing methods (set by a fixture) are preserved. Use to make a
/// parsed-from-TOML layer resolve cleanly without overriding test intent.
pub fn ensure_test_auth_methods(&mut self) {
use super::server::{ServerAuthLayer, ServerAuthMethod, ServerLayer as ServerLayerTy};
pub(crate) fn ensure_test_auth_methods(&mut self) {
use fabro_types::settings::ServerAuthMethod;
use super::server::{ServerAuthLayer, ServerLayer as ServerLayerTy};
if self
.server

View file

@ -0,0 +1,3 @@
//! Shared splice marker literal for splice-capable arrays in raw settings.
pub(crate) const SPLICE_MARKER: &str = "...";

View file

@ -0,0 +1,20 @@
//! Sparse `[workflow]` settings layer definitions.
use serde::{Deserialize, Serialize};
use super::maps::ReplaceMap;
/// A sparse `[workflow]` layer as it appears in a single settings file.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
pub struct WorkflowLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// Optional override for the default `workflow.fabro` graph path.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub graph: Option<String>,
#[serde(default, skip_serializing_if = "ReplaceMap::is_empty")]
pub metadata: ReplaceMap<String>,
}

View file

@ -2,45 +2,59 @@
clippy::disallowed_methods,
reason = "sync config loading utilities used at startup; not on a Tokio path"
)]
//! Resolved settings entrypoints: [`ServerSettings`] for the running server,
//! [`UserSettings`] for the CLI/user perspective, and [`WorkflowSettings`] for
//! workflow execution.
//! Configuration loading and resolution helpers.
extern crate self as fabro_config;
pub mod context;
pub mod builders;
mod defaults;
mod layers;
pub mod bind;
pub mod daemon;
pub mod effective_settings;
pub mod envfile;
pub mod error;
pub mod home;
pub mod load;
mod load;
pub mod parse;
pub mod project;
pub mod resolve;
pub mod run;
pub mod storage;
#[cfg(test)]
mod tests;
pub mod user;
use std::path::Path;
pub use context::{ServerSettings, UserSettings, WorkflowSettings};
pub use defaults::{apply_builtin_defaults, defaults_layer};
pub use builders::{
ResolveErrors, RunSettingsBuilder, ServerRuntimeSettings, ServerSettingsBuilder,
UserSettingsBuilder, WorkflowSettingsBuilder, load_server_runtime_settings,
};
pub use error::{Error, Result};
pub use fabro_util::path::expand_tilde;
pub use home::Home;
pub use load::{
load_settings_for_workflow, load_settings_path, load_settings_project, load_settings_user,
pub use layers::{
CliAuthLayer, CliExecAgentLayer, CliExecLayer, CliExecModelLayer, CliLayer, CliLoggingLayer,
CliOutputLayer, CliTargetLayer, CliUpdatesLayer, DaytonaDockerfileLayer, DaytonaSandboxLayer,
DaytonaSnapshotLayer, DiscordIntegrationLayer, FeaturesLayer, GitAuthorLayer,
GithubIntegrationLayer, HookAgentMarker, HookEntry, HookTlsMode, IntegrationWebhooksLayer,
InterviewProviderLayer, InterviewsLayer, LocalSandboxLayer, McpEntryLayer, MergeMap,
ModelRefOrSplice, NotificationProviderLayer, NotificationRouteLayer, ObjectStoreLocalLayer,
ObjectStoreS3Layer, PrepareStep, ProjectLayer, ReplaceMap, RunAgentLayer, RunArtifactsLayer,
RunCheckpointLayer, RunExecutionLayer, RunGitLayer, RunGoalLayer, RunLayer, RunModelLayer,
RunPrepareLayer, RunPullRequestLayer, RunSandboxLayer, RunScmLayer, ScmGitHubLayer,
ServerApiLayer, ServerArtifactsLayer, ServerAuthGithubLayer, ServerAuthLayer,
ServerIntegrationsLayer, ServerIpAllowlistLayer, ServerIpAllowlistOverrideLayer, ServerLayer,
ServerListenLayer, ServerLoggingLayer, ServerSchedulerLayer, ServerSlateDbLayer,
ServerStorageLayer, ServerWebLayer, SlackIntegrationLayer, StickyMap, StringOrSplice,
TeamsIntegrationLayer, WorkflowLayer,
};
pub use parse::{ParseError, parse_settings_layer};
pub(crate) use layers::{Combine, SettingsLayer};
pub use parse::ParseError;
pub use resolve::{
ResolveError, dev_token_auth_enabled, render_resolve_errors, resolve_cli,
resolve_cli_from_file, resolve_features, resolve_features_from_file, resolve_project,
resolve_project_from_file, resolve_run, resolve_run_from_file, resolve_server,
resolve_server_from_file, resolve_storage_root, resolve_workflow, resolve_workflow_from_file,
ResolveError, resolve_cli, resolve_features, resolve_project, resolve_run, resolve_server,
resolve_workflow,
};
use serde::de::DeserializeOwned;
pub use storage::{RunScratch, RuntimeDirectory, Storage};

View file

@ -5,52 +5,20 @@
use std::path::{Path, PathBuf};
use fabro_types::settings::run::RunGoalLayer;
use fabro_types::settings::{Combine, InterpString, SettingsLayer};
use fabro_types::settings::InterpString;
use crate::parse::parse_settings_layer;
use crate::{Error, Result, project, user};
use crate::{Error, Result, RunGoalLayer, SettingsLayer};
pub fn load_settings_path(path: &Path) -> Result<SettingsLayer> {
pub(crate) fn load_settings_path(path: &Path) -> Result<SettingsLayer> {
let content = std::fs::read_to_string(path).map_err(|source| Error::read_file(path, source))?;
let mut layer = parse_settings_layer(&content)
let mut layer = content
.parse::<SettingsLayer>()
.map_err(|err| Error::parse_file("Failed to parse settings file", path, err))?;
let base_dir = path.parent().unwrap_or_else(|| Path::new("."));
resolve_goal_file_paths(&mut layer, base_dir);
Ok(layer)
}
pub fn load_settings_for_workflow(path: &Path, cwd: &Path) -> Result<SettingsLayer> {
let resolution = project::resolve_workflow_path(path, cwd)?;
if resolution.workflow_config.is_none() && !resolution.resolved_workflow_path.is_file() {
return Err(Error::WorkflowNotFound(
resolution.resolved_workflow_path.display().to_string(),
));
}
let workflow_config = resolution.workflow_config.unwrap_or_default();
let project_config = project::discover_project_config(
resolution
.resolved_workflow_path
.parent()
.unwrap_or_else(|| Path::new(".")),
)?
.map(|(_, config)| config)
.unwrap_or_default();
Ok(workflow_config.combine(project_config))
}
pub fn load_settings_project(start: &Path) -> Result<SettingsLayer> {
Ok(project::discover_project_config(start)?
.map(|(_, config)| config)
.unwrap_or_default())
}
pub fn load_settings_user() -> Result<SettingsLayer> {
user::load_settings_config(None)
}
pub(crate) fn resolve_goal_file_paths(file: &mut SettingsLayer, base_dir: &Path) {
let Some(run) = file.run.as_mut() else {
return;

View file

@ -1,6 +1,6 @@
use std::fmt;
use fabro_types::settings::SettingsLayer;
use crate::SettingsLayer;
const CURRENT_VERSION: u32 = 1;
@ -58,7 +58,7 @@ impl fmt::Display for VersionError {
impl std::error::Error for VersionError {}
pub fn parse_settings_layer(input: &str) -> Result<SettingsLayer, ParseError> {
pub(crate) fn parse_settings(input: &str) -> Result<SettingsLayer, ParseError> {
let raw: toml::Value = toml::from_str(input).map_err(|e| ParseError::Toml(e.to_string()))?;
validate_version(&raw).map_err(ParseError::Version)?;
@ -135,19 +135,19 @@ mod tests {
#[test]
fn parses_empty_file() {
let file = parse_settings_layer("").unwrap();
let file = "".parse::<SettingsLayer>().unwrap();
assert_eq!(file, SettingsLayer::default());
}
#[test]
fn parses_minimal_valid_file() {
let file = parse_settings_layer("_version = 1\n").unwrap();
let file = "_version = 1\n".parse::<SettingsLayer>().unwrap();
assert_eq!(file.version, Some(1));
}
#[test]
fn rejects_legacy_version_key_with_rename_hint() {
let err = parse_settings_layer("version = 1").unwrap_err();
let err = "version = 1".parse::<SettingsLayer>().unwrap_err();
assert!(matches!(
err,
ParseError::Version(VersionError::LegacyVersionKey)
@ -157,13 +157,13 @@ mod tests {
#[test]
fn rejects_unknown_top_level_key() {
let err = parse_settings_layer("unknown_key = 1").unwrap_err();
let err = "unknown_key = 1".parse::<SettingsLayer>().unwrap_err();
assert!(matches!(err, ParseError::UnknownTopLevelKey { .. }));
}
#[test]
fn higher_version_rejected_with_upgrade_hint() {
let err = parse_settings_layer("_version = 99").unwrap_err();
let err = "_version = 99".parse::<SettingsLayer>().unwrap_err();
assert!(err.to_string().contains("Upgrade"));
}
}

View file

@ -12,53 +12,42 @@
use std::fmt::Write;
use std::path::{Component, Path, PathBuf};
use fabro_types::settings::SettingsLayer;
use fabro_types::settings::{InterpString, RunNamespace};
use serde::Serialize;
use crate::load::load_settings_path;
use crate::parse::parse_settings_layer;
use crate::{
Error, Result, resolve_project_from_file, resolve_run_from_file, resolve_workflow_from_file,
run,
};
use crate::{Error, Result, SettingsLayer, WorkflowSettingsBuilder, run};
const CONFIG_FILENAME: &str = ".fabro/project.toml";
#[derive(Clone, Debug)]
pub struct WorkflowPathResolution {
pub resolved_workflow_path: PathBuf,
pub dot_path: PathBuf,
pub workflow_config: Option<SettingsLayer>,
pub workflow_toml_path: Option<PathBuf>,
pub workflow_slug: Option<String>,
}
/// Parse a project config from a TOML string.
pub fn parse_project_config(content: &str) -> Result<SettingsLayer> {
parse_settings_layer(content).map_err(|err| Error::parse("Failed to parse project config", err))
}
/// Load a project config from a file path.
///
/// Goes through [`load_settings_path`] so that relative `run.goal.file`
/// paths are anchored at the directory of `path` at load time.
pub fn load_project_config(path: &Path) -> Result<SettingsLayer> {
fn load_project_config(path: &Path) -> Result<SettingsLayer> {
let config = load_settings_path(path)?;
let root = resolve_project_from_file(&config)
.map_err(|errors| Error::resolve("Failed to resolve project settings", errors))?
let root = WorkflowSettingsBuilder::project_from_layer(&config)
.map_err(|errors| Error::resolve("Failed to resolve project settings", errors.into()))?
.directory;
tracing::debug!(path = %path.display(), root = %root, "Loaded project config");
Ok(config)
}
/// Walk ancestor directories from `start` looking for `.fabro/project.toml`.
/// Returns the config file path and parsed config, or `None` if not found.
pub fn discover_project_config(start: &Path) -> Result<Option<(PathBuf, SettingsLayer)>> {
/// Returns the config file path, or `None` if not found.
pub fn discover_project_config(start: &Path) -> Result<Option<PathBuf>> {
for ancestor in start.ancestors() {
let candidate = ancestor.join(CONFIG_FILENAME);
if candidate.is_file() {
tracing::debug!(path = %candidate.display(), "Discovered project config");
let config = load_project_config(&candidate)?;
return Ok(Some((candidate, config)));
return Ok(Some(candidate));
}
}
Ok(None)
@ -94,14 +83,14 @@ pub fn resolve_workflow_path(workflow_path: &Path, cwd: &Path) -> Result<Workflo
if path.extension().is_some_and(|ext| ext == "toml") {
match run::load_run_config(&path) {
Ok(cfg) => {
let workflow = resolve_workflow_from_file(&cfg).map_err(|errors| {
Error::resolve("Failed to resolve workflow settings", errors)
})?;
let workflow =
WorkflowSettingsBuilder::workflow_from_layer(&cfg).map_err(|errors| {
Error::resolve("Failed to resolve workflow settings", errors.into())
})?;
let dot_path = run::resolve_graph_path(&path, &workflow.graph);
Ok(WorkflowPathResolution {
resolved_workflow_path: path.clone(),
dot_path,
workflow_config: Some(cfg),
workflow_toml_path: Some(path),
workflow_slug,
})
@ -113,22 +102,17 @@ pub fn resolve_workflow_path(workflow_path: &Path, cwd: &Path) -> Result<Workflo
Ok(WorkflowPathResolution {
resolved_workflow_path: path.clone(),
dot_path: path,
workflow_config: None,
workflow_toml_path: None,
workflow_slug,
})
}
}
pub fn resolve_working_directory(settings: &SettingsLayer, caller_cwd: &Path) -> PathBuf {
let Some(work_dir) = resolve_run_from_file(settings)
.ok()
.and_then(|settings| settings.working_dir)
.map(|value| value.as_source())
else {
pub fn resolve_working_directory_from_run(run: &RunNamespace, caller_cwd: &Path) -> PathBuf {
let Some(work_dir) = run.working_dir.as_ref().map(InterpString::as_source) else {
return caller_cwd.to_path_buf();
};
let path = PathBuf::from(&work_dir);
let path = PathBuf::from(work_dir);
if path.is_absolute() {
path
} else {
@ -161,8 +145,8 @@ fn resolve_workflow_arg_impl(
let name = arg.to_string_lossy();
match discover_project_config(start_dir) {
Ok(Some((config_path, config))) => {
let fabro_root = resolve_fabro_root(&config_path, &config);
Ok(Some(config_path)) => {
let fabro_root = resolve_fabro_root(&config_path);
let project_candidate = fabro_root
.join("workflows")
.join(&*name)
@ -344,10 +328,10 @@ fn find_closest_match(input: &str, candidates: &[String]) -> Option<String> {
}
/// Resolve a workflow argument to a DOT path and optional run config.
pub fn resolve_workflow(arg: &Path) -> Result<(PathBuf, Option<SettingsLayer>)> {
pub fn resolve_workflow(arg: &Path) -> Result<PathBuf> {
let start = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let resolution = resolve_workflow_path(arg, &start)?;
Ok((resolution.dot_path, resolution.workflow_config))
Ok(resolution.dot_path)
}
/// Check whether retros are enabled in the project config.
@ -355,11 +339,15 @@ pub fn resolve_workflow(arg: &Path) -> Result<(PathBuf, Option<SettingsLayer>)>
pub fn is_retro_enabled() -> bool {
let start = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
match discover_project_config(&start) {
Ok(Some((_path, config))) => config
.run
.as_ref()
.and_then(|r| r.execution.as_ref())
.and_then(|e| e.retros)
Ok(Some(path)) => load_project_config(&path)
.ok()
.and_then(|config| {
config
.run
.as_ref()
.and_then(|r| r.execution.as_ref())
.and_then(|e| e.retros)
})
.unwrap_or(false),
_ => false,
}
@ -388,11 +376,12 @@ fn normalize_joined_path(base_dir: &Path, reference: &Path) -> PathBuf {
/// Resolve the fabro root directory from a config file path and its config.
/// The returned path is the config file's parent directory joined with the
/// `project.directory` value (default: `.`).
pub fn resolve_fabro_root(config_path: &Path, config: &SettingsLayer) -> PathBuf {
pub fn resolve_fabro_root(config_path: &Path) -> PathBuf {
let project_dir = config_path
.parent()
.expect("config_path should have a parent directory");
let root = resolve_project_from_file(config)
let config = load_project_config(config_path).expect("project config should load");
let root = WorkflowSettingsBuilder::project_from_layer(&config)
.expect("project settings should resolve")
.directory;
normalize_joined_path(project_dir, Path::new(&root))
@ -408,38 +397,38 @@ mod tests {
#[test]
fn parse_minimal_config() {
let config = parse_project_config("_version = 1\n").unwrap();
let config = "_version = 1\n".parse::<crate::SettingsLayer>().unwrap();
assert_eq!(config.version, Some(1));
assert!(config.project.is_none());
}
#[test]
fn parse_with_project_directory() {
let config = parse_project_config(
r#"
assert_eq!(
WorkflowSettingsBuilder::from_toml(
r#"
_version = 1
[project]
directory = "custom/"
"#,
)
.unwrap();
assert_eq!(
resolve_project_from_file(&config).unwrap().directory,
)
.unwrap()
.project
.directory,
"custom/"
);
}
#[test]
fn parse_with_run_execution_retros() {
let config = parse_project_config(
"
let config = "
_version = 1
[run.execution]
retros = true
",
)
"
.parse::<crate::SettingsLayer>()
.unwrap();
assert_eq!(
config
@ -453,7 +442,9 @@ retros = true
#[test]
fn parse_rejects_legacy_llm_section() {
let err = parse_project_config("_version = 1\n[llm]\nprovider = \"openai\"\n").unwrap_err();
let err = "_version = 1\n[llm]\nprovider = \"openai\"\n"
.parse::<crate::SettingsLayer>()
.unwrap_err();
let text = format!("{err:#}");
assert!(
text.contains("run.model") || text.contains("llm"),
@ -463,7 +454,9 @@ retros = true
#[test]
fn parse_higher_version_errors() {
let err = parse_project_config("_version = 2\n").unwrap_err();
let err = "_version = 2\n"
.parse::<crate::SettingsLayer>()
.unwrap_err();
let chain = format!("{err:#}");
assert!(
chain.contains("Upgrade") || chain.to_lowercase().contains("version"),
@ -491,14 +484,13 @@ retros = true
let sub = tmp.path().join("sub").join("dir");
fs::create_dir_all(&sub).unwrap();
let (found_path, config) = discover_project_config(&sub).unwrap().unwrap();
let found_path = discover_project_config(&sub).unwrap().unwrap();
assert_eq!(found_path, config_dir.join("project.toml"));
assert_eq!(config.version, Some(1));
}
#[test]
fn load_project_config_rewrites_relative_goal_file_path() {
use fabro_types::settings::run::RunGoalLayer;
use crate::RunGoalLayer;
let tmp = TempDir::new().unwrap();
let config_dir = tmp.path().join(".fabro");
@ -532,9 +524,7 @@ file = "prompts/goal.md"
let config_path = config_dir.join("project.toml");
fs::write(&config_path, "_version = 1\n").unwrap();
let config = load_project_config(&config_path).unwrap();
assert_eq!(resolve_fabro_root(&config_path, &config), config_dir);
assert_eq!(resolve_fabro_root(&config_path), config_dir);
}
#[test]
@ -553,17 +543,12 @@ directory = "../custom"
)
.unwrap();
let config = load_project_config(&config_path).unwrap();
assert_eq!(
resolve_fabro_root(&config_path, &config),
tmp.path().join("custom")
);
assert_eq!(resolve_fabro_root(&config_path), tmp.path().join("custom"));
}
#[test]
fn relative_goal_file_resolves_from_config_dir() {
use fabro_types::settings::run::RunGoalLayer;
use crate::RunGoalLayer;
let tmp = TempDir::new().unwrap();
let config_dir = tmp.path().join(".fabro");
@ -591,4 +576,18 @@ file = "prompts/goal.md"
config_dir.join("prompts").join("goal.md").to_string_lossy()
);
}
#[test]
fn resolve_working_directory_from_run_joins_relative_path() {
let cwd = Path::new("/tmp/workspace");
let resolved = resolve_working_directory_from_run(
&RunNamespace {
working_dir: Some(InterpString::parse("repo")),
..RunNamespace::default()
},
cwd,
);
assert_eq!(resolved, cwd.join("repo"));
}
}

View file

@ -1,10 +1,10 @@
use fabro_types::settings::cli::{
CliAuthSettings, CliExecAgentSettings, CliExecLayer, CliExecModelSettings, CliExecSettings,
CliLayer, CliLoggingSettings, CliNamespace, CliOutputSettings, CliTargetLayer,
CliTargetSettings, CliUpdatesSettings,
CliAuthSettings, CliExecAgentSettings, CliExecModelSettings, CliExecSettings,
CliLoggingSettings, CliNamespace, CliOutputSettings, CliTargetSettings, CliUpdatesSettings,
};
use super::{ResolveError, require_interp};
use crate::{CliExecLayer, CliLayer, CliTargetLayer};
pub fn resolve_cli(layer: &CliLayer, errors: &mut Vec<ResolveError>) -> CliNamespace {
CliNamespace {

View file

@ -1,6 +1,7 @@
use fabro_types::settings::features::{FeaturesLayer, FeaturesNamespace};
use fabro_types::settings::FeaturesNamespace;
use super::ResolveError;
use crate::FeaturesLayer;
pub fn resolve_features(
layer: &FeaturesLayer,

View file

@ -8,89 +8,13 @@ mod workflow;
pub use cli::resolve_cli;
pub use error::ResolveError;
use fabro_types::settings::{
CliNamespace, FeaturesNamespace, InterpString, ProjectNamespace, RunNamespace, ServerNamespace,
SettingsLayer, WorkflowNamespace,
};
use fabro_types::settings::InterpString;
pub use features::resolve_features;
pub use project::resolve_project;
pub use run::resolve_run;
pub use server::{dev_token_auth_enabled, resolve_server};
pub use server::resolve_server;
pub use workflow::resolve_workflow;
use crate::apply_builtin_defaults;
use crate::user::default_storage_dir;
pub fn resolve_storage_root(file: &SettingsLayer) -> InterpString {
let layer = apply_builtin_defaults(file.clone());
layer
.server
.as_ref()
.and_then(|server| server.storage.as_ref())
.and_then(|storage| storage.root.clone())
.unwrap_or_else(|| default_interp(default_storage_dir()))
}
pub fn resolve_cli_from_file(file: &SettingsLayer) -> Result<CliNamespace, Vec<ResolveError>> {
let layer = apply_builtin_defaults(file.clone());
let mut errors = Vec::new();
let value = resolve_cli(&layer.cli.clone().unwrap_or_default(), &mut errors);
finish(value, errors)
}
pub fn resolve_server_from_file(
file: &SettingsLayer,
) -> Result<ServerNamespace, Vec<ResolveError>> {
let layer = apply_builtin_defaults(file.clone());
let mut errors = Vec::new();
let value = resolve_server(&layer.server.clone().unwrap_or_default(), &mut errors);
finish(value, errors)
}
pub fn resolve_project_from_file(
file: &SettingsLayer,
) -> Result<ProjectNamespace, Vec<ResolveError>> {
let layer = apply_builtin_defaults(file.clone());
let mut errors = Vec::new();
let value = resolve_project(&layer.project.clone().unwrap_or_default(), &mut errors);
finish(value, errors)
}
pub fn resolve_features_from_file(
file: &SettingsLayer,
) -> Result<FeaturesNamespace, Vec<ResolveError>> {
let layer = apply_builtin_defaults(file.clone());
let mut errors = Vec::new();
let value = resolve_features(&layer.features.clone().unwrap_or_default(), &mut errors);
finish(value, errors)
}
pub fn resolve_run_from_file(file: &SettingsLayer) -> Result<RunNamespace, Vec<ResolveError>> {
let layer = apply_builtin_defaults(file.clone());
let mut errors = Vec::new();
let value = resolve_run(&layer.run.clone().unwrap_or_default(), &mut errors);
finish(value, errors)
}
pub fn resolve_workflow_from_file(
file: &SettingsLayer,
) -> Result<WorkflowNamespace, Vec<ResolveError>> {
let layer = apply_builtin_defaults(file.clone());
let mut errors = Vec::new();
let value = resolve_workflow(&layer.workflow.clone().unwrap_or_default(), &mut errors);
finish(value, errors)
}
/// Render a list of [`ResolveError`]s as a single semicolon-separated message
/// for human-facing error envelopes.
pub fn render_resolve_errors(errors: &[ResolveError]) -> String {
errors
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("; ")
}
pub(crate) fn require_interp(
value: Option<&InterpString>,
path: &str,
@ -126,27 +50,17 @@ pub(crate) fn default_interp(path: impl AsRef<std::path::Path>) -> InterpString
InterpString::parse(&path.as_ref().to_string_lossy())
}
fn finish<T>(value: T, errors: Vec<ResolveError>) -> Result<T, Vec<ResolveError>> {
if errors.is_empty() {
Ok(value)
} else {
Err(errors)
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use fabro_types::settings::run::{HookType, McpTransport, TlsMode};
use super::resolve_run_from_file;
use crate::parse_settings_layer;
use crate::{SettingsLayer, WorkflowSettingsBuilder};
#[test]
fn resolve_preserves_source_templates_for_mcp_and_hook_strings() {
let settings = parse_settings_layer(
r#"
let settings = r#"
_version = 1
[server.auth]
@ -181,11 +95,13 @@ url = "https://hooks.example.com"
[run.hooks.headers]
Authorization = "Bearer {{ env.HOOK_TOKEN }}"
"#,
)
"#
.parse::<SettingsLayer>()
.expect("settings fixture should parse");
let resolved = resolve_run_from_file(&settings).expect("run settings should resolve");
let resolved = WorkflowSettingsBuilder::from_layer(&settings)
.expect("run settings should resolve")
.run;
let mcps = &resolved.agent.mcps;
assert_eq!(

View file

@ -1,6 +1,7 @@
use fabro_types::settings::project::{ProjectLayer, ProjectNamespace};
use fabro_types::settings::ProjectNamespace;
use super::ResolveError;
use crate::ProjectLayer;
pub fn resolve_project(layer: &ProjectLayer, _errors: &mut Vec<ResolveError>) -> ProjectNamespace {
ProjectNamespace {

View file

@ -1,19 +1,22 @@
use fabro_types::settings::InterpString;
use fabro_types::settings::run::{
ArtifactsSettings, DaytonaDockerfileLayer, DaytonaSandboxLayer, DaytonaSettings,
DaytonaSnapshotSettings, DockerfileSource, GitAuthorSettings, HookAgentMarker, HookDefinition,
HookEntry, HookTlsMode, HookType, InterviewProviderLayer, InterviewProviderSettings,
InterviewsLayer, LocalSandboxSettings, McpEntryLayer, McpServerSettings, McpTransport,
MergeStrategy, ModelRefOrSplice, NotificationProviderLayer, NotificationProviderSettings,
NotificationRouteLayer, NotificationRouteSettings, PullRequestSettings, RunAgentLayer,
RunAgentSettings, RunArtifactsLayer, RunCheckpointLayer, RunCheckpointSettings,
RunExecutionLayer, RunExecutionSettings, RunGitLayer, RunGitSettings, RunGoal, RunGoalLayer,
RunInterviewsSettings, RunLayer, RunModelLayer, RunModelSettings, RunNamespace,
RunPrepareLayer, RunPrepareSettings, RunPullRequestLayer, RunSandboxLayer, RunSandboxSettings,
RunScmLayer, RunScmSettings, ScmGitHubSettings, StringOrSplice, TlsMode,
ArtifactsSettings, DaytonaSettings, DaytonaSnapshotSettings, DockerfileSource,
GitAuthorSettings, HookDefinition, HookType, InterviewProviderSettings, LocalSandboxSettings,
McpServerSettings, McpTransport, MergeStrategy, NotificationProviderSettings,
NotificationRouteSettings, PullRequestSettings, RunAgentSettings, RunCheckpointSettings,
RunExecutionSettings, RunGitSettings, RunGoal, RunInterviewsSettings, RunModelSettings,
RunNamespace, RunPrepareSettings, RunSandboxSettings, RunScmSettings, ScmGitHubSettings,
TlsMode,
};
use super::ResolveError;
use crate::{
DaytonaDockerfileLayer, DaytonaSandboxLayer, HookAgentMarker, HookEntry, HookTlsMode,
InterviewProviderLayer, InterviewsLayer, McpEntryLayer, ModelRefOrSplice,
NotificationProviderLayer, NotificationRouteLayer, RunAgentLayer, RunArtifactsLayer,
RunCheckpointLayer, RunExecutionLayer, RunGitLayer, RunGoalLayer, RunLayer, RunModelLayer,
RunPrepareLayer, RunPullRequestLayer, RunSandboxLayer, RunScmLayer, StringOrSplice,
};
pub fn resolve_run(layer: &RunLayer, errors: &mut Vec<ResolveError>) -> RunNamespace {
RunNamespace {
@ -451,7 +454,7 @@ fn resolve_scm(scm: Option<&RunScmLayer>) -> RunScmSettings {
provider: scm.provider.clone(),
owner: scm.owner.clone(),
repository: scm.repository.clone(),
github: scm.github.as_ref().map(|_| ScmGitHubSettings),
github: scm.github.as_ref().map(|_| ScmGitHubSettings {}),
}
}

View file

@ -1,30 +1,23 @@
use fabro_types::settings::InterpString;
use fabro_types::settings::server::{
DiscordIntegrationSettings, GithubIntegrationSettings, GithubIntegrationStrategy,
IntegrationWebhooksLayer, IntegrationWebhooksSettings, IpAllowEntry, ObjectStoreLocalLayer,
ObjectStoreProvider, ObjectStoreS3Layer, ObjectStoreSettings, ServerApiLayer,
ServerApiSettings, ServerArtifactsLayer, ServerArtifactsSettings, ServerAuthGithubSettings,
ServerAuthLayer, ServerAuthMethod, ServerAuthSettings, ServerIntegrationsLayer,
ServerIntegrationsSettings, ServerIpAllowlistLayer, ServerIpAllowlistOverrideLayer,
ServerIpAllowlistOverrideSettings, ServerIpAllowlistSettings, ServerLayer, ServerListenLayer,
ServerListenSettings, ServerLoggingSettings, ServerNamespace, ServerSchedulerSettings,
ServerSlateDbLayer, ServerSlateDbSettings, ServerStorageLayer, ServerStorageSettings,
ServerWebLayer, ServerWebSettings, SlackIntegrationSettings, TeamsIntegrationSettings,
WebhookStrategy,
IntegrationWebhooksSettings, IpAllowEntry, ObjectStoreProvider, ObjectStoreSettings,
ServerApiSettings, ServerArtifactsSettings, ServerAuthGithubSettings, ServerAuthMethod,
ServerAuthSettings, ServerIntegrationsSettings, ServerIpAllowlistOverrideSettings,
ServerIpAllowlistSettings, ServerListenSettings, ServerLoggingSettings, ServerNamespace,
ServerSchedulerSettings, ServerSlateDbSettings, ServerStorageSettings, ServerWebSettings,
SlackIntegrationSettings, TeamsIntegrationSettings, WebhookStrategy,
};
use fabro_types::settings::{InterpString, SettingsLayer};
use fabro_util::Home;
use super::{ResolveError, default_interp, parse_socket_addr, require_interp};
use crate::user::default_storage_dir;
pub fn dev_token_auth_enabled(layer: &SettingsLayer) -> bool {
layer
.server
.as_ref()
.and_then(|server| server.auth.as_ref())
.and_then(|auth| auth.methods.as_ref())
.is_some_and(|methods| methods.contains(&ServerAuthMethod::DevToken))
}
use crate::{
IntegrationWebhooksLayer, ObjectStoreLocalLayer, ObjectStoreS3Layer, ServerApiLayer,
ServerArtifactsLayer, ServerAuthLayer, ServerIntegrationsLayer, ServerIpAllowlistLayer,
ServerIpAllowlistOverrideLayer, ServerLayer, ServerListenLayer, ServerSlateDbLayer,
ServerStorageLayer, ServerWebLayer,
};
pub fn resolve_server(layer: &ServerLayer, errors: &mut Vec<ResolveError>) -> ServerNamespace {
let storage = resolve_storage(layer.storage.as_ref());

View file

@ -1,6 +1,7 @@
use fabro_types::settings::workflow::{WorkflowLayer, WorkflowNamespace};
use fabro_types::settings::WorkflowNamespace;
use super::ResolveError;
use crate::WorkflowLayer;
pub fn resolve_workflow(
layer: &WorkflowLayer,

View file

@ -1,9 +1,8 @@
//! Workflow / run config loading helpers.
//!
//! Thin wrappers around `parse_settings_layer` / `load_settings_path` plus
//! path resolution for the `[workflow] graph` override. Runtime types
//! that used to be re-exported from here live under
//! `fabro_types::settings::run` now.
//! Helpers for loading workflow-local settings and resolving runtime goal /
//! graph paths. Runtime types that used to be re-exported from here live
//! under `fabro_types::settings::run` now.
#![expect(
clippy::disallowed_methods,
@ -12,24 +11,17 @@
use std::path::{Path, PathBuf};
use fabro_types::settings::SettingsLayer;
use fabro_types::settings::run::{ResolvedGoalSource, ResolvedRunGoal, RunGoalLayer};
use fabro_types::settings::InterpString;
use fabro_types::settings::run::{ResolvedGoalSource, ResolvedRunGoal, RunGoal, RunNamespace};
use crate::load::{load_settings_path, resolve_goal_file_path};
use crate::parse::parse_settings_layer;
use crate::{Error, Result};
/// Load and parse a run config from a TOML file.
pub fn parse_run_config(contents: &str) -> Result<SettingsLayer> {
parse_settings_layer(contents)
.map_err(|err| Error::parse("Failed to parse run config TOML", err))
}
use crate::{Result, RunGoalLayer, RunLayer, SettingsLayer};
/// Load and parse a run config from a TOML file.
///
/// Goes through [`load_settings_path`] so that relative `run.goal.file`
/// paths are anchored at the directory of `path` at load time.
pub fn load_run_config(path: &Path) -> Result<SettingsLayer> {
pub(crate) fn load_run_config(path: &Path) -> Result<SettingsLayer> {
load_settings_path(path)
}
@ -76,42 +68,78 @@ impl std::error::Error for ResolveRunGoalError {
}
}
pub fn resolve_run_goal(
settings: &SettingsLayer,
pub fn resolve_run_goal_from_layer(
run: &RunLayer,
base_dir: &Path,
) -> std::result::Result<Option<ResolvedRunGoal>, ResolveRunGoalError> {
let Some(goal) = settings.run.as_ref().and_then(|run| run.goal.as_ref()) else {
let Some(goal) = run.goal.as_ref() else {
return Ok(None);
};
resolve_layer_goal(goal, base_dir).map(Some)
}
pub fn resolve_run_goal_from_namespace(
run: &RunNamespace,
base_dir: &Path,
) -> std::result::Result<Option<ResolvedRunGoal>, ResolveRunGoalError> {
let Some(goal) = run.goal.as_ref() else {
return Ok(None);
};
resolve_goal(goal, base_dir).map(Some)
}
fn resolve_goal_file(
file: &InterpString,
base_dir: &Path,
) -> std::result::Result<ResolvedRunGoal, ResolveRunGoalError> {
let resolved = file
.resolve(|name| std::env::var(name).ok())
.map_err(|err| ResolveRunGoalError::EnvLookup { var: err.name })?;
let path = resolve_goal_file_path(&resolved.value, base_dir);
let text = std::fs::read_to_string(&path).map_err(|source| ResolveRunGoalError::Io {
path: path.clone(),
source,
})?;
Ok(ResolvedRunGoal {
text,
source: ResolvedGoalSource::File { path },
})
}
fn resolve_layer_goal(
goal: &RunGoalLayer,
base_dir: &Path,
) -> std::result::Result<ResolvedRunGoal, ResolveRunGoalError> {
match goal {
RunGoalLayer::Inline(text) => Ok(Some(ResolvedRunGoal {
RunGoalLayer::Inline(text) => Ok(ResolvedRunGoal {
text: text.as_source(),
source: ResolvedGoalSource::Inline,
})),
RunGoalLayer::File { file } => {
let resolved = file
.resolve(|name| std::env::var(name).ok())
.map_err(|err| ResolveRunGoalError::EnvLookup { var: err.name })?;
let path = resolve_goal_file_path(&resolved.value, base_dir);
let text =
std::fs::read_to_string(&path).map_err(|source| ResolveRunGoalError::Io {
path: path.clone(),
source,
})?;
Ok(Some(ResolvedRunGoal {
text,
source: ResolvedGoalSource::File { path },
}))
}
}),
RunGoalLayer::File { file } => resolve_goal_file(file, base_dir),
}
}
fn resolve_goal(
goal: &RunGoal,
base_dir: &Path,
) -> std::result::Result<ResolvedRunGoal, ResolveRunGoalError> {
match goal {
RunGoal::Inline(text) => Ok(ResolvedRunGoal {
text: text.as_source(),
source: ResolvedGoalSource::Inline,
}),
RunGoal::File(file) => resolve_goal_file(file, base_dir),
}
}
#[cfg(test)]
mod tests {
use fabro_types::settings::run::RunGoalLayer;
use fabro_types::settings::run::RunGoal;
use super::*;
use crate::RunGoalLayer;
#[test]
fn load_run_config_rewrites_relative_goal_file_path() {
@ -161,4 +189,28 @@ file = "/etc/fabro/goal.md"
};
assert_eq!(file.as_source(), "/etc/fabro/goal.md");
}
#[test]
fn resolve_run_goal_from_namespace_reads_file_goal() {
let tmp = tempfile::tempdir().unwrap();
let goal_path = tmp.path().join("goal.md");
std::fs::write(&goal_path, "ship from namespace").unwrap();
let resolved = resolve_run_goal_from_namespace(
&RunNamespace {
goal: Some(RunGoal::File(InterpString::parse(
&goal_path.display().to_string(),
))),
..RunNamespace::default()
},
tmp.path(),
)
.unwrap()
.expect("goal should resolve");
assert_eq!(resolved.text, "ship from namespace");
assert_eq!(resolved.source, ResolvedGoalSource::File {
path: goal_path,
});
}
}

View file

@ -1,9 +1,12 @@
use fabro_types::settings::InterpString;
use fabro_types::settings::cli::{OutputFormat, OutputVerbosity};
use fabro_types::settings::run::StringOrSplice;
use fabro_types::settings::{Combine, InterpString, SettingsLayer};
use crate::{Combine, SettingsLayer, StringOrSplice};
fn parse(input: &str) -> SettingsLayer {
fabro_config::parse_settings_layer(input).expect("fixture should parse")
input
.parse::<SettingsLayer>()
.expect("fixture should parse")
}
#[test]

View file

@ -1,19 +1,22 @@
use fabro_config::{
apply_builtin_defaults, defaults_layer, parse_settings_layer, resolve_run_from_file,
resolve_server_from_file, resolve_workflow_from_file,
};
use fabro_types::settings::SettingsLayer;
use fabro_types::settings::cli::OutputFormat;
use fabro_types::settings::run::{ApprovalMode, RunMode, WorktreeMode};
use fabro_types::settings::server::ObjectStoreProvider;
use crate::{Combine, ServerSettingsBuilder, SettingsLayer, WorkflowSettingsBuilder};
fn parse(source: &str) -> SettingsLayer {
parse_settings_layer(source).expect("fixture should parse")
source
.parse::<SettingsLayer>()
.expect("fixture should parse")
}
fn embedded_defaults() -> SettingsLayer {
parse(include_str!("../defaults.toml"))
}
#[test]
fn embedded_defaults_parse_successfully() {
let defaults = defaults_layer();
let defaults = embedded_defaults();
assert_eq!(
defaults
@ -33,7 +36,7 @@ fn embedded_defaults_parse_successfully() {
#[test]
fn apply_builtin_defaults_materializes_expected_layer() {
let layer = apply_builtin_defaults(SettingsLayer::default());
let layer = SettingsLayer::default().combine(embedded_defaults());
assert_eq!(
layer
@ -94,15 +97,19 @@ fn apply_builtin_defaults_materializes_expected_layer() {
#[test]
fn resolve_empty_settings_requires_explicit_server_auth_methods() {
let errors = resolve_server_from_file(&SettingsLayer::default())
let errors = ServerSettingsBuilder::from_layer(&SettingsLayer::default())
.expect_err("empty server settings should fail");
assert!(errors.iter().any(|error| {
matches!(
error,
fabro_config::ResolveError::Missing { path } if path == "server.auth.methods"
)
}));
assert!(matches!(
errors,
fabro_config::Error::Resolve { errors, .. }
if errors.iter().any(|error| {
matches!(
error,
fabro_config::ResolveError::Missing { path } if path == "server.auth.methods"
)
})
));
}
#[test]
@ -119,10 +126,10 @@ mode = "dry_run"
"#,
);
let workflow = resolve_workflow_from_file(&layer).expect("workflow settings should resolve");
let run = resolve_run_from_file(&layer).expect("run settings should resolve");
let settings =
WorkflowSettingsBuilder::from_layer(&layer).expect("workflow settings should resolve");
assert_eq!(run.execution.mode, RunMode::DryRun);
assert_eq!(run.execution.approval, ApprovalMode::Prompt);
assert_eq!(workflow.graph, "workflow.fabro");
assert_eq!(settings.run.execution.mode, RunMode::DryRun);
assert_eq!(settings.run.execution.approval, ApprovalMode::Prompt);
assert_eq!(settings.workflow.graph, "workflow.fabro");
}

View file

@ -0,0 +1,9 @@
mod combine;
mod defaults;
mod resolve_cli;
mod resolve_features;
mod resolve_project;
mod resolve_root;
mod resolve_run;
mod resolve_server;
mod resolve_workflow;

View file

@ -3,17 +3,20 @@
reason = "sync test fixture setup; not on a Tokio path"
)]
use fabro_config::{parse_settings_layer, resolve_cli_from_file};
use fabro_types::settings::InterpString;
use fabro_types::settings::cli::{CliTargetSettings, OutputFormat, OutputVerbosity};
use fabro_types::settings::run::AgentPermissions;
use fabro_types::settings::{InterpString, SettingsLayer};
use temp_env::with_var;
use crate::{SettingsLayer, UserSettingsBuilder};
#[test]
fn resolves_cli_defaults_from_empty_settings() {
let settings = SettingsLayer::default();
let cli = resolve_cli_from_file(&settings).expect("empty settings should resolve");
let cli = UserSettingsBuilder::from_layer(&settings)
.expect("empty settings should resolve")
.cli;
assert!(cli.target.is_none());
assert_eq!(cli.output.format, OutputFormat::Text);
@ -25,7 +28,7 @@ fn resolves_cli_defaults_from_empty_settings() {
#[test]
fn user_settings_from_layer_matches_namespace_resolvers() {
let settings: SettingsLayer = parse_settings_layer(
let user_settings = fabro_config::UserSettingsBuilder::from_toml(
r#"
_version = 1
@ -37,20 +40,15 @@ url = "https://config.example.com"
session_sandboxes = true
"#,
)
.expect("fixture should parse");
let user_settings =
fabro_config::UserSettings::from_layer(&settings).expect("user settings should resolve");
.expect("user settings should resolve");
assert_eq!(
user_settings.cli,
resolve_cli_from_file(&settings).expect("cli namespace should resolve")
);
assert_eq!(
user_settings.features,
fabro_config::resolve_features_from_file(&settings)
.expect("features namespace should resolve")
user_settings.cli.target,
Some(CliTargetSettings::Http {
url: InterpString::parse("https://config.example.com"),
})
);
assert!(user_settings.features.session_sandboxes);
}
#[test]
@ -71,8 +69,8 @@ session_sandboxes = true
.unwrap();
with_var("FABRO_HOME", Some(home.path()), || {
let user_settings =
fabro_config::UserSettings::resolve().expect("user settings should resolve");
let user_settings = fabro_config::UserSettingsBuilder::load_default()
.expect("user settings should resolve");
assert_eq!(user_settings.cli.output.verbosity, OutputVerbosity::Verbose);
assert!(user_settings.features.session_sandboxes);
});
@ -83,8 +81,8 @@ fn user_settings_resolve_returns_defaults_when_default_settings_file_is_missing(
let home = tempfile::tempdir().unwrap();
with_var("FABRO_HOME", Some(home.path()), || {
let user_settings =
fabro_config::UserSettings::resolve().expect("user settings should resolve");
let user_settings = fabro_config::UserSettingsBuilder::load_default()
.expect("user settings should resolve");
assert_eq!(user_settings.cli.output.format, OutputFormat::Text);
assert_eq!(user_settings.cli.output.verbosity, OutputVerbosity::Normal);
assert!(!user_settings.features.session_sandboxes);
@ -93,7 +91,7 @@ fn user_settings_resolve_returns_defaults_when_default_settings_file_is_missing(
#[test]
fn resolves_cli_target_exec_and_output_settings() {
let settings: SettingsLayer = parse_settings_layer(
let cli = UserSettingsBuilder::from_toml(
r#"
_version = 1
@ -126,9 +124,8 @@ check = false
level = "debug"
"#,
)
.expect("fixture should parse");
let cli = resolve_cli_from_file(&settings).expect("cli settings should resolve");
.expect("cli settings should resolve")
.cli;
let CliTargetSettings::Http { url } = cli.target.expect("target") else {
panic!("expected http target");

View file

@ -0,0 +1,28 @@
use crate::{SettingsLayer, UserSettingsBuilder};
#[test]
fn resolves_features_defaults_from_empty_settings() {
let settings = SettingsLayer::default();
let features = UserSettingsBuilder::from_layer(&settings)
.expect("empty settings should resolve")
.features;
assert!(!features.session_sandboxes);
}
#[test]
fn resolves_session_sandboxes_flag() {
let features = UserSettingsBuilder::from_toml(
r"
_version = 1
[features]
session_sandboxes = true
",
)
.expect("features should resolve")
.features;
assert!(features.session_sandboxes);
}

View file

@ -1,11 +1,12 @@
use fabro_config::{parse_settings_layer, resolve_project_from_file};
use fabro_types::settings::SettingsLayer;
use crate::{SettingsLayer, WorkflowSettingsBuilder};
#[test]
fn resolves_project_defaults_from_empty_settings() {
let settings = SettingsLayer::default();
let project = resolve_project_from_file(&settings).expect("empty settings should resolve");
let project = WorkflowSettingsBuilder::from_layer(&settings)
.expect("empty settings should resolve")
.project;
assert_eq!(project.directory, ".");
assert!(project.name.is_none());
@ -15,7 +16,7 @@ fn resolves_project_defaults_from_empty_settings() {
#[test]
fn resolves_project_directory_and_metadata() {
let settings: SettingsLayer = parse_settings_layer(
let project = WorkflowSettingsBuilder::from_toml(
r#"
_version = 1
@ -28,9 +29,8 @@ directory = ".fabro"
team = "platform"
"#,
)
.expect("fixture should parse");
let project = resolve_project_from_file(&settings).expect("project settings should resolve");
.expect("project settings should resolve")
.project;
assert_eq!(project.name.as_deref(), Some("Acme"));
assert_eq!(project.description.as_deref(), Some("Automation"));

View file

@ -1,28 +1,28 @@
use fabro_config::parse_settings_layer;
use fabro_types::settings::InterpString;
use fabro_types::settings::run::RunMode;
use fabro_types::settings::{InterpString, SettingsLayer};
fn parse(source: &str) -> SettingsLayer {
parse_settings_layer(source).expect("fixture should parse")
}
use crate::{ServerSettingsBuilder, SettingsLayer, WorkflowSettingsBuilder};
#[test]
fn resolves_root_settings_require_explicit_server_auth_methods() {
let errors = fabro_config::resolve_server_from_file(&SettingsLayer::default())
let errors = ServerSettingsBuilder::from_layer(&SettingsLayer::default())
.expect_err("empty server settings should fail");
assert!(errors.iter().any(|error| {
matches!(
error,
fabro_config::ResolveError::Missing { path } if path == "server.auth.methods"
)
}));
assert!(matches!(
errors,
fabro_config::Error::Resolve { errors, .. }
if errors.iter().any(|error| {
matches!(
error,
fabro_config::ResolveError::Missing { path } if path == "server.auth.methods"
)
})
));
}
#[test]
fn resolve_accumulates_errors_across_namespaces() {
let settings = parse(
r#"
let source = r#"
_version = 1
[server.listen]
@ -37,21 +37,28 @@ allowed_usernames = []
[run.sandbox]
provider = "not-a-provider"
"#,
);
"#;
let mut rendered = Vec::new();
rendered.extend(
fabro_config::resolve_server_from_file(&settings)
match ServerSettingsBuilder::from_toml(source)
.expect_err("invalid server settings should fail")
.into_iter()
.map(|error| error.to_string()),
{
fabro_config::Error::Resolve { errors, .. } => errors,
other => panic!("expected resolve error, got {other:#}"),
}
.into_iter()
.map(|error| error.to_string()),
);
rendered.extend(
fabro_config::resolve_run_from_file(&settings)
match fabro_config::WorkflowSettingsBuilder::from_toml(source)
.expect_err("invalid run settings should fail")
.into_iter()
.map(|error| error.to_string()),
{
fabro_config::Error::Resolve { errors, .. } => errors,
other => panic!("expected resolve error, got {other:#}"),
}
.into_iter()
.map(|error| error.to_string()),
);
let rendered = rendered.join("\n");
@ -62,8 +69,7 @@ provider = "not-a-provider"
#[test]
fn namespace_resolvers_cover_root_level_settings_shape() {
let settings = parse(
r#"
let source = r#"
_version = 1
[project]
@ -80,26 +86,31 @@ methods = ["dev-token"]
[run.model]
provider = "openai"
name = "gpt-5"
"#,
);
"#;
let project = fabro_config::resolve_project_from_file(&settings)
.expect("project settings should resolve");
let workflow = fabro_config::resolve_workflow_from_file(&settings)
.expect("workflow settings should resolve");
let server =
fabro_config::resolve_server_from_file(&settings).expect("server settings should resolve");
let run = fabro_config::resolve_run_from_file(&settings).expect("run settings should resolve");
let workflow_settings =
WorkflowSettingsBuilder::from_toml(source).expect("workflow settings should resolve");
let server = ServerSettingsBuilder::from_toml(source).expect("server settings should resolve");
assert_eq!(project.directory, ".fabro");
assert_eq!(workflow.graph, "graphs/workflow.dot");
assert_eq!(server.storage.root.as_source(), "/srv/fabro");
assert_eq!(workflow_settings.project.directory, ".fabro");
assert_eq!(workflow_settings.workflow.graph, "graphs/workflow.dot");
assert_eq!(server.server.storage.root.as_source(), "/srv/fabro");
assert_eq!(
run.model.provider.as_ref().map(InterpString::as_source),
workflow_settings
.run
.model
.provider
.as_ref()
.map(InterpString::as_source),
Some("openai".to_string())
);
assert_eq!(
run.model.name.as_ref().map(InterpString::as_source),
workflow_settings
.run
.model
.name
.as_ref()
.map(InterpString::as_source),
Some("gpt-5".to_string())
);
}
@ -107,8 +118,8 @@ name = "gpt-5"
#[test]
fn workflow_settings_resolve_defaults_and_expose_fields() {
let settings = SettingsLayer::default();
let resolved =
fabro_config::WorkflowSettings::from_layer(&settings).expect("defaults should resolve");
let resolved = fabro_config::WorkflowSettingsBuilder::from_layer(&settings)
.expect("defaults should resolve");
assert_eq!(resolved.project.directory, ".");
assert_eq!(resolved.workflow.graph, "workflow.fabro");
@ -117,7 +128,7 @@ fn workflow_settings_resolve_defaults_and_expose_fields() {
#[test]
fn workflow_settings_combine_labels_with_later_namespaces_winning() {
let settings = parse(
let labels = fabro_config::WorkflowSettingsBuilder::from_toml(
r#"
_version = 1
@ -133,11 +144,9 @@ shared = "workflow"
run = "yes"
shared = "run"
"#,
);
let labels = fabro_config::WorkflowSettings::from_layer(&settings)
.expect("workflow settings should resolve")
.combined_labels();
)
.expect("workflow settings should resolve")
.combined_labels();
assert_eq!(labels.get("project").map(String::as_str), Some("yes"));
assert_eq!(labels.get("workflow").map(String::as_str), Some("yes"));
@ -147,17 +156,19 @@ shared = "run"
#[test]
fn workflow_settings_report_invalid_run_sandbox_provider() {
let settings = parse(
let errors = match fabro_config::WorkflowSettingsBuilder::from_toml(
r#"
_version = 1
[run.sandbox]
provider = "not-a-provider"
"#,
);
let errors = fabro_config::WorkflowSettings::from_layer(&settings)
.expect_err("invalid workflow settings should fail");
)
.expect_err("invalid workflow settings should fail")
{
fabro_config::Error::Resolve { errors, .. } => errors,
other => panic!("expected resolve error, got {other:#}"),
};
assert!(errors.iter().any(|error| {
matches!(
@ -169,7 +180,7 @@ provider = "not-a-provider"
#[test]
fn workflow_settings_accumulate_multiple_run_errors() {
let settings = parse(
let rendered = fabro_config::WorkflowSettingsBuilder::from_toml(
r#"
_version = 1
@ -180,14 +191,9 @@ provider = "not-a-provider"
script = "echo hi"
command = ["echo", "hi"]
"#,
);
let rendered = fabro_config::WorkflowSettings::from_layer(&settings)
.expect_err("invalid workflow settings should fail")
.into_iter()
.map(|error| error.to_string())
.collect::<Vec<_>>()
.join("\n");
)
.expect_err("invalid workflow settings should fail")
.to_string();
assert!(rendered.contains("run.sandbox.provider"));
assert!(rendered.contains("run.prepare.steps[0]"));

View file

@ -1,15 +1,13 @@
use fabro_config::parse_settings_layer;
use fabro_types::settings::InterpString;
use fabro_types::settings::run::{ApprovalMode, RunGoal, RunMode, WorktreeMode};
use fabro_types::settings::{InterpString, SettingsLayer};
fn parse(source: &str) -> SettingsLayer {
parse_settings_layer(source).expect("fixture should parse")
}
use crate::{SettingsLayer, WorkflowSettingsBuilder};
#[test]
fn resolves_run_defaults_from_empty_settings() {
let settings = fabro_config::resolve_run_from_file(&SettingsLayer::default())
.expect("empty settings should resolve");
let settings = WorkflowSettingsBuilder::from_layer(&SettingsLayer::default())
.expect("empty settings should resolve")
.run;
assert_eq!(settings.execution.mode, RunMode::Normal);
assert_eq!(settings.execution.approval, ApprovalMode::Prompt);
@ -22,7 +20,7 @@ fn resolves_run_defaults_from_empty_settings() {
#[test]
fn preserves_goal_variants_and_model_sources() {
let file = parse(
let settings = WorkflowSettingsBuilder::from_toml(
r#"
_version = 1
@ -36,9 +34,9 @@ file = "{{ env.GOAL_FILE }}"
provider = "anthropic"
name = "sonnet"
"#,
);
let settings = fabro_config::resolve_run_from_file(&file).expect("run settings should resolve");
)
.expect("run settings should resolve")
.run;
match settings.goal {
Some(RunGoal::File(path)) => {

View file

@ -3,17 +3,21 @@
reason = "sync test fixture setup; not on a Tokio path"
)]
use fabro_config::parse_settings_layer;
use fabro_config::user::default_storage_dir;
use fabro_types::settings::InterpString;
use fabro_types::settings::server::{
GithubIntegrationStrategy, IpAllowEntry, ObjectStoreSettings, ServerListenSettings,
GithubIntegrationStrategy, IpAllowEntry, ObjectStoreSettings, ServerAuthMethod,
ServerListenSettings, ServerNamespace,
};
use fabro_types::settings::{InterpString, SettingsLayer};
use fabro_util::Home;
use temp_env::with_var;
use crate::user::default_storage_dir;
use crate::{ServerSettingsBuilder, SettingsLayer};
fn parse(source: &str) -> SettingsLayer {
let mut layer = parse_settings_layer(source).expect("fixture should parse");
let mut layer = source
.parse::<SettingsLayer>()
.expect("fixture should parse");
layer.ensure_test_auth_methods();
layer
}
@ -22,10 +26,39 @@ fn empty_settings_with_auth_methods() -> SettingsLayer {
SettingsLayer::test_default()
}
fn dev_token_auth_enabled(layer: &SettingsLayer) -> bool {
layer
.server
.as_ref()
.and_then(|server| server.auth.as_ref())
.and_then(|auth| auth.methods.as_ref())
.is_some_and(|methods| methods.contains(&ServerAuthMethod::DevToken))
}
fn resolve_server(file: &SettingsLayer) -> ServerNamespace {
ServerSettingsBuilder::from_layer(file)
.expect("server settings should resolve")
.server
}
fn resolve_errors(error: fabro_config::Error) -> Vec<fabro_config::ResolveError> {
match error {
fabro_config::Error::Resolve { errors, .. } => errors,
other => panic!("expected resolve error, got {other:#}"),
}
}
fn render_resolve_error_lines(error: fabro_config::Error) -> String {
resolve_errors(error)
.into_iter()
.map(|error| error.to_string())
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn resolves_server_defaults_from_empty_settings() {
let settings = fabro_config::resolve_server_from_file(&empty_settings_with_auth_methods())
.expect("server settings should resolve");
let settings = resolve_server(&empty_settings_with_auth_methods());
assert_eq!(
settings.storage.root.as_source(),
@ -92,18 +125,14 @@ session_sandboxes = true
"#,
);
let context =
fabro_config::ServerSettings::from_layer(&settings).expect("settings should resolve");
let context = fabro_config::ServerSettingsBuilder::from_layer(&settings)
.expect("settings should resolve");
let user_settings = fabro_config::UserSettingsBuilder::from_layer(&settings)
.expect("user settings should resolve");
assert_eq!(
context.server,
fabro_config::resolve_server_from_file(&settings).expect("server namespace should resolve")
);
assert_eq!(
context.features,
fabro_config::resolve_features_from_file(&settings)
.expect("features namespace should resolve")
);
assert_eq!(context.server.storage.root.as_source(), "/srv/fabro");
assert!(context.features.session_sandboxes);
assert_eq!(context.features, user_settings.features);
}
#[test]
@ -127,7 +156,8 @@ session_sandboxes = true
.unwrap();
with_var("FABRO_HOME", Some(home.path()), || {
let settings = fabro_config::ServerSettings::resolve().expect("settings should resolve");
let settings =
fabro_config::ServerSettingsBuilder::load_default().expect("settings should resolve");
assert_eq!(settings.server.storage.root.as_source(), "/srv/from-home");
assert!(settings.features.session_sandboxes);
});
@ -135,8 +165,7 @@ session_sandboxes = true
#[test]
fn parsing_rejects_inbound_listener_tls_configuration() {
let err = fabro_config::parse_settings_layer(
r#"
let err = r#"
_version = 1
[server.listen]
@ -145,8 +174,8 @@ address = "127.0.0.1:32276"
[server.listen.tls]
cert = "/etc/fabro/server.pem"
"#,
)
"#
.parse::<SettingsLayer>()
.expect_err("listener TLS should be rejected at parse time");
assert!(err.to_string().contains("unknown field `tls`"));
@ -166,13 +195,10 @@ endpoint = "{{ env.S3_ENDPOINT }}"
"#,
);
let errors = fabro_config::resolve_server_from_file(&file)
.expect_err("s3 config without bucket/region should fail");
let rendered = errors
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
let rendered = render_resolve_error_lines(
ServerSettingsBuilder::from_layer(&file)
.expect_err("s3 config without bucket/region should fail"),
);
assert!(rendered.contains("server.artifacts.s3.bucket"));
assert!(rendered.contains("server.artifacts.s3.region"));
@ -195,8 +221,7 @@ slug = "fabro-app"
"#,
);
let settings =
fabro_config::resolve_server_from_file(&file).expect("server settings should resolve");
let settings = resolve_server(&file);
match settings.listen {
ServerListenSettings::Unix { path } => {
@ -230,8 +255,7 @@ strategy = "app"
"#,
);
let settings =
fabro_config::resolve_server_from_file(&file).expect("server settings should resolve");
let settings = resolve_server(&file);
assert_eq!(
settings.integrations.github.strategy,
@ -250,8 +274,7 @@ enabled = true
",
);
let settings =
fabro_config::resolve_server_from_file(&file).expect("server settings should resolve");
let settings = resolve_server(&file);
assert_eq!(
settings.integrations.github.strategy,
@ -270,15 +293,14 @@ disk_cache = true
",
);
let settings = fabro_config::resolve_server_from_file(&file).expect("settings should resolve");
let settings = resolve_server(&file);
assert!(settings.slatedb.disk_cache);
}
#[test]
fn resolves_empty_ip_allowlist_by_default() {
let settings = fabro_config::resolve_server_from_file(&empty_settings_with_auth_methods())
.expect("server settings should resolve");
let settings = resolve_server(&empty_settings_with_auth_methods());
assert!(settings.ip_allowlist.entries.is_empty());
assert_eq!(settings.ip_allowlist.trusted_proxy_count, 0);
@ -296,8 +318,7 @@ trusted_proxy_count = 2
"#,
);
let settings =
fabro_config::resolve_server_from_file(&file).expect("server settings should resolve");
let settings = resolve_server(&file);
assert_eq!(settings.ip_allowlist.entries, vec![
IpAllowEntry::parse_literal("10.0.0.0/8").unwrap(),
@ -322,8 +343,7 @@ entries = ["github_meta_hooks"]
"#,
);
let settings =
fabro_config::resolve_server_from_file(&file).expect("server settings should resolve");
let settings = resolve_server(&file);
let webhook_allowlist = settings
.integrations
.github
@ -354,8 +374,7 @@ trusted_proxy_count = 3
"#,
);
let settings =
fabro_config::resolve_server_from_file(&file).expect("server settings should resolve");
let settings = resolve_server(&file);
let webhook_allowlist = settings
.integrations
.github
@ -382,13 +401,10 @@ strategy = "server_url"
"#,
);
let errors = fabro_config::resolve_server_from_file(&file)
.expect_err("server_url webhook strategy should require server.api.url");
let rendered = errors
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
let rendered = render_resolve_error_lines(
ServerSettingsBuilder::from_layer(&file)
.expect_err("server_url webhook strategy should require server.api.url"),
);
assert!(rendered.contains("server.api.url"));
}
@ -407,13 +423,9 @@ strategy = "tailscale_funnel"
"#,
);
let errors = fabro_config::resolve_server_from_file(&file)
.expect_err("configured webhook strategy should require server.integrations.github.app_id");
let rendered = errors
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
let rendered = render_resolve_error_lines(ServerSettingsBuilder::from_layer(&file).expect_err(
"configured webhook strategy should require server.integrations.github.app_id",
));
assert!(rendered.contains("server.integrations.github.app_id"));
}
@ -429,13 +441,9 @@ entries = ["10.0.0.0/33"]
"#,
);
let errors =
fabro_config::resolve_server_from_file(&file).expect_err("invalid CIDR should fail");
let rendered = errors
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
let rendered = render_resolve_error_lines(
ServerSettingsBuilder::from_layer(&file).expect_err("invalid CIDR should fail"),
);
assert!(rendered.contains("server.ip_allowlist.entries[0]"));
}
@ -451,13 +459,10 @@ entries = ["github_meta_hooks"]
"#,
);
let errors = fabro_config::resolve_server_from_file(&file)
.expect_err("github_meta_hooks should be rejected outside github webhooks");
let rendered = errors
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
let rendered = render_resolve_error_lines(
ServerSettingsBuilder::from_layer(&file)
.expect_err("github_meta_hooks should be rejected outside github webhooks"),
);
assert!(rendered.contains("server.ip_allowlist.entries[0]"));
}
@ -477,13 +482,10 @@ entries = ["10.0.0.0/8"]
"#,
);
let errors = fabro_config::resolve_server_from_file(&file)
.expect_err("unix allowlist without trusted proxies should fail");
let rendered = errors
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
let rendered = render_resolve_error_lines(
ServerSettingsBuilder::from_layer(&file)
.expect_err("unix allowlist without trusted proxies should fail"),
);
assert!(rendered.contains("server.ip_allowlist.trusted_proxy_count"));
}
@ -503,13 +505,10 @@ entries = ["github_meta_hooks"]
"#,
);
let errors = fabro_config::resolve_server_from_file(&file)
.expect_err("unix github webhook allowlist without trusted proxies should fail");
let rendered = errors
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
let rendered = render_resolve_error_lines(
ServerSettingsBuilder::from_layer(&file)
.expect_err("unix github webhook allowlist without trusted proxies should fail"),
);
assert!(
rendered.contains("server.integrations.github.webhooks.ip_allowlist.trusted_proxy_count")
@ -517,9 +516,11 @@ entries = ["github_meta_hooks"]
}
#[test]
fn resolve_storage_root_defaults_without_server_auth_methods() {
fn resolve_storage_root_defaults_with_minimal_server_auth_methods() {
let settings = ServerSettingsBuilder::from_layer(&empty_settings_with_auth_methods())
.expect("default server settings should resolve");
assert_eq!(
fabro_config::resolve_storage_root(&SettingsLayer::default()).as_source(),
settings.server.storage.root.as_source(),
default_storage_dir().to_string_lossy()
);
}
@ -534,11 +535,10 @@ _version = 1
root = "/srv/fabro"
"#,
);
let settings =
ServerSettingsBuilder::from_layer(&file).expect("server settings should resolve");
assert_eq!(
fabro_config::resolve_storage_root(&file).as_source(),
"/srv/fabro"
);
assert_eq!(settings.server.storage.root.as_source(), "/srv/fabro");
}
#[test]
@ -551,9 +551,11 @@ _version = 1
root = "{{ env.FABRO_STORAGE_ROOT }}"
"#,
);
let settings =
ServerSettingsBuilder::from_layer(&file).expect("server settings should resolve");
assert_eq!(
fabro_config::resolve_storage_root(&file),
settings.server.storage.root,
InterpString::parse("{{ env.FABRO_STORAGE_ROOT }}")
);
}
@ -585,10 +587,8 @@ methods = ["dev-token", "github"]
"#,
);
assert!(fabro_config::dev_token_auth_enabled(&dev_token_only));
assert!(!fabro_config::dev_token_auth_enabled(&github_only));
assert!(fabro_config::dev_token_auth_enabled(&both));
assert!(!fabro_config::dev_token_auth_enabled(
&SettingsLayer::default()
));
assert!(dev_token_auth_enabled(&dev_token_only));
assert!(!dev_token_auth_enabled(&github_only));
assert!(dev_token_auth_enabled(&both));
assert!(!dev_token_auth_enabled(&SettingsLayer::default()));
}

View file

@ -1,11 +1,12 @@
use fabro_config::{parse_settings_layer, resolve_workflow_from_file};
use fabro_types::settings::SettingsLayer;
use crate::{SettingsLayer, WorkflowSettingsBuilder};
#[test]
fn resolves_workflow_defaults_from_empty_settings() {
let settings = SettingsLayer::default();
let workflow = resolve_workflow_from_file(&settings).expect("empty settings should resolve");
let workflow = WorkflowSettingsBuilder::from_layer(&settings)
.expect("empty settings should resolve")
.workflow;
assert_eq!(workflow.graph, "workflow.fabro");
assert!(workflow.name.is_none());
@ -15,7 +16,7 @@ fn resolves_workflow_defaults_from_empty_settings() {
#[test]
fn resolves_workflow_graph_and_metadata() {
let settings: SettingsLayer = parse_settings_layer(
let workflow = WorkflowSettingsBuilder::from_toml(
r#"
_version = 1
@ -28,9 +29,8 @@ graph = "graphs/ship.dot"
tier = "gold"
"#,
)
.expect("fixture should parse");
let workflow = resolve_workflow_from_file(&settings).expect("workflow settings should resolve");
.expect("workflow settings should resolve")
.workflow;
assert_eq!(workflow.name.as_deref(), Some("Ship"));
assert_eq!(workflow.description.as_deref(), Some("Primary flow"));

View file

@ -6,11 +6,9 @@
use std::path::{Path, PathBuf};
use fabro_types::settings::SettingsLayer;
use crate::Result;
use crate::home::Home;
use crate::load::load_settings_path;
use crate::{Result, SettingsLayer};
pub const SETTINGS_CONFIG_FILENAME: &str = "settings.toml";
pub const FABRO_CONFIG_ENV: &str = "FABRO_CONFIG";
@ -43,7 +41,7 @@ fn active_settings_path_with_lookup(
/// Load settings config from an explicit path or `~/.fabro/settings.toml`,
/// returning defaults if the default file doesn't exist. An explicit path that
/// doesn't exist is an error.
pub fn load_settings_config(path: Option<&Path>) -> Result<SettingsLayer> {
pub(crate) fn load_settings_config(path: Option<&Path>) -> Result<SettingsLayer> {
if let Some(explicit) = path
.map(Path::to_path_buf)
.or_else(|| std::env::var_os(FABRO_CONFIG_ENV).map(PathBuf::from))
@ -63,24 +61,6 @@ fn load_v2_layer_from_path(path: &Path) -> Result<SettingsLayer> {
load_settings_path(path)
}
/// Override the resolved storage root in a settings layer with a runtime path.
pub fn apply_storage_dir_override(
mut layer: SettingsLayer,
storage_dir: Option<&Path>,
) -> SettingsLayer {
use fabro_types::settings::interp::InterpString;
use fabro_types::settings::server::{ServerLayer, ServerStorageLayer};
if let Some(dir) = storage_dir {
let server = layer.server.get_or_insert_with(ServerLayer::default);
let storage = server
.storage
.get_or_insert_with(ServerStorageLayer::default);
storage.root = Some(InterpString::parse(&dir.display().to_string()));
}
layer
}
#[cfg(test)]
mod tests {
use super::{

View file

@ -1,28 +0,0 @@
use fabro_config::{parse_settings_layer, resolve_features_from_file};
use fabro_types::settings::SettingsLayer;
#[test]
fn resolves_features_defaults_from_empty_settings() {
let settings = SettingsLayer::default();
let features = resolve_features_from_file(&settings).expect("empty settings should resolve");
assert!(!features.session_sandboxes);
}
#[test]
fn resolves_session_sandboxes_flag() {
let settings: SettingsLayer = parse_settings_layer(
r"
_version = 1
[features]
session_sandboxes = true
",
)
.expect("fixture should parse");
let features = resolve_features_from_file(&settings).expect("features should resolve");
assert!(features.session_sandboxes);
}

View file

@ -468,7 +468,7 @@ pub fn persist_install_outputs_direct(
#[cfg(test)]
mod tests {
use fabro_config::{Storage, envfile};
use fabro_config::{ServerSettingsBuilder, Storage, envfile};
use fabro_vault::{SecretType as VaultSecretType, Vault};
use super::{
@ -492,16 +492,12 @@ mod tests {
#[test]
fn config_toml_has_auth_strategies() {
use fabro_types::settings::{ServerAuthMethod, SettingsLayer};
use fabro_types::settings::ServerAuthMethod;
let toml_str = format_config_toml();
let cfg: SettingsLayer = fabro_config::parse_settings_layer(&toml_str).unwrap();
let auth = cfg
.server
.as_ref()
.and_then(|s| s.auth.as_ref())
.expect("server.auth should be set");
assert_eq!(auth.methods, Some(vec![ServerAuthMethod::DevToken]));
let cfg =
ServerSettingsBuilder::from_toml(&toml_str).expect("generated config should resolve");
assert_eq!(cfg.server.auth.methods, vec![ServerAuthMethod::DevToken]);
}
#[test]
@ -657,12 +653,11 @@ name = "custom"
)
.unwrap();
let settings = fabro_config::parse_settings_layer(
let resolved = ServerSettingsBuilder::from_toml(
&toml::to_string_pretty(&doc).expect("settings should serialize"),
)
.expect("settings should parse");
let resolved =
fabro_config::resolve_server_from_file(&settings).expect("settings should resolve");
.expect("settings should resolve")
.server;
match resolved.listen {
ServerListenSettings::Tcp { address, .. } => {
assert_eq!(address.to_string(), "0.0.0.0:32276");

View file

@ -174,12 +174,12 @@ fn impl_combine(ast: &DeriveInput) -> TokenStream {
let combines = fields.iter().map(|field| {
let name = &field.ident;
quote! {
#name: crate::settings::Combine::combine(self.#name, other.#name)
#name: ::fabro_config::layers::Combine::combine(self.#name, other.#name)
}
});
quote! {
impl #impl_generics crate::settings::Combine for #name #ty_generics #where_clause {
impl #impl_generics ::fabro_config::layers::Combine for #name #ty_generics #where_clause {
fn combine(self, other: Self) -> Self {
Self {
#(#combines),*

View file

@ -1301,12 +1301,9 @@ mod tests {
use axum_extra::extract::cookie::Key;
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use fabro_config::{RunLayer, ServerSettingsBuilder};
use fabro_types::RunAuthMethod;
use fabro_types::settings::SettingsLayer;
use fabro_types::settings::server::{
GithubIntegrationLayer, ServerAuthGithubLayer, ServerAuthLayer, ServerAuthMethod,
ServerIntegrationsLayer, ServerLayer, ServerWebLayer,
};
use fabro_types::settings::server::ServerAuthMethod;
use serde_json::json;
use sha2::{Digest, Sha256};
use tokio::sync::Barrier;
@ -1343,35 +1340,34 @@ mod tests {
AuthMode::Enabled(config)
}
fn github_settings(web_url: &str) -> SettingsLayer {
SettingsLayer {
server: Some(ServerLayer {
web: Some(ServerWebLayer {
enabled: Some(true),
url: Some(web_url.into()),
}),
auth: Some(ServerAuthLayer {
methods: Some(vec![ServerAuthMethod::Github]),
github: Some(ServerAuthGithubLayer {
allowed_usernames: vec!["octocat".to_string()],
}),
}),
integrations: Some(ServerIntegrationsLayer {
github: Some(GithubIntegrationLayer {
client_id: Some("github-client-id".into()),
..GithubIntegrationLayer::default()
}),
..ServerIntegrationsLayer::default()
}),
..ServerLayer::default()
}),
..SettingsLayer::default()
}
fn github_settings(web_url: &str) -> fabro_types::ServerSettings {
ServerSettingsBuilder::from_toml(&format!(
r#"
_version = 1
[server.web]
enabled = true
url = "{web_url}"
[server.auth]
methods = ["github"]
[server.auth.github]
allowed_usernames = ["octocat"]
[server.integrations.github]
client_id = "github-client-id"
"#
))
.expect("github settings should resolve")
}
fn test_router(settings: SettingsLayer) -> (axum::Router, Arc<crate::server::AppState>) {
let state = server::create_test_app_state_with_session_key(
fn test_router(
settings: fabro_types::ServerSettings,
) -> (axum::Router, Arc<crate::server::AppState>) {
let state = server::create_test_app_state_with_runtime_settings_and_session_key(
settings,
RunLayer::default(),
Some("cli-flow-test-key-material-0123456789"),
);
let app = axum::Router::new()

View file

@ -153,7 +153,8 @@ mod tests {
use axum::routing::get;
use axum::{Json, Router, middleware};
use cookie::{Cookie, CookieJar};
use fabro_types::settings::{ServerAuthMethod, SettingsLayer};
use fabro_config::{RunLayer, ServerSettingsBuilder};
use fabro_types::settings::ServerAuthMethod;
use fabro_types::{IdpIdentity, RunAuthMethod};
use serde_json::json;
use tower::ServiceExt;
@ -221,9 +222,22 @@ mod tests {
.layer(middleware::from_fn(demo_routing_middleware))
}
fn test_server_settings() -> fabro_types::ServerSettings {
ServerSettingsBuilder::from_toml(
r#"
_version = 1
[server.auth]
methods = ["dev-token"]
"#,
)
.expect("test settings should resolve")
}
fn test_state() -> Arc<server::AppState> {
server::create_test_app_state_with_session_key(
SettingsLayer::default(),
server::create_test_app_state_with_runtime_settings_and_session_key(
test_server_settings(),
RunLayer::default(),
Some(SESSION_SECRET),
)
}

Some files were not shown because too many files have changed in this diff Show more