mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
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:
commit
4e9dd6fb52
165 changed files with 6136 additions and 4436 deletions
3
Cargo.lock
generated
3
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
54
lib/crates/fabro-api/tests/workflow_settings_round_trip.rs
Normal file
54
lib/crates/fabro-api/tests/workflow_settings_round_trip.rs
Normal 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>()
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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(());
|
||||
|
|
|
|||
|
|
@ -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)?
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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?;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)?;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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?;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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?;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)?;
|
||||
|
||||
|
|
|
|||
|
|
@ -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?;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
543
lib/crates/fabro-config/src/builders.rs
Normal file
543
lib/crates/fabro-config/src/builders.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
108
lib/crates/fabro-config/src/layers/cli.rs
Normal file
108
lib/crates/fabro-config/src/layers/cli.rs
Normal 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>,
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
14
lib/crates/fabro-config/src/layers/features.rs
Normal file
14
lib/crates/fabro-config/src/layers/features.rs
Normal 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>,
|
||||
}
|
||||
37
lib/crates/fabro-config/src/layers/mod.rs
Normal file
37
lib/crates/fabro-config/src/layers/mod.rs
Normal 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;
|
||||
21
lib/crates/fabro-config/src/layers/project.rs
Normal file
21
lib/crates/fabro-config/src/layers/project.rs
Normal 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>,
|
||||
}
|
||||
492
lib/crates/fabro-config/src/layers/run.rs
Normal file
492
lib/crates/fabro-config/src/layers/run.rs
Normal 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>,
|
||||
}
|
||||
260
lib/crates/fabro-config/src/layers/server.rs
Normal file
260
lib/crates/fabro-config/src/layers/server.rs
Normal 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>,
|
||||
}
|
||||
|
|
@ -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
|
||||
3
lib/crates/fabro-config/src/layers/splice_array.rs
Normal file
3
lib/crates/fabro-config/src/layers/splice_array.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
//! Shared splice marker literal for splice-capable arrays in raw settings.
|
||||
|
||||
pub(crate) const SPLICE_MARKER: &str = "...";
|
||||
20
lib/crates/fabro-config/src/layers/workflow.rs
Normal file
20
lib/crates/fabro-config/src/layers/workflow.rs
Normal 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>,
|
||||
}
|
||||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
@ -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");
|
||||
}
|
||||
9
lib/crates/fabro-config/src/tests/mod.rs
Normal file
9
lib/crates/fabro-config/src/tests/mod.rs
Normal 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;
|
||||
|
|
@ -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");
|
||||
28
lib/crates/fabro-config/src/tests/resolve_features.rs
Normal file
28
lib/crates/fabro-config/src/tests/resolve_features.rs
Normal 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);
|
||||
}
|
||||
|
|
@ -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"));
|
||||
|
|
@ -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]"));
|
||||
|
|
@ -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)) => {
|
||||
|
|
@ -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()));
|
||||
}
|
||||
|
|
@ -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"));
|
||||
|
|
@ -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::{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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),*
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue