From 288e73321343a44e2126ffb8303718662df7ec66 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 9 Apr 2026 09:02:45 -0400 Subject: [PATCH 01/47] feat(types): add settings v2 parse tree scaffolding Stage 1 of the settings TOML redesign. Introduces the namespaced v2 schema module alongside the existing flat Settings shape so the workspace still builds while the new parser architecture comes online. - value-language helpers with full unit-test coverage: - Duration: single-unit suffixes (ms, s, m, h, d); rejects composed values like '1h30m'; canonical renderer picks the largest unit - Size: decimal (KB, MB, GB, TB) and binary (KiB, MiB, GiB, TiB) units; bare integers default to GB; canonical renderer picks the largest decimal unit - ModelRef: bare vs qualified forms with a ModelRegistry trait for later ambiguity resolution - InterpString: ${env.NAME} tokens with whole-value, substring, and multi-token support; provenance tagging for outward-facing redaction - SpliceArray: '...' marker with append, prepend, and single-marker enforcement - SchemaVersion pre-validation: missing defaults to 1, legacy 'version' key hard-fails with a rename hint, unsupported higher versions hard-fail with an upgrade hint - SettingsFile top-level sparse parse tree with strict unknown-key rejection and targeted rename hints for every legacy top-level section (llm, vars, exec, fabro, setup, sandbox, etc.) - Skeleton ProjectLayer/WorkflowLayer/RunLayer/CliLayer/ServerLayer/ FeaturesLayer with deny_unknown_fields; full subtree fleshed out in Stage 2 65 new unit tests all passing. fabro-types is clippy-clean under -D warnings. --- Cargo.lock | 1 + lib/crates/fabro-types/Cargo.toml | 1 + lib/crates/fabro-types/src/settings/mod.rs | 1 + lib/crates/fabro-types/src/settings/v2/cli.rs | 14 + .../fabro-types/src/settings/v2/duration.rs | 303 ++++++++++++++ .../fabro-types/src/settings/v2/features.rs | 17 + .../fabro-types/src/settings/v2/interp.rs | 335 ++++++++++++++++ lib/crates/fabro-types/src/settings/v2/mod.rs | 37 ++ .../fabro-types/src/settings/v2/model_ref.rs | 375 ++++++++++++++++++ .../fabro-types/src/settings/v2/project.rs | 24 ++ lib/crates/fabro-types/src/settings/v2/run.rs | 26 ++ .../fabro-types/src/settings/v2/server.rs | 13 + .../fabro-types/src/settings/v2/size.rs | 313 +++++++++++++++ .../src/settings/v2/splice_array.rs | 261 ++++++++++++ .../fabro-types/src/settings/v2/tree.rs | 223 +++++++++++ .../fabro-types/src/settings/v2/version.rs | 111 ++++++ .../fabro-types/src/settings/v2/workflow.rs | 23 ++ 17 files changed, 2078 insertions(+) create mode 100644 lib/crates/fabro-types/src/settings/v2/cli.rs create mode 100644 lib/crates/fabro-types/src/settings/v2/duration.rs create mode 100644 lib/crates/fabro-types/src/settings/v2/features.rs create mode 100644 lib/crates/fabro-types/src/settings/v2/interp.rs create mode 100644 lib/crates/fabro-types/src/settings/v2/mod.rs create mode 100644 lib/crates/fabro-types/src/settings/v2/model_ref.rs create mode 100644 lib/crates/fabro-types/src/settings/v2/project.rs create mode 100644 lib/crates/fabro-types/src/settings/v2/run.rs create mode 100644 lib/crates/fabro-types/src/settings/v2/server.rs create mode 100644 lib/crates/fabro-types/src/settings/v2/size.rs create mode 100644 lib/crates/fabro-types/src/settings/v2/splice_array.rs create mode 100644 lib/crates/fabro-types/src/settings/v2/tree.rs create mode 100644 lib/crates/fabro-types/src/settings/v2/version.rs create mode 100644 lib/crates/fabro-types/src/settings/v2/workflow.rs diff --git a/Cargo.lock b/Cargo.lock index 7986ddaa5..d538cf560 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2059,6 +2059,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "toml 0.8.23", "ulid", ] diff --git a/lib/crates/fabro-types/Cargo.toml b/lib/crates/fabro-types/Cargo.toml index 9ca57b410..aed2dee7f 100644 --- a/lib/crates/fabro-types/Cargo.toml +++ b/lib/crates/fabro-types/Cargo.toml @@ -27,4 +27,5 @@ hex.workspace = true serde.workspace = true serde_json.workspace = true sha2.workspace = true +toml.workspace = true ulid.workspace = true diff --git a/lib/crates/fabro-types/src/settings/mod.rs b/lib/crates/fabro-types/src/settings/mod.rs index 229b65b36..1393e6845 100644 --- a/lib/crates/fabro-types/src/settings/mod.rs +++ b/lib/crates/fabro-types/src/settings/mod.rs @@ -10,6 +10,7 @@ pub mod run; pub mod sandbox; pub mod server; pub mod user; +pub mod v2; pub use hook::{HookDefinition, HookEvent, HookSettings, HookType, TlsMode}; pub use mcp::{ diff --git a/lib/crates/fabro-types/src/settings/v2/cli.rs b/lib/crates/fabro-types/src/settings/v2/cli.rs new file mode 100644 index 000000000..1946a50d1 --- /dev/null +++ b/lib/crates/fabro-types/src/settings/v2/cli.rs @@ -0,0 +1,14 @@ +//! CLI domain. +//! +//! `[cli]` is owner-first: the CLI process reads its settings from +//! `~/.fabro/settings.toml` plus process-local overrides. `cli.*` stanzas in +//! `fabro.toml` and `workflow.toml` remain schema-valid but runtime-inert. +//! This file holds only the Stage-1 skeleton; Stage 2 fleshes out the full +//! subtree (target, auth, exec, output, updates, logging). + +use serde::{Deserialize, Serialize}; + +/// A sparse `[cli]` layer as it appears in a single settings file. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CliLayer; diff --git a/lib/crates/fabro-types/src/settings/v2/duration.rs b/lib/crates/fabro-types/src/settings/v2/duration.rs new file mode 100644 index 000000000..f3698fd9e --- /dev/null +++ b/lib/crates/fabro-types/src/settings/v2/duration.rs @@ -0,0 +1,303 @@ +//! Config-facing duration values. +//! +//! Accepts a single-unit suffix per value: `ms`, `s`, `m`, `h`, or `d`. +//! Composed values like `1h30m` are rejected. The canonical renderer emits +//! the same single-unit form. + +use std::fmt; +use std::str::FromStr; +use std::time::Duration as StdDuration; + +use serde::de::{self, Visitor}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +/// A duration parsed from a single-unit human-readable string. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Duration(StdDuration); + +impl Duration { + #[must_use] + pub const fn from_std(duration: StdDuration) -> Self { + Self(duration) + } + + #[must_use] + pub const fn as_std(&self) -> StdDuration { + self.0 + } + + #[must_use] + pub const fn from_secs(secs: u64) -> Self { + Self(StdDuration::from_secs(secs)) + } + + #[must_use] + pub const fn from_millis(millis: u64) -> Self { + Self(StdDuration::from_millis(millis)) + } +} + +impl From for Duration { + fn from(value: StdDuration) -> Self { + Self(value) + } +} + +impl From for StdDuration { + fn from(value: Duration) -> Self { + value.0 + } +} + +/// An error returned when parsing a duration string fails. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ParseDurationError { + /// The input was empty or whitespace only. + Empty, + /// The input had a numeric portion that could not be parsed as a non-negative integer. + InvalidNumber { input: String }, + /// The input had no unit suffix. + MissingUnit { input: String }, + /// The input had an unrecognized unit suffix. + InvalidUnit { input: String, unit: String }, + /// The input contained more than one unit (e.g. `1h30m`), which is not supported. + Composed { input: String }, +} + +impl fmt::Display for ParseDurationError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Empty => f.write_str("duration value is empty"), + Self::InvalidNumber { input } => { + write!( + f, + "duration {input:?}: numeric portion is not a non-negative integer" + ) + } + Self::MissingUnit { input } => { + write!( + f, + "duration {input:?}: missing unit suffix (expected one of ms, s, m, h, d)" + ) + } + Self::InvalidUnit { input, unit } => { + write!( + f, + "duration {input:?}: unknown unit {unit:?} (expected one of ms, s, m, h, d)" + ) + } + Self::Composed { input } => { + write!( + f, + "duration {input:?}: composed values like 1h30m are not supported; use the smallest needed unit instead" + ) + } + } + } +} + +impl std::error::Error for ParseDurationError {} + +impl FromStr for Duration { + type Err = ParseDurationError; + + fn from_str(input: &str) -> Result { + let trimmed = input.trim(); + if trimmed.is_empty() { + return Err(ParseDurationError::Empty); + } + + // Split numeric prefix from alphabetic suffix. Reject interior alpha runs. + let (num, unit) = + split_number_and_suffix(trimmed).ok_or_else(|| ParseDurationError::MissingUnit { + input: input.to_owned(), + })?; + + if num.is_empty() { + return Err(ParseDurationError::InvalidNumber { + input: input.to_owned(), + }); + } + + // A composed value like "1h30m" leaves digits inside the suffix. + if unit.chars().any(|c| c.is_ascii_digit()) { + return Err(ParseDurationError::Composed { + input: input.to_owned(), + }); + } + + let n: u64 = num.parse().map_err(|_| ParseDurationError::InvalidNumber { + input: input.to_owned(), + })?; + + let duration = match unit { + "ms" => StdDuration::from_millis(n), + "s" => StdDuration::from_secs(n), + "m" => StdDuration::from_secs(n.saturating_mul(60)), + "h" => StdDuration::from_secs(n.saturating_mul(60 * 60)), + "d" => StdDuration::from_secs(n.saturating_mul(24 * 60 * 60)), + other => { + return Err(ParseDurationError::InvalidUnit { + input: input.to_owned(), + unit: other.to_owned(), + }); + } + }; + + Ok(Self(duration)) + } +} + +/// Split a trimmed string into `(numeric_prefix, alphabetic_suffix)`. +/// +/// Returns `None` if there is no alphabetic suffix or the string is all alpha. +fn split_number_and_suffix(input: &str) -> Option<(&str, &str)> { + let first_alpha = input.find(|c: char| !c.is_ascii_digit())?; + if first_alpha == 0 { + return None; + } + Some((&input[..first_alpha], &input[first_alpha..])) +} + +impl fmt::Display for Duration { + /// Canonical rendering picks the largest unit that represents the value as an + /// integer multiple, preferring `d`, `h`, `m`, `s`, `ms` in that order. + /// Zero renders as `0s`. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + const MS_PER_S: u128 = 1_000; + const MS_PER_M: u128 = 60 * MS_PER_S; + const MS_PER_H: u128 = 60 * MS_PER_M; + const MS_PER_D: u128 = 24 * MS_PER_H; + + let total_ms = self.0.as_millis(); + if total_ms == 0 { + return f.write_str("0s"); + } + if total_ms.is_multiple_of(MS_PER_D) { + write!(f, "{}d", total_ms / MS_PER_D) + } else if total_ms.is_multiple_of(MS_PER_H) { + write!(f, "{}h", total_ms / MS_PER_H) + } else if total_ms.is_multiple_of(MS_PER_M) { + write!(f, "{}m", total_ms / MS_PER_M) + } else if total_ms.is_multiple_of(MS_PER_S) { + write!(f, "{}s", total_ms / MS_PER_S) + } else { + write!(f, "{total_ms}ms") + } + } +} + +impl Serialize for Duration { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> Deserialize<'de> for Duration { + fn deserialize>(deserializer: D) -> Result { + struct DurationVisitor; + + impl Visitor<'_> for DurationVisitor { + type Value = Duration; + + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(r#"a duration string such as "30s", "1m", or "1h""#) + } + + fn visit_str(self, value: &str) -> Result { + value.parse().map_err(de::Error::custom) + } + + fn visit_string(self, value: String) -> Result { + self.visit_str(&value) + } + } + + deserializer.deserialize_str(DurationVisitor) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_each_unit() { + assert_eq!( + "500ms".parse::().unwrap(), + Duration::from_millis(500) + ); + assert_eq!("30s".parse::().unwrap(), Duration::from_secs(30)); + assert_eq!("2m".parse::().unwrap(), Duration::from_secs(120)); + assert_eq!( + "1h".parse::().unwrap(), + Duration::from_secs(3_600) + ); + assert_eq!( + "1d".parse::().unwrap(), + Duration::from_secs(86_400) + ); + } + + #[test] + fn rejects_composed_values() { + let err = "1h30m".parse::().unwrap_err(); + assert!(matches!(err, ParseDurationError::Composed { .. })); + } + + #[test] + fn rejects_missing_unit() { + let err = "30".parse::().unwrap_err(); + assert!(matches!(err, ParseDurationError::MissingUnit { .. })); + } + + #[test] + fn rejects_unknown_unit() { + let err = "1w".parse::().unwrap_err(); + assert!(matches!(err, ParseDurationError::InvalidUnit { unit, .. } if unit == "w")); + } + + #[test] + fn rejects_empty() { + let err = "".parse::().unwrap_err(); + assert!(matches!(err, ParseDurationError::Empty)); + } + + #[test] + fn canonical_render_picks_largest_unit() { + assert_eq!(Duration::from_secs(86_400).to_string(), "1d"); + assert_eq!(Duration::from_secs(3_600).to_string(), "1h"); + assert_eq!(Duration::from_secs(120).to_string(), "2m"); + assert_eq!(Duration::from_secs(30).to_string(), "30s"); + assert_eq!(Duration::from_millis(500).to_string(), "500ms"); + assert_eq!(Duration::from_millis(0).to_string(), "0s"); + } + + #[test] + fn canonical_render_rounds_down_units_that_do_not_divide() { + // 90s cannot render as a whole number of minutes. + assert_eq!(Duration::from_secs(90).to_string(), "90s"); + } + + #[test] + fn round_trip_through_parse_and_display() { + for input in ["500ms", "30s", "1m", "2h", "3d"] { + let parsed: Duration = input.parse().unwrap(); + assert_eq!(parsed.to_string(), input); + } + } + + #[test] + fn serde_round_trip_via_json() { + #[derive(Debug, serde::Deserialize, serde::Serialize, PartialEq)] + struct Wrap { + d: Duration, + } + + let input = r#"{"d":"30s"}"#; + let parsed: Wrap = serde_json::from_str(input).unwrap(); + assert_eq!(parsed.d, Duration::from_secs(30)); + let rendered = serde_json::to_string(&parsed).unwrap(); + assert_eq!(rendered, r#"{"d":"30s"}"#); + } +} diff --git a/lib/crates/fabro-types/src/settings/v2/features.rs b/lib/crates/fabro-types/src/settings/v2/features.rs new file mode 100644 index 000000000..a590076ff --- /dev/null +++ b/lib/crates/fabro-types/src/settings/v2/features.rs @@ -0,0 +1,17 @@ +//! Features domain. +//! +//! `[features]` is a reserved cross-cutting namespace for Fabro capability +//! flags only. It has a high admission bar and must not become a junk drawer. + +use serde::{Deserialize, Serialize}; + +/// A sparse `[features]` layer as it appears in a single settings file. +/// +/// Every field is an `Option` 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, +} diff --git a/lib/crates/fabro-types/src/settings/v2/interp.rs b/lib/crates/fabro-types/src/settings/v2/interp.rs new file mode 100644 index 000000000..ef8e43b3a --- /dev/null +++ b/lib/crates/fabro-types/src/settings/v2/interp.rs @@ -0,0 +1,335 @@ +//! Env var interpolation for config strings. +//! +//! Any string field may use `${env.NAME}` tokens, either as a whole value or +//! as one or more substrings inside a larger string. Resolution happens only +//! when the field is consumed, and provenance tracking lets outward-facing +//! renderers redact env-sourced values uniformly. + +use std::fmt; + +use serde::de::{self, Visitor}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +/// A config string that may contain `${env.NAME}` tokens. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InterpString { + segments: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum Segment { + Literal(String), + EnvVar(String), +} + +impl InterpString { + /// Parse a raw string into its literal/env-var segments. + /// + /// Parsing is infallible: the token grammar is intentionally permissive so + /// that validation happens at consumption time along with env lookup. + #[must_use] + pub fn parse(input: &str) -> Self { + let mut segments: Vec = Vec::new(); + let mut rest = input; + + while let Some(start) = rest.find("${env.") { + if start > 0 { + segments.push(Segment::Literal(rest[..start].to_owned())); + } + // rest[start..] begins with "${env." + let after_prefix = &rest[start + "${env.".len()..]; + if let Some(close) = after_prefix.find('}') { + let name = after_prefix[..close].to_owned(); + segments.push(Segment::EnvVar(name)); + rest = &after_prefix[close + 1..]; + } else { + // Unterminated token — treat the remainder as literal text. + segments.push(Segment::Literal(rest[start..].to_owned())); + rest = ""; + break; + } + } + + if !rest.is_empty() { + segments.push(Segment::Literal(rest.to_owned())); + } + + if segments.is_empty() { + segments.push(Segment::Literal(String::new())); + } + + Self { segments } + } + + /// True when this string contains no env var tokens. + #[must_use] + pub fn is_literal(&self) -> bool { + self.segments + .iter() + .all(|seg| matches!(seg, Segment::Literal(_))) + } + + /// True when this string contains at least one env var token. + #[must_use] + pub fn references_env(&self) -> bool { + self.segments + .iter() + .any(|seg| matches!(seg, Segment::EnvVar(_))) + } + + /// The env var names referenced by this string, in source order. + #[must_use] + pub fn env_var_names(&self) -> Vec<&str> { + self.segments + .iter() + .filter_map(|seg| match seg { + Segment::EnvVar(name) => Some(name.as_str()), + Segment::Literal(_) => None, + }) + .collect() + } + + /// The raw source string. + #[must_use] + pub fn as_source(&self) -> String { + let mut out = String::new(); + for seg in &self.segments { + match seg { + Segment::Literal(text) => out.push_str(text), + Segment::EnvVar(name) => { + out.push_str("${env."); + out.push_str(name); + out.push('}'); + } + } + } + out + } + + /// Resolve this string using `lookup`, which should return the current + /// value for a given env var name (or `None` if unset). + /// + /// On success the caller gets the final string plus provenance describing + /// whether any env var contributed to the value. On failure the caller + /// learns which env var was unresolved. + pub fn resolve(&self, mut lookup: F) -> Result + where + F: FnMut(&str) -> Option, + { + let mut value = String::new(); + let mut used = Vec::new(); + for seg in &self.segments { + match seg { + Segment::Literal(text) => value.push_str(text), + Segment::EnvVar(name) => { + let Some(resolved) = lookup(name) else { + return Err(ResolveEnvError { name: name.clone() }); + }; + value.push_str(&resolved); + used.push(name.clone()); + } + } + } + + let provenance = if used.is_empty() { + Provenance::Literal + } else { + Provenance::EnvSourced { names: used } + }; + Ok(Resolved { value, provenance }) + } +} + +impl From for InterpString { + fn from(value: String) -> Self { + Self::parse(&value) + } +} + +impl From<&str> for InterpString { + fn from(value: &str) -> Self { + Self::parse(value) + } +} + +/// The outcome of a successful env interpolation resolution. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Resolved { + pub value: String, + pub provenance: Provenance, +} + +/// Provenance metadata for resolved config values. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Provenance { + /// No env var contributed to this value. + Literal, + /// One or more env vars contributed to this value. Used by outward-facing + /// renderers to redact env-sourced values uniformly. + EnvSourced { names: Vec }, +} + +/// An error returned when an env var referenced in a config string is not set. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolveEnvError { + pub name: String, +} + +impl fmt::Display for ResolveEnvError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "environment variable {:?} referenced by ${{env.{}}} is not set", + self.name, self.name + ) + } +} + +impl std::error::Error for ResolveEnvError {} + +impl Serialize for InterpString { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.as_source()) + } +} + +impl<'de> Deserialize<'de> for InterpString { + fn deserialize>(deserializer: D) -> Result { + struct InterpStringVisitor; + + impl Visitor<'_> for InterpStringVisitor { + type Value = InterpString; + + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("a string, optionally containing ${env.NAME} interpolation tokens") + } + + fn visit_str(self, value: &str) -> Result { + Ok(InterpString::parse(value)) + } + + fn visit_string(self, value: String) -> Result { + Ok(InterpString::parse(&value)) + } + } + + deserializer.deserialize_str(InterpStringVisitor) + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use super::*; + + fn lookup_from(values: &[(&str, &str)]) -> impl FnMut(&str) -> Option + 'static { + let map: HashMap = values + .iter() + .map(|(k, v)| ((*k).to_owned(), (*v).to_owned())) + .collect(); + move |name| map.get(name).cloned() + } + + #[test] + fn literal_string_has_no_env_refs() { + let s = InterpString::parse("hello world"); + assert!(s.is_literal()); + assert!(!s.references_env()); + assert_eq!(s.env_var_names(), Vec::<&str>::new()); + } + + #[test] + fn whole_value_env_reference() { + let s = InterpString::parse("${env.API_KEY}"); + assert!(!s.is_literal()); + assert_eq!(s.env_var_names(), vec!["API_KEY"]); + assert_eq!(s.as_source(), "${env.API_KEY}"); + } + + #[test] + fn substring_env_reference() { + let s = InterpString::parse("Bearer ${env.TOKEN}"); + assert_eq!(s.env_var_names(), vec!["TOKEN"]); + } + + #[test] + fn multi_token_env_reference() { + let s = InterpString::parse("${env.USER}@${env.HOST}:${env.PORT}"); + assert_eq!(s.env_var_names(), vec!["USER", "HOST", "PORT"]); + } + + #[test] + fn resolve_literal_string() { + let s = InterpString::parse("static"); + let resolved = s.resolve(lookup_from(&[])).unwrap(); + assert_eq!(resolved.value, "static"); + assert_eq!(resolved.provenance, Provenance::Literal); + } + + #[test] + fn resolve_whole_value() { + let s = InterpString::parse("${env.API_KEY}"); + let resolved = s + .resolve(lookup_from(&[("API_KEY", "secret-123")])) + .unwrap(); + assert_eq!(resolved.value, "secret-123"); + assert_eq!( + resolved.provenance, + Provenance::EnvSourced { + names: vec!["API_KEY".into()] + } + ); + } + + #[test] + fn resolve_substring() { + let s = InterpString::parse("Bearer ${env.TOKEN}"); + let resolved = s.resolve(lookup_from(&[("TOKEN", "abc")])).unwrap(); + assert_eq!(resolved.value, "Bearer abc"); + } + + #[test] + fn resolve_multiple_tokens() { + let s = InterpString::parse("${env.USER}@${env.HOST}"); + let resolved = s + .resolve(lookup_from(&[("USER", "root"), ("HOST", "example.com")])) + .unwrap(); + assert_eq!(resolved.value, "root@example.com"); + assert_eq!( + resolved.provenance, + Provenance::EnvSourced { + names: vec!["USER".into(), "HOST".into()] + } + ); + } + + #[test] + fn resolve_missing_env_fails_with_name() { + let s = InterpString::parse("${env.MISSING}"); + let err = s.resolve(lookup_from(&[])).unwrap_err(); + assert_eq!(err.name, "MISSING"); + } + + #[test] + fn unterminated_token_treated_as_literal() { + let s = InterpString::parse("${env.OPEN"); + let resolved = s.resolve(lookup_from(&[])).unwrap(); + assert_eq!(resolved.value, "${env.OPEN"); + assert_eq!(resolved.provenance, Provenance::Literal); + } + + #[test] + fn serde_round_trip_preserves_token_form() { + #[derive(Debug, serde::Deserialize, serde::Serialize, PartialEq)] + struct Wrap { + s: InterpString, + } + + let input = r#"{"s":"Bearer ${env.TOKEN}"}"#; + let parsed: Wrap = serde_json::from_str(input).unwrap(); + assert_eq!(parsed.s.as_source(), "Bearer ${env.TOKEN}"); + let rendered = serde_json::to_string(&parsed).unwrap(); + assert_eq!(rendered, input); + } +} diff --git a/lib/crates/fabro-types/src/settings/v2/mod.rs b/lib/crates/fabro-types/src/settings/v2/mod.rs new file mode 100644 index 000000000..c88494cb3 --- /dev/null +++ b/lib/crates/fabro-types/src/settings/v2/mod.rs @@ -0,0 +1,37 @@ +//! Namespaced settings schema (v2). +//! +//! This module is the hard-cut replacement for the flat [`super::Settings`] +//! shape. The top-level schema is strictly namespaced with `_version`, +//! `[project]`, `[workflow]`, `[run]`, `[cli]`, `[server]`, and `[features]`. +//! Value-language helpers live alongside the tree: durations, byte sizes, +//! model references, env interpolation, and splice-capable arrays. + +pub mod cli; +pub mod duration; +pub mod features; +pub mod interp; +pub mod model_ref; +pub mod project; +pub mod run; +pub mod server; +pub mod size; +pub mod splice_array; +pub mod tree; +pub mod version; +pub mod workflow; + +pub use cli::CliLayer; +pub use duration::{Duration, ParseDurationError}; +pub use features::FeaturesLayer; +pub use interp::{InterpString, Provenance, ResolveEnvError, Resolved}; +pub use model_ref::{ + AmbiguousModelRef, ModelRef, ModelRegistry, ParseModelRefError, ResolvedModelRef, +}; +pub use project::ProjectLayer; +pub use run::RunLayer; +pub use server::ServerLayer; +pub use size::{ParseSizeError, Size}; +pub use splice_array::{SPLICE_MARKER, SpliceArray, SpliceArrayError}; +pub use tree::{ParseError, SettingsFile, parse_settings_file}; +pub use version::{CURRENT_VERSION, SchemaVersion, VersionError, validate_version}; +pub use workflow::WorkflowLayer; diff --git a/lib/crates/fabro-types/src/settings/v2/model_ref.rs b/lib/crates/fabro-types/src/settings/v2/model_ref.rs new file mode 100644 index 000000000..6b2514559 --- /dev/null +++ b/lib/crates/fabro-types/src/settings/v2/model_ref.rs @@ -0,0 +1,375 @@ +//! Model references for `run.model.fallbacks`. +//! +//! Each entry is one of: +//! +//! - a bare token such as `openai` or `gpt-5.4` — the parser cannot tell alone +//! whether the token is a provider name or a model alias +//! - a qualified reference such as `gemini/gemini-flash`, which names both a +//! provider and a model +//! +//! The parser produces [`ModelRef`]; ambiguity resolution against a known +//! registry of providers and models happens at consumption time via +//! [`ModelRef::resolve`]. + +use std::fmt; +use std::str::FromStr; + +use serde::de::{self, Visitor}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +/// A parsed model reference. Bare tokens remain ambiguous until resolved +/// against a registry. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ModelRef { + /// A bare token. May be a provider name, a model alias, or a model id. + Bare(String), + /// A provider-qualified model reference. + Qualified { provider: String, model: String }, +} + +/// An error returned when parsing a model reference fails. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ParseModelRefError { + /// The input was empty or whitespace only. + Empty, + /// The input contained more than one `/`, which is not a valid qualified ref. + TooManySlashes { input: String }, + /// The provider or model side of a qualified reference was empty. + EmptySide { input: String }, +} + +impl fmt::Display for ParseModelRefError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Empty => f.write_str("model reference is empty"), + Self::TooManySlashes { input } => { + write!( + f, + "model reference {input:?}: expected at most one \"/\" separator between provider and model" + ) + } + Self::EmptySide { input } => { + write!( + f, + "model reference {input:?}: provider and model sides must both be non-empty" + ) + } + } + } +} + +impl std::error::Error for ParseModelRefError {} + +impl FromStr for ModelRef { + type Err = ParseModelRefError; + + fn from_str(input: &str) -> Result { + let trimmed = input.trim(); + if trimmed.is_empty() { + return Err(ParseModelRefError::Empty); + } + + let parts: Vec<&str> = trimmed.split('/').collect(); + match parts.as_slice() { + [bare] => Ok(Self::Bare((*bare).to_owned())), + [provider, model] => { + if provider.is_empty() || model.is_empty() { + Err(ParseModelRefError::EmptySide { + input: input.to_owned(), + }) + } else { + Ok(Self::Qualified { + provider: (*provider).to_owned(), + model: (*model).to_owned(), + }) + } + } + _ => Err(ParseModelRefError::TooManySlashes { + input: input.to_owned(), + }), + } + } +} + +impl fmt::Display for ModelRef { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Bare(token) => f.write_str(token), + Self::Qualified { provider, model } => write!(f, "{provider}/{model}"), + } + } +} + +/// An error returned when resolving an ambiguous bare model reference. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AmbiguousModelRef { + pub input: String, + pub providers: Vec, + pub models: Vec, +} + +impl fmt::Display for AmbiguousModelRef { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "model reference {:?} is ambiguous: matches provider names {:?} and model names {:?}; qualify it as \"provider/model\"", + self.input, self.providers, self.models + ) + } +} + +impl std::error::Error for AmbiguousModelRef {} + +/// The resolved form of a model reference after registry lookup. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ResolvedModelRef { + /// The reference named a provider; the runtime should pick the best model from that provider. + Provider(String), + /// The reference named a model (qualified or unambiguously bare). + Model { + provider: Option, + model: String, + }, +} + +/// A minimal registry view used by [`ModelRef::resolve`]. +/// +/// Each method reports whether a bare token is a known provider, model, or +/// both. The registry is abstract so unit tests and runtime resolution can +/// share the same logic. +pub trait ModelRegistry { + fn is_provider(&self, token: &str) -> bool; + fn is_model(&self, token: &str) -> bool; + /// Returns the canonical provider for a bare model token, when the registry + /// knows of a unique provider for that model. + fn provider_of(&self, token: &str) -> Option; +} + +impl ModelRef { + /// Resolve this reference against a registry. + /// + /// - [`ModelRef::Qualified`] always resolves to a model. + /// - [`ModelRef::Bare`] resolves to a provider if the token is only a provider, + /// to a model if the token is only a model, and returns [`AmbiguousModelRef`] + /// if the token matches both a provider and a model name. + pub fn resolve( + &self, + registry: &dyn ModelRegistry, + ) -> Result { + match self { + Self::Qualified { provider, model } => Ok(ResolvedModelRef::Model { + provider: Some(provider.clone()), + model: model.clone(), + }), + Self::Bare(token) => { + let is_provider = registry.is_provider(token); + let is_model = registry.is_model(token); + match (is_provider, is_model) { + (true, false) => Ok(ResolvedModelRef::Provider(token.clone())), + (false, true) => Ok(ResolvedModelRef::Model { + provider: registry.provider_of(token), + model: token.clone(), + }), + (true, true) => Err(AmbiguousModelRef { + input: token.clone(), + providers: vec![token.clone()], + models: vec![token.clone()], + }), + // Unknown tokens flow through as bare models — provider TBD at runtime. + (false, false) => Ok(ResolvedModelRef::Model { + provider: None, + model: token.clone(), + }), + } + } + } + } +} + +impl Serialize for ModelRef { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> Deserialize<'de> for ModelRef { + fn deserialize>(deserializer: D) -> Result { + struct ModelRefVisitor; + + impl Visitor<'_> for ModelRefVisitor { + type Value = ModelRef; + + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str( + r#"a model reference such as "openai", "gpt-5.4", or "gemini/gemini-flash""#, + ) + } + + fn visit_str(self, value: &str) -> Result { + value.parse().map_err(de::Error::custom) + } + + fn visit_string(self, value: String) -> Result { + self.visit_str(&value) + } + } + + deserializer.deserialize_str(ModelRefVisitor) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct TestRegistry { + providers: &'static [&'static str], + models: &'static [&'static str], + } + + impl ModelRegistry for TestRegistry { + fn is_provider(&self, token: &str) -> bool { + self.providers.contains(&token) + } + fn is_model(&self, token: &str) -> bool { + self.models.contains(&token) + } + fn provider_of(&self, token: &str) -> Option { + if self.models.contains(&token) { + Some("test".to_owned()) + } else { + None + } + } + } + + #[test] + fn parses_bare_token() { + assert_eq!( + "openai".parse::().unwrap(), + ModelRef::Bare("openai".into()) + ); + } + + #[test] + fn parses_qualified() { + assert_eq!( + "gemini/gemini-flash".parse::().unwrap(), + ModelRef::Qualified { + provider: "gemini".into(), + model: "gemini-flash".into() + } + ); + } + + #[test] + fn rejects_too_many_slashes() { + let err = "a/b/c".parse::().unwrap_err(); + assert!(matches!(err, ParseModelRefError::TooManySlashes { .. })); + } + + #[test] + fn rejects_empty_side() { + assert!(matches!( + "/foo".parse::().unwrap_err(), + ParseModelRefError::EmptySide { .. } + )); + assert!(matches!( + "foo/".parse::().unwrap_err(), + ParseModelRefError::EmptySide { .. } + )); + } + + #[test] + fn rejects_empty_input() { + assert!(matches!( + "".parse::().unwrap_err(), + ParseModelRefError::Empty + )); + } + + #[test] + fn resolves_unique_provider_token() { + let reg = TestRegistry { + providers: &["openai"], + models: &[], + }; + let resolved = ModelRef::Bare("openai".into()).resolve(®).unwrap(); + assert_eq!(resolved, ResolvedModelRef::Provider("openai".into())); + } + + #[test] + fn resolves_unique_model_token() { + let reg = TestRegistry { + providers: &[], + models: &["gpt-5.4"], + }; + let resolved = ModelRef::Bare("gpt-5.4".into()).resolve(®).unwrap(); + assert_eq!( + resolved, + ResolvedModelRef::Model { + provider: Some("test".into()), + model: "gpt-5.4".into() + } + ); + } + + #[test] + fn ambiguous_bare_token_errors() { + let reg = TestRegistry { + providers: &["ambiguous"], + models: &["ambiguous"], + }; + let err = ModelRef::Bare("ambiguous".into()) + .resolve(®) + .unwrap_err(); + assert_eq!(err.input, "ambiguous"); + } + + #[test] + fn qualified_never_ambiguous() { + let reg = TestRegistry { + providers: &["ambiguous"], + models: &["ambiguous"], + }; + let resolved = ModelRef::Qualified { + provider: "a".into(), + model: "b".into(), + } + .resolve(®) + .unwrap(); + assert_eq!( + resolved, + ResolvedModelRef::Model { + provider: Some("a".into()), + model: "b".into(), + } + ); + } + + #[test] + fn display_round_trip() { + for input in ["openai", "gpt-5.4", "gemini/gemini-flash"] { + let parsed: ModelRef = input.parse().unwrap(); + assert_eq!(parsed.to_string(), input); + } + } + + #[test] + fn serde_round_trip_via_json() { + #[derive(Debug, serde::Deserialize, serde::Serialize, PartialEq)] + struct Wrap { + m: ModelRef, + } + + let input = r#"{"m":"gemini/gemini-flash"}"#; + let parsed: Wrap = serde_json::from_str(input).unwrap(); + assert!(matches!( + parsed.m, + ModelRef::Qualified { ref provider, ref model } + if provider == "gemini" && model == "gemini-flash" + )); + let rendered = serde_json::to_string(&parsed).unwrap(); + assert_eq!(rendered, input); + } +} diff --git a/lib/crates/fabro-types/src/settings/v2/project.rs b/lib/crates/fabro-types/src/settings/v2/project.rs new file mode 100644 index 000000000..3355559d2 --- /dev/null +++ b/lib/crates/fabro-types/src/settings/v2/project.rs @@ -0,0 +1,24 @@ +//! Project domain: first-class project object. +//! +//! `[project]` replaces the old flat `[fabro]` shape. `directory` means the +//! Fabro-managed project directory inside the repo, defaulting to `fabro/`. + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +/// A sparse `[project]` layer as it appears in a single settings file. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProjectLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + /// The Fabro-managed project directory inside the repo. Defaults to + /// `fabro/` after layering when unspecified. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub directory: Option, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub metadata: HashMap, +} diff --git a/lib/crates/fabro-types/src/settings/v2/run.rs b/lib/crates/fabro-types/src/settings/v2/run.rs new file mode 100644 index 000000000..e03170e6c --- /dev/null +++ b/lib/crates/fabro-types/src/settings/v2/run.rs @@ -0,0 +1,26 @@ +//! Run domain. +//! +//! `[run]` is the shared execution domain. It may appear in all three config +//! files and layer normally. This file holds only the Stage-1 skeleton; the +//! rich subtree (model, git, prepare, execution, checkpoint, sandbox, +//! notifications, interviews, agent, hooks, scm, pull_request, artifacts) is +//! filled in during Stage 2. + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +/// A sparse `[run]` layer as it appears in a single settings file. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RunLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub goal: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub working_dir: Option, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub metadata: HashMap, + /// Run-time inputs. Stage 2 will widen the value type beyond strings. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub inputs: Option>, +} diff --git a/lib/crates/fabro-types/src/settings/v2/server.rs b/lib/crates/fabro-types/src/settings/v2/server.rs new file mode 100644 index 000000000..9e0ff87db --- /dev/null +++ b/lib/crates/fabro-types/src/settings/v2/server.rs @@ -0,0 +1,13 @@ +//! Server domain. +//! +//! `[server]` is a namespace container; actual settings live in named +//! subdomains. This file holds only the Stage-1 skeleton; Stage 2 fleshes out +//! the full subtree (listen, api, web, auth, storage, artifacts, slatedb, +//! scheduler, logging, integrations). + +use serde::{Deserialize, Serialize}; + +/// A sparse `[server]` layer as it appears in a single settings file. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerLayer; diff --git a/lib/crates/fabro-types/src/settings/v2/size.rs b/lib/crates/fabro-types/src/settings/v2/size.rs new file mode 100644 index 000000000..2ac4685e2 --- /dev/null +++ b/lib/crates/fabro-types/src/settings/v2/size.rs @@ -0,0 +1,313 @@ +//! Config-facing byte sizes. +//! +//! Accepts bare integers plus `B`, `KB`, `MB`, `GB`, `TB`, and `KiB`, `MiB`, +//! `GiB`, `TiB`. Decimal units (`KB`, …) are powers of 1000; binary units +//! (`KiB`, …) are powers of 1024. Bare values default to `GB`. Fractional +//! values are not supported in the first pass. The canonical renderer emits +//! the largest decimal unit that represents the value as an integer multiple. + +use std::fmt; +use std::str::FromStr; + +use serde::de::{self, Visitor}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +/// A byte size parsed from a human-readable string. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] +pub struct Size(u64); + +impl Size { + #[must_use] + pub const fn from_bytes(bytes: u64) -> Self { + Self(bytes) + } + + #[must_use] + pub const fn as_bytes(&self) -> u64 { + self.0 + } + + #[must_use] + pub const fn from_gigabytes(gb: u64) -> Self { + Self(gb.saturating_mul(1_000_000_000)) + } +} + +/// An error returned when parsing a size string fails. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ParseSizeError { + /// The input was empty or whitespace only. + Empty, + /// The input had a numeric portion that could not be parsed as a non-negative integer. + InvalidNumber { input: String }, + /// The input contained a fractional value, which is not supported in the first pass. + Fractional { input: String }, + /// The input had an unrecognized unit suffix. + InvalidUnit { input: String, unit: String }, + /// The resulting value overflowed a `u64`. + Overflow { input: String }, +} + +impl fmt::Display for ParseSizeError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Empty => f.write_str("size value is empty"), + Self::InvalidNumber { input } => { + write!( + f, + "size {input:?}: numeric portion is not a non-negative integer" + ) + } + Self::Fractional { input } => { + write!( + f, + "size {input:?}: fractional values are not supported in the first pass" + ) + } + Self::InvalidUnit { input, unit } => { + write!( + f, + "size {input:?}: unknown unit {unit:?} (expected one of B, KB, MB, GB, TB, KiB, MiB, GiB, TiB)" + ) + } + Self::Overflow { input } => { + write!(f, "size {input:?}: value overflows u64 bytes") + } + } + } +} + +impl std::error::Error for ParseSizeError {} + +impl FromStr for Size { + type Err = ParseSizeError; + + fn from_str(input: &str) -> Result { + let trimmed = input.trim(); + if trimmed.is_empty() { + return Err(ParseSizeError::Empty); + } + + if trimmed.contains('.') { + return Err(ParseSizeError::Fractional { + input: input.to_owned(), + }); + } + + let first_alpha = trimmed.find(|c: char| !c.is_ascii_digit()); + let (num, unit) = match first_alpha { + Some(0) => { + return Err(ParseSizeError::InvalidNumber { + input: input.to_owned(), + }); + } + Some(idx) => (&trimmed[..idx], trimmed[idx..].trim()), + None => (trimmed, ""), + }; + + let n: u64 = num.parse().map_err(|_| ParseSizeError::InvalidNumber { + input: input.to_owned(), + })?; + + // Bare integers default to GB per R84. + let multiplier: u64 = match unit { + "" | "GB" => 1_000_000_000, + "B" => 1, + "KB" => 1_000, + "MB" => 1_000_000, + "TB" => 1_000_000_000_000, + "KiB" => 1_024, + "MiB" => 1_024 * 1_024, + "GiB" => 1_024 * 1_024 * 1_024, + "TiB" => 1_024 * 1_024 * 1_024 * 1_024, + other => { + return Err(ParseSizeError::InvalidUnit { + input: input.to_owned(), + unit: other.to_owned(), + }); + } + }; + + let bytes = n + .checked_mul(multiplier) + .ok_or_else(|| ParseSizeError::Overflow { + input: input.to_owned(), + })?; + + Ok(Self(bytes)) + } +} + +impl fmt::Display for Size { + /// Canonical rendering picks the largest decimal unit that represents the + /// value as an integer multiple, preferring `TB`, `GB`, `MB`, `KB`, `B`. + /// Zero renders as `0B`. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + const KB: u64 = 1_000; + const MB: u64 = 1_000_000; + const GB: u64 = 1_000_000_000; + const TB: u64 = 1_000_000_000_000; + + let b = self.0; + if b == 0 { + return f.write_str("0B"); + } + if b.is_multiple_of(TB) { + write!(f, "{}TB", b / TB) + } else if b.is_multiple_of(GB) { + write!(f, "{}GB", b / GB) + } else if b.is_multiple_of(MB) { + write!(f, "{}MB", b / MB) + } else if b.is_multiple_of(KB) { + write!(f, "{}KB", b / KB) + } else { + write!(f, "{b}B") + } + } +} + +impl Serialize for Size { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> Deserialize<'de> for Size { + fn deserialize>(deserializer: D) -> Result { + struct SizeVisitor; + + impl Visitor<'_> for SizeVisitor { + type Value = Size; + + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(r#"a size string such as "8GB" or "512MiB", or a bare integer interpreted as GB"#) + } + + fn visit_str(self, value: &str) -> Result { + value.parse().map_err(de::Error::custom) + } + + fn visit_string(self, value: String) -> Result { + self.visit_str(&value) + } + + // Bare integers (non-negative) should parse as GB. + fn visit_u64(self, value: u64) -> Result { + Ok(Size::from_gigabytes(value)) + } + + fn visit_i64(self, value: i64) -> Result { + u64::try_from(value) + .map(Size::from_gigabytes) + .map_err(|_| de::Error::custom("size must be non-negative")) + } + } + + deserializer.deserialize_any(SizeVisitor) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bare_integer_defaults_to_gb() { + assert_eq!("8".parse::().unwrap(), Size::from_gigabytes(8)); + } + + #[test] + fn parses_decimal_units() { + assert_eq!("1B".parse::().unwrap().as_bytes(), 1); + assert_eq!("1KB".parse::().unwrap().as_bytes(), 1_000); + assert_eq!("1MB".parse::().unwrap().as_bytes(), 1_000_000); + assert_eq!("1GB".parse::().unwrap().as_bytes(), 1_000_000_000); + assert_eq!("1TB".parse::().unwrap().as_bytes(), 1_000_000_000_000); + } + + #[test] + fn parses_binary_units() { + assert_eq!("1KiB".parse::().unwrap().as_bytes(), 1_024); + assert_eq!("1MiB".parse::().unwrap().as_bytes(), 1_024 * 1_024); + assert_eq!( + "1GiB".parse::().unwrap().as_bytes(), + 1_024 * 1_024 * 1_024 + ); + assert_eq!( + "1TiB".parse::().unwrap().as_bytes(), + 1_024u64 * 1_024 * 1_024 * 1_024 + ); + } + + #[test] + fn rejects_fractional_values() { + let err = "1.5GB".parse::().unwrap_err(); + assert!(matches!(err, ParseSizeError::Fractional { .. })); + } + + #[test] + fn rejects_unknown_units() { + let err = "5XB".parse::().unwrap_err(); + assert!(matches!(err, ParseSizeError::InvalidUnit { unit, .. } if unit == "XB")); + } + + #[test] + fn rejects_empty_input() { + let err = "".parse::().unwrap_err(); + assert!(matches!(err, ParseSizeError::Empty)); + } + + #[test] + fn canonical_render_uses_largest_decimal_unit() { + assert_eq!(Size::from_bytes(1_000_000_000_000).to_string(), "1TB"); + assert_eq!(Size::from_bytes(2_000_000_000).to_string(), "2GB"); + assert_eq!(Size::from_bytes(5_000_000).to_string(), "5MB"); + assert_eq!(Size::from_bytes(3_000).to_string(), "3KB"); + assert_eq!(Size::from_bytes(0).to_string(), "0B"); + } + + #[test] + fn canonical_render_falls_back_to_bytes_for_odd_values() { + // 1_500 bytes is not a whole MB/KB. 1_500 / 1_000 = 1.5 → falls back to B. + assert_eq!(Size::from_bytes(1_500).to_string(), "1500B"); + } + + #[test] + fn binary_unit_values_render_as_bytes_when_not_a_whole_decimal_unit() { + // 1 KiB = 1024 bytes — not divisible by 1000, so canonical render is bytes. + let size = "1KiB".parse::().unwrap(); + assert_eq!(size.to_string(), "1024B"); + } + + #[test] + fn overflow_detected() { + let err = format!("{}TB", u64::MAX).parse::().unwrap_err(); + assert!(matches!(err, ParseSizeError::Overflow { .. })); + } + + #[test] + fn serde_round_trip_via_json_string() { + #[derive(Debug, serde::Deserialize, serde::Serialize, PartialEq)] + struct Wrap { + s: Size, + } + + let input = r#"{"s":"8GB"}"#; + let parsed: Wrap = serde_json::from_str(input).unwrap(); + assert_eq!(parsed.s, Size::from_gigabytes(8)); + let rendered = serde_json::to_string(&parsed).unwrap(); + assert_eq!(rendered, r#"{"s":"8GB"}"#); + } + + #[test] + fn serde_accepts_bare_integer_as_gb() { + #[derive(Debug, serde::Deserialize, PartialEq)] + struct Wrap { + s: Size, + } + + let input = r#"{"s":8}"#; + let parsed: Wrap = serde_json::from_str(input).unwrap(); + assert_eq!(parsed.s, Size::from_gigabytes(8)); + } +} diff --git a/lib/crates/fabro-types/src/settings/v2/splice_array.rs b/lib/crates/fabro-types/src/settings/v2/splice_array.rs new file mode 100644 index 000000000..435570d67 --- /dev/null +++ b/lib/crates/fabro-types/src/settings/v2/splice_array.rs @@ -0,0 +1,261 @@ +//! Splice-capable string arrays. +//! +//! In declared splice-capable array paths, the literal string value `"..."` +//! is reserved: it represents "splice in inherited values from lower-precedence +//! layers here." At most one `"..."` marker may appear per array. In the base +//! layer with no inherited parent, the marker resolves to an empty inherited +//! segment. In non-splice paths the same literal is a hard error — enforced +//! by using the plain `Vec` type elsewhere and this type only where +//! splice semantics are explicitly allowed. + +use std::fmt; + +use serde::de::{self, Visitor}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +/// The reserved literal that marks the splice insertion point. +pub const SPLICE_MARKER: &str = "..."; + +/// A string array that may contain at most one splice marker. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct SpliceArray { + entries: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum Entry { + Value(String), + Splice, +} + +/// An error returned when a splice array fails validation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SpliceArrayError { + /// The array contained more than one splice marker. + MultipleMarkers, +} + +impl fmt::Display for SpliceArrayError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MultipleMarkers => { + f.write_str(r#"splice array must contain at most one "..." marker"#) + } + } + } +} + +impl std::error::Error for SpliceArrayError {} + +impl SpliceArray { + /// Build a splice array from a raw `Vec`. + pub fn from_raw(raw: Vec) -> Result { + let mut entries = Vec::with_capacity(raw.len()); + let mut marker_count = 0; + for item in raw { + if item == SPLICE_MARKER { + marker_count += 1; + entries.push(Entry::Splice); + } else { + entries.push(Entry::Value(item)); + } + } + if marker_count > 1 { + return Err(SpliceArrayError::MultipleMarkers); + } + Ok(Self { entries }) + } + + /// Build a splice array with no inherited splice marker. + #[must_use] + pub fn from_values(values: impl IntoIterator) -> Self { + Self { + entries: values.into_iter().map(Entry::Value).collect(), + } + } + + /// True when the array contains a splice marker. + #[must_use] + pub fn has_splice(&self) -> bool { + self.entries.iter().any(|e| matches!(e, Entry::Splice)) + } + + /// The index of the splice marker, if present. + #[must_use] + pub fn splice_position(&self) -> Option { + self.entries.iter().position(|e| matches!(e, Entry::Splice)) + } + + /// The non-splice values, in source order. + #[must_use] + pub fn values(&self) -> Vec<&str> { + self.entries + .iter() + .filter_map(|e| match e { + Entry::Value(v) => Some(v.as_str()), + Entry::Splice => None, + }) + .collect() + } + + /// Resolve this array against an inherited lower-precedence value list. + /// + /// - If the array has a splice marker, the inherited list is spliced in + /// at the marker position. + /// - If the array has no splice marker, it replaces the inherited list + /// wholesale. + #[must_use] + pub fn resolve(self, inherited: Vec) -> Vec { + let Some(pos) = self.splice_position() else { + return self + .entries + .into_iter() + .filter_map(|e| match e { + Entry::Value(v) => Some(v), + Entry::Splice => None, + }) + .collect(); + }; + + let mut prefix = Vec::new(); + let mut suffix = Vec::new(); + for (i, entry) in self.entries.into_iter().enumerate() { + match entry { + Entry::Value(v) => { + if i < pos { + prefix.push(v); + } else { + suffix.push(v); + } + } + Entry::Splice => {} + } + } + + let mut out = prefix; + out.extend(inherited); + out.extend(suffix); + out + } +} + +impl Serialize for SpliceArray { + fn serialize(&self, serializer: S) -> Result { + use serde::ser::SerializeSeq; + let mut seq = serializer.serialize_seq(Some(self.entries.len()))?; + for entry in &self.entries { + match entry { + Entry::Value(v) => seq.serialize_element(v)?, + Entry::Splice => seq.serialize_element(SPLICE_MARKER)?, + } + } + seq.end() + } +} + +impl<'de> Deserialize<'de> for SpliceArray { + fn deserialize>(deserializer: D) -> Result { + struct SpliceArrayVisitor; + + impl<'de> Visitor<'de> for SpliceArrayVisitor { + type Value = SpliceArray; + + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str( + r#"an array of strings, optionally containing a single "..." splice marker"#, + ) + } + + fn visit_seq>(self, mut seq: A) -> Result { + let mut raw: Vec = Vec::new(); + while let Some(item) = seq.next_element::()? { + raw.push(item); + } + SpliceArray::from_raw(raw).map_err(de::Error::custom) + } + } + + deserializer.deserialize_seq(SpliceArrayVisitor) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn from_raw_with_no_marker() { + let arr = SpliceArray::from_raw(vec!["a".into(), "b".into()]).unwrap(); + assert!(!arr.has_splice()); + assert_eq!(arr.values(), vec!["a", "b"]); + } + + #[test] + fn append_marker_at_front() { + let arr = SpliceArray::from_raw(vec!["...".into(), "c".into()]).unwrap(); + assert_eq!(arr.splice_position(), Some(0)); + let resolved = arr.resolve(vec!["a".into(), "b".into()]); + assert_eq!(resolved, vec!["a", "b", "c"]); + } + + #[test] + fn prepend_marker_at_back() { + let arr = SpliceArray::from_raw(vec!["a".into(), "...".into()]).unwrap(); + assert_eq!(arr.splice_position(), Some(1)); + let resolved = arr.resolve(vec!["b".into(), "c".into()]); + assert_eq!(resolved, vec!["a", "b", "c"]); + } + + #[test] + fn marker_in_middle() { + let arr = SpliceArray::from_raw(vec!["pre".into(), "...".into(), "post".into()]).unwrap(); + let resolved = arr.resolve(vec!["mid".into()]); + assert_eq!(resolved, vec!["pre", "mid", "post"]); + } + + #[test] + fn replace_semantics_without_marker() { + let arr = SpliceArray::from_raw(vec!["only".into()]).unwrap(); + let resolved = arr.resolve(vec!["inherited".into()]); + assert_eq!(resolved, vec!["only"]); + } + + #[test] + fn multiple_markers_rejected() { + let err = SpliceArray::from_raw(vec!["...".into(), "...".into()]).unwrap_err(); + assert_eq!(err, SpliceArrayError::MultipleMarkers); + } + + #[test] + fn base_layer_with_splice_resolves_to_empty_inherited() { + let arr = SpliceArray::from_raw(vec!["...".into(), "b".into()]).unwrap(); + let resolved = arr.resolve(vec![]); + assert_eq!(resolved, vec!["b"]); + } + + #[test] + fn serde_round_trip_via_json() { + #[derive(Debug, serde::Deserialize, serde::Serialize, PartialEq)] + struct Wrap { + a: SpliceArray, + } + + let input = r#"{"a":["...","b"]}"#; + let parsed: Wrap = serde_json::from_str(input).unwrap(); + assert!(parsed.a.has_splice()); + let rendered = serde_json::to_string(&parsed).unwrap(); + assert_eq!(rendered, input); + } + + #[test] + fn serde_rejects_multiple_markers() { + #[derive(Debug, serde::Deserialize)] + struct Wrap { + _a: SpliceArray, + } + + let input = r#"{"_a":["...","..."]}"#; + let err = serde_json::from_str::(input).unwrap_err(); + assert!(err.to_string().contains("at most one")); + } +} diff --git a/lib/crates/fabro-types/src/settings/v2/tree.rs b/lib/crates/fabro-types/src/settings/v2/tree.rs new file mode 100644 index 000000000..2d404cff3 --- /dev/null +++ b/lib/crates/fabro-types/src/settings/v2/tree.rs @@ -0,0 +1,223 @@ +//! The top-level v2 sparse parse tree. +//! +//! This struct models a single settings file (`~/.fabro/settings.toml`, +//! `fabro.toml`, or `workflow.toml`) after parsing. Fields unset in the source +//! stay `None`/empty. Strict unknown-key handling catches any top-level key +//! that is not one of the reserved domains, with targeted rename hints for +//! legacy flat shapes. + +use std::fmt; + +use serde::{Deserialize, Serialize}; + +use super::cli::CliLayer; +use super::features::FeaturesLayer; +use super::project::ProjectLayer; +use super::run::RunLayer; +use super::server::ServerLayer; +use super::workflow::WorkflowLayer; + +/// A parsed settings file before layering. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct SettingsFile { + #[serde(default, rename = "_version", skip_serializing_if = "Option::is_none")] + pub version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workflow: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub run: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cli: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub server: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub features: Option, +} + +/// A top-level key in a v2 settings file. Anything not in this list is rejected +/// at parse time with a targeted rename hint when possible. +const ALLOWED_TOP_LEVEL_KEYS: &[&str] = &[ + "_version", "project", "workflow", "run", "cli", "server", "features", +]; + +/// An error returned when a settings file fails parse-level validation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ParseError { + /// A low-level TOML parse error. + Toml(String), + /// Schema version pre-validation failed. + Version(super::version::VersionError), + /// A top-level key is not part of the v2 schema. Rename hints are + /// populated for known-legacy keys. + UnknownTopLevelKey { key: String, hint: Option }, +} + +impl fmt::Display for ParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Toml(msg) => write!(f, "settings file is not valid TOML: {msg}"), + Self::Version(err) => fmt::Display::fmt(err, f), + Self::UnknownTopLevelKey { key, hint } => { + if let Some(hint) = hint { + write!(f, "unknown top-level settings key `{key}`: {hint}") + } else { + write!( + f, + "unknown top-level settings key `{key}`: expected one of `_version`, `project`, `workflow`, `run`, `cli`, `server`, `features`" + ) + } + } + } + } +} + +impl std::error::Error for ParseError {} + +/// Parse a v2 settings file from TOML text. +/// +/// This runs `_version` pre-validation, top-level unknown-key validation +/// with rename hints, and then decodes the sparse namespaced tree. Deeper +/// unknown-key validation for nested tables is enforced by the individual +/// layer types via `#[serde(deny_unknown_fields)]`. +pub fn parse_settings_file(input: &str) -> Result { + let raw: toml::Value = toml::from_str(input).map_err(|e| ParseError::Toml(e.to_string()))?; + super::version::validate_version(&raw).map_err(ParseError::Version)?; + + if let Some(table) = raw.as_table() { + for key in table.keys() { + if !ALLOWED_TOP_LEVEL_KEYS.contains(&key.as_str()) { + return Err(ParseError::UnknownTopLevelKey { + key: key.clone(), + hint: rename_hint(key), + }); + } + } + } + + let file: SettingsFile = raw + .try_into::() + .map_err(|e| ParseError::Toml(e.to_string()))?; + Ok(file) +} + +/// Targeted rename hint for known legacy top-level keys. +fn rename_hint(key: &str) -> Option { + let target = match key { + "version" => "rename to `_version`", + "goal" | "goal_file" | "work_dir" | "directory" => "move to `[run]`", + "graph" => "move to `[workflow]`", + "labels" => "move to `[run.metadata]`", + "llm" => "rename to `[run.model]`", + "vars" => "rename to `[run.inputs]`", + "setup" => "rename to `[run.prepare]`", + "sandbox" => "move under `[run.sandbox]`", + "checkpoint" => "move under `[run.checkpoint]`", + "pull_request" => "move under `[run.pull_request]`", + "artifacts" => "move under `[run.artifacts]`", + "hooks" => "move under `[[run.hooks]]`", + "mcp_servers" => "move under `[run.agent.mcps.]` or `[cli.exec.agent.mcps.]`", + "exec" => "rename to `[cli.exec]`", + "api" => "rename to `[server.api]`", + "web" => "rename to `[server.web]`", + "artifact_storage" => "rename to `[server.artifacts]`", + "storage_dir" | "data_dir" => "rename to `[server.storage] root`", + "max_concurrent_runs" => "rename to `[server.scheduler]` field", + "fabro" => "rename to `[project]`; `fabro.root` becomes `project.directory`", + "git" => "split into `[run.git]` (local git behavior) and `[server.integrations.github]`", + "github" => "rename to `[server.integrations.github]`", + "slack" => "move under `[server.integrations.slack]`", + "log" => "rename to `[server.logging]` or `[cli.logging]` depending on owner", + "prevent_idle_sleep" => "rename to `[cli.exec] prevent_idle_sleep`", + "verbose" => "rename to `[cli.output] verbosity`", + "upgrade_check" => "rename to `[cli.updates] check`", + "dry_run" => "rename to `[run.execution] mode = \"dry_run\"`", + "auto_approve" => "rename to `[run.execution] approval = \"auto\"`", + "no_retro" => "rename to `[run.execution] retros = false`", + _ => return None, + }; + Some(target.to_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_empty_file() { + let file = parse_settings_file("").unwrap(); + assert_eq!(file, SettingsFile::default()); + } + + #[test] + fn parses_minimal_valid_file() { + let input = r#" +_version = 1 + +[project] +name = "Fabro" +"#; + let file = parse_settings_file(input).unwrap(); + assert_eq!(file.version, Some(1)); + assert!(file.project.is_some()); + } + + #[test] + fn rejects_legacy_version_key_with_rename_hint() { + let err = parse_settings_file("version = 1").unwrap_err(); + assert!(matches!(err, ParseError::Version(_))); + assert!(err.to_string().contains("_version")); + } + + #[test] + fn rejects_unknown_top_level_key() { + let err = parse_settings_file("unknown_key = 1").unwrap_err(); + let ParseError::UnknownTopLevelKey { key, .. } = err else { + panic!("expected UnknownTopLevelKey, got: {err:?}"); + }; + assert_eq!(key, "unknown_key"); + } + + #[test] + fn legacy_llm_section_gets_run_model_rename_hint() { + let err = parse_settings_file("[llm]\nprovider = \"openai\"").unwrap_err(); + assert!( + err.to_string().contains("run.model"), + "expected rename hint for [llm]: {err}" + ); + } + + #[test] + fn legacy_vars_section_gets_run_inputs_rename_hint() { + let err = parse_settings_file("[vars]\nk = \"v\"").unwrap_err(); + assert!( + err.to_string().contains("run.inputs"), + "expected rename hint for [vars]: {err}" + ); + } + + #[test] + fn legacy_exec_section_gets_cli_exec_rename_hint() { + let err = parse_settings_file("[exec]\nmodel = \"claude-opus\"").unwrap_err(); + assert!( + err.to_string().contains("cli.exec"), + "expected rename hint for [exec]: {err}" + ); + } + + #[test] + fn legacy_fabro_section_gets_project_rename_hint() { + let err = parse_settings_file("[fabro]\nroot = \"fabro/\"").unwrap_err(); + assert!( + err.to_string().contains("project"), + "expected rename hint for [fabro]: {err}" + ); + } + + #[test] + fn higher_version_rejected_with_upgrade_hint() { + let err = parse_settings_file("_version = 99").unwrap_err(); + assert!(err.to_string().contains("Upgrade")); + } +} diff --git a/lib/crates/fabro-types/src/settings/v2/version.rs b/lib/crates/fabro-types/src/settings/v2/version.rs new file mode 100644 index 000000000..bb3dc8e15 --- /dev/null +++ b/lib/crates/fabro-types/src/settings/v2/version.rs @@ -0,0 +1,111 @@ +//! Schema version handling. +//! +//! The settings schema version lives under the reserved top-level key +//! `_version`. Missing defaults to `1`. The legacy top-level `version` key is +//! a targeted rename hint. Unsupported higher versions hard-fail with an +//! upgrade hint before deeper validation continues. + +use std::fmt; + +/// The highest schema version this parser can consume. +pub const CURRENT_VERSION: u32 = 1; + +/// An error returned when `_version` pre-validation fails. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum VersionError { + /// The file contains a legacy top-level `version` key. Offer a rename hint. + LegacyVersionKey, + /// The file declares `_version` higher than [`CURRENT_VERSION`]. + UnsupportedHigherVersion { found: u32 }, +} + +impl fmt::Display for VersionError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::LegacyVersionKey => f.write_str( + "settings files must use `_version` instead of `version`. Rename the key and try again.", + ), + Self::UnsupportedHigherVersion { found } => write!( + f, + "settings schema version {found} is newer than this build supports (current: {CURRENT_VERSION}). Upgrade Fabro to read this file." + ), + } + } +} + +impl std::error::Error for VersionError {} + +/// The parsed schema version for a settings file. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SchemaVersion(pub u32); + +impl Default for SchemaVersion { + fn default() -> Self { + Self(CURRENT_VERSION) + } +} + +/// Validate and extract the schema version from a parsed TOML value before +/// deeper validation continues. +/// +/// This function peeks at the top-level table and enforces three rules: +/// +/// 1. `version = ...` (no underscore) is an explicit rename hint error. +/// 2. `_version` higher than [`CURRENT_VERSION`] is an upgrade hint error. +/// 3. Missing `_version` defaults to [`CURRENT_VERSION`]. +pub fn validate_version(raw: &toml::Value) -> Result { + if let Some(table) = raw.as_table() { + if table.contains_key("version") { + return Err(VersionError::LegacyVersionKey); + } + if let Some(value) = table.get("_version") { + if let Some(n) = value.as_integer() { + let found = u32::try_from(n).unwrap_or(u32::MAX); + if found > CURRENT_VERSION { + return Err(VersionError::UnsupportedHigherVersion { found }); + } + return Ok(SchemaVersion(found)); + } + } + } + Ok(SchemaVersion::default()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(input: &str) -> toml::Value { + toml::from_str(input).expect("toml parse") + } + + #[test] + fn missing_version_defaults_to_current() { + let raw = parse(""); + let v = validate_version(&raw).unwrap(); + assert_eq!(v, SchemaVersion(CURRENT_VERSION)); + } + + #[test] + fn explicit_version_one_is_accepted() { + let raw = parse("_version = 1"); + let v = validate_version(&raw).unwrap(); + assert_eq!(v, SchemaVersion(1)); + } + + #[test] + fn legacy_version_key_errors_with_rename_hint() { + let raw = parse("version = 1"); + let err = validate_version(&raw).unwrap_err(); + assert_eq!(err, VersionError::LegacyVersionKey); + assert!(err.to_string().contains("_version")); + } + + #[test] + fn unsupported_higher_version_errors_with_upgrade_hint() { + let raw = parse("_version = 99"); + let err = validate_version(&raw).unwrap_err(); + assert_eq!(err, VersionError::UnsupportedHigherVersion { found: 99 }); + assert!(err.to_string().contains("Upgrade")); + } +} diff --git a/lib/crates/fabro-types/src/settings/v2/workflow.rs b/lib/crates/fabro-types/src/settings/v2/workflow.rs new file mode 100644 index 000000000..8b0e986fd --- /dev/null +++ b/lib/crates/fabro-types/src/settings/v2/workflow.rs @@ -0,0 +1,23 @@ +//! Workflow domain. +//! +//! `[workflow]` is descriptive: `name`, `description`, optional `graph` (a +//! path override for the default `workflow.fabro` file), and `metadata`. + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +/// A sparse `[workflow]` layer as it appears in a single settings file. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WorkflowLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Optional override for the default `workflow.fabro` graph path. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub graph: Option, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub metadata: HashMap, +} From bb228643e769cdb373dd84df2dfadf0f22c99dfc Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 9 Apr 2026 09:17:37 -0400 Subject: [PATCH 02/47] feat(types): flesh out v2 subtrees and add legacy bridge Stage 2 of the settings TOML redesign. Completes the v2 resolved settings tree and adds a temporary internal bridge so callers can migrate incrementally during stages 3 and 4. - run subtree: model (with splice-aware fallbacks), git author, prepare steps (script xor command), execution (mode, approval, retros as positive-form), checkpoint, sandbox (with local/daytona provider leaves and sticky env), notifications (keyed routes with slack/discord/teams subtables), interviews (provider + subtables), agent (permissions + mcps map), hooks (id-aware ordered list), scm (with github leaf), pull_request, artifacts - cli subtree: target (http/unix), auth (strategy), exec (model, agent, prevent_idle_sleep), output (format, verbosity), updates, logging - server subtree: listen (tcp/unix with tls), api, web, auth (api jwt/mtls, web providers), storage, artifacts (local/s3 provider leaves), slatedb (local/s3 provider leaves), scheduler, logging, integrations (github/slack/discord/teams) - closed ObjectStoreProvider enum so unknown providers hard-fail schema validation - provider-specific subtables use enumerated known providers rather than flatten+HashMap so strict deny_unknown_fields still holds - bridge module (settings::v2::bridge) with bridge_to_old() mapping the v2 resolved tree back to the legacy flat Settings shape for fields that current consumers read. Env interpolation emits raw source form; resolution is a Stage 3 concern - representative_full_tree_parses integration test exercises the canonical example from the brainstorm document end-to-end - 140 tests passing; workspace clippy-clean under -D warnings --- .../fabro-types/src/settings/v2/bridge.rs | 809 ++++++++++++++++++ lib/crates/fabro-types/src/settings/v2/cli.rs | 139 ++- lib/crates/fabro-types/src/settings/v2/mod.rs | 3 + lib/crates/fabro-types/src/settings/v2/run.rs | 524 +++++++++++- .../fabro-types/src/settings/v2/server.rs | 318 ++++++- .../fabro-types/src/settings/v2/tree.rs | 199 +++++ 6 files changed, 1978 insertions(+), 14 deletions(-) create mode 100644 lib/crates/fabro-types/src/settings/v2/bridge.rs diff --git a/lib/crates/fabro-types/src/settings/v2/bridge.rs b/lib/crates/fabro-types/src/settings/v2/bridge.rs new file mode 100644 index 000000000..59d671969 --- /dev/null +++ b/lib/crates/fabro-types/src/settings/v2/bridge.rs @@ -0,0 +1,809 @@ +//! Temporary bridge from the v2 parse tree to the old flat [`Settings`] shape. +//! +//! This module exists only to keep consumers compiling while Stages 3 and 4 +//! migrate parsers and consumers across the workspace. Field mappings are +//! best-effort and deliberately lossy for anything the old shape does not +//! have a slot for. **This entire module is deleted in Stage 6.** +//! +//! Env var interpolation is not performed here; `${env.NAME}` tokens are +//! emitted verbatim via [`InterpString::as_source`]. The post-layering +//! interpolation pass runs in `fabro-config` during Stage 3, after layering +//! is already complete. + +use std::collections::HashMap; + +use super::cli::{CliExecLayer, CliLayer, CliOutputLayer, CliTargetLayer, OutputVerbosity}; +use super::interp::InterpString; +use super::project::ProjectLayer; +use super::run::{ + AgentPermissions as V2AgentPermissions, ApprovalMode, HookEntry as V2HookEntry, + HookEvent as V2HookEvent, McpEntryLayer, MergeStrategy as V2MergeStrategy, ModelRefOrSplice, + RunLayer, RunMode, WorktreeMode as V2WorktreeMode, +}; +use super::server::{ + ObjectStoreProvider, ServerArtifactsLayer, ServerIntegrationsLayer, ServerLayer, + ServerSchedulerLayer, ServerStorageLayer, ServerWebLayer, +}; +use super::tree::SettingsFile; +use super::workflow::WorkflowLayer; +use crate::settings::Settings; +use crate::settings::hook::{ + HookDefinition, HookEvent as OldHookEvent, HookType as OldHookType, TlsMode as OldTlsMode, +}; +use crate::settings::mcp::{McpServerEntry, McpTransport}; +use crate::settings::project::ProjectSettings; +use crate::settings::run::{ + ArtifactsSettings, CheckpointSettings, LlmSettings, MergeStrategy as OldMergeStrategy, + PullRequestSettings, SetupSettings, +}; +use crate::settings::sandbox::{ + DaytonaNetwork, DaytonaSettings, DaytonaSnapshotSettings, DockerfileSource, + LocalSandboxSettings, SandboxSettings, WorktreeMode as OldWorktreeMode, +}; +use crate::settings::server::{ + ApiAuthStrategy, ApiSettings, ArtifactStorageBackend, ArtifactStorageSettings, AuthProvider, + AuthSettings, FeaturesSettings, GitAuthorSettings, GitProvider, GitSettings, LogSettings, + SlackSettings, WebSettings, +}; +use crate::settings::user::{ + ExecSettings, OutputFormat, PermissionLevel, ServerSettings as UserServer, +}; + +/// Convert a v2 `SettingsFile` into the legacy flat [`Settings`] shape. +/// +/// This is a temporary seam. All v2 fields that do not map cleanly are +/// dropped; callers that need those should read the v2 tree directly. +#[must_use] +pub fn bridge_to_old(file: &SettingsFile) -> Settings { + let mut out = Settings { + version: file.version, + ..Settings::default() + }; + + if let Some(project) = &file.project { + bridge_project(project, &mut out); + } + if let Some(workflow) = &file.workflow { + bridge_workflow(workflow, &mut out); + } + if let Some(run) = &file.run { + bridge_run(run, &mut out); + } + if let Some(cli) = &file.cli { + bridge_cli(cli, &mut out); + } + if let Some(server) = &file.server { + bridge_server(server, &mut out); + } + if let Some(features) = &file.features { + out.features = Some(FeaturesSettings { + session_sandboxes: features.session_sandboxes.unwrap_or(false), + retros: false, // v2 moves retros to run.execution.retros + }); + } + + out +} + +fn bridge_project(project: &ProjectLayer, out: &mut Settings) { + if let Some(directory) = &project.directory { + out.fabro = Some(ProjectSettings { + root: directory.clone(), + }); + } + if !project.metadata.is_empty() { + merge_labels(&mut out.labels, &project.metadata); + } +} + +fn bridge_workflow(workflow: &WorkflowLayer, out: &mut Settings) { + if let Some(graph) = &workflow.graph { + out.graph = Some(graph.clone()); + } + if !workflow.metadata.is_empty() { + merge_labels(&mut out.labels, &workflow.metadata); + } +} + +fn bridge_run(run: &RunLayer, out: &mut Settings) { + if let Some(goal) = &run.goal { + out.goal = Some(interp_to_string(goal)); + } + if let Some(wd) = &run.working_dir { + out.work_dir = Some(interp_to_string(wd)); + } + if !run.metadata.is_empty() { + merge_labels(&mut out.labels, &run.metadata); + } + + if let Some(inputs) = &run.inputs { + let mut vars: HashMap = HashMap::new(); + for (k, v) in inputs { + vars.insert(k.clone(), toml_value_to_string(v)); + } + out.vars = Some(vars); + } + + if let Some(model) = &run.model { + let mut llm = LlmSettings::default(); + if let Some(p) = &model.provider { + llm.provider = Some(interp_to_string(p)); + } + if let Some(n) = &model.name { + llm.model = Some(interp_to_string(n)); + } + if !model.fallbacks.is_empty() { + let mut fallbacks_by_provider: HashMap> = HashMap::new(); + for entry in &model.fallbacks { + match entry { + ModelRefOrSplice::ModelRef(model_ref) => { + let s = model_ref.to_string(); + fallbacks_by_provider + .entry(String::new()) + .or_default() + .push(s); + } + ModelRefOrSplice::Splice => {} + } + } + if !fallbacks_by_provider.is_empty() { + llm.fallbacks = Some(fallbacks_by_provider); + } + } + out.llm = Some(llm); + } + + if let Some(prepare) = &run.prepare { + let commands: Vec = prepare + .steps + .iter() + .filter_map(|step| { + if let Some(script) = &step.script { + Some(interp_to_string(script)) + } else { + step.command.as_ref().map(|argv| { + argv.iter() + .map(interp_to_string) + .collect::>() + .join(" ") + }) + } + }) + .collect(); + let timeout_ms = prepare + .timeout + .map(|d| u64::try_from(d.as_std().as_millis()).unwrap_or(u64::MAX)); + out.setup = Some(SetupSettings { + commands, + timeout_ms, + }); + } + + if let Some(execution) = &run.execution { + out.dry_run = match execution.mode { + Some(RunMode::DryRun) => Some(true), + Some(RunMode::Normal) => Some(false), + None => None, + }; + out.auto_approve = match execution.approval { + Some(ApprovalMode::Auto) => Some(true), + Some(ApprovalMode::Prompt) => Some(false), + None => None, + }; + out.no_retro = execution.retros.map(|r| !r); + } + + if let Some(cp) = &run.checkpoint { + out.checkpoint = CheckpointSettings { + exclude_globs: cp.exclude_globs.clone(), + }; + } + + if let Some(sb) = &run.sandbox { + out.sandbox = Some(bridge_sandbox(sb)); + } + + if let Some(agent) = &run.agent { + let map = bridge_mcps(&agent.mcps); + if !map.is_empty() { + out.mcp_servers = map; + } + } + + if !run.hooks.is_empty() { + out.hooks = run.hooks.iter().map(bridge_hook).collect(); + } + + if let Some(pr) = &run.pull_request { + out.pull_request = Some(PullRequestSettings { + enabled: pr.enabled.unwrap_or(false), + draft: pr.draft.unwrap_or(true), + auto_merge: pr.auto_merge.unwrap_or(false), + merge_strategy: pr + .merge_strategy + .map(bridge_merge_strategy) + .unwrap_or_default(), + }); + } + + if let Some(art) = &run.artifacts { + out.artifacts = Some(ArtifactsSettings { + include: art.include.clone(), + }); + } + + // Slack notifications feed the old flat SlackSettings.default_channel. + for route in run.notifications.values() { + if let Some(slack) = &route.slack { + if let Some(channel) = &slack.channel { + out.slack + .get_or_insert_with(SlackSettings::default) + .default_channel = Some(interp_to_string(channel)); + break; + } + } + } + + // Git author from run.git + if let Some(git) = &run.git { + if let Some(author) = &git.author { + let git_settings = out.git.get_or_insert_with(GitSettings::default); + git_settings.author = GitAuthorSettings { + name: author.name.as_ref().map(interp_to_string), + email: author.email.as_ref().map(interp_to_string), + }; + } + } +} + +fn bridge_sandbox(sb: &super::run::RunSandboxLayer) -> SandboxSettings { + SandboxSettings { + provider: sb.provider.clone(), + preserve: sb.preserve, + devcontainer: sb.devcontainer, + local: sb.local.as_ref().map(|local| LocalSandboxSettings { + worktree_mode: local + .worktree_mode + .map(bridge_worktree_mode) + .unwrap_or_default(), + }), + daytona: sb.daytona.as_ref().map(|d| DaytonaSettings { + auto_stop_interval: d.auto_stop_interval, + labels: if d.labels.is_empty() { + None + } else { + Some(d.labels.clone()) + }, + snapshot: d.snapshot.as_ref().and_then(|s| { + s.name.as_ref().map(|name| DaytonaSnapshotSettings { + name: name.clone(), + cpu: s.cpu, + memory: s.memory.map(|sz| size_to_gb_i32(sz.as_bytes())), + disk: s.disk.map(|sz| size_to_gb_i32(sz.as_bytes())), + dockerfile: s.dockerfile.as_ref().map(|d| match d { + super::run::DaytonaDockerfileLayer::Inline(text) => { + DockerfileSource::Inline(text.clone()) + } + super::run::DaytonaDockerfileLayer::Path { path } => { + DockerfileSource::Path { path: path.clone() } + } + }), + }) + }), + network: d.network.as_ref().map(|n| match n { + super::run::DaytonaNetworkLayer::Block => DaytonaNetwork::Block, + super::run::DaytonaNetworkLayer::AllowAll => DaytonaNetwork::AllowAll, + super::run::DaytonaNetworkLayer::AllowList { allow_list } => { + DaytonaNetwork::AllowList(allow_list.clone()) + } + }), + skip_clone: d.skip_clone.unwrap_or(false), + }), + env: if sb.env.is_empty() { + None + } else { + Some( + sb.env + .iter() + .map(|(k, v)| (k.clone(), interp_to_string(v))) + .collect(), + ) + }, + } +} + +fn bridge_worktree_mode(m: V2WorktreeMode) -> OldWorktreeMode { + match m { + V2WorktreeMode::Always => OldWorktreeMode::Always, + V2WorktreeMode::Clean => OldWorktreeMode::Clean, + V2WorktreeMode::Dirty => OldWorktreeMode::Dirty, + V2WorktreeMode::Never => OldWorktreeMode::Never, + } +} + +fn bridge_merge_strategy(m: V2MergeStrategy) -> OldMergeStrategy { + match m { + V2MergeStrategy::Squash => OldMergeStrategy::Squash, + V2MergeStrategy::Merge => OldMergeStrategy::Merge, + V2MergeStrategy::Rebase => OldMergeStrategy::Rebase, + } +} + +fn bridge_mcps(mcps: &HashMap) -> HashMap { + mcps.iter() + .map(|(name, entry)| (name.clone(), bridge_mcp_entry(entry))) + .collect() +} + +fn bridge_mcp_entry(entry: &McpEntryLayer) -> McpServerEntry { + let transport = match entry { + McpEntryLayer::Stdio { + script, + command, + env, + .. + } => { + let command_vec: Vec = if let Some(script) = script { + vec!["sh".into(), "-c".into(), interp_to_string(script)] + } else if let Some(command) = command { + command.iter().map(interp_to_string).collect() + } else { + Vec::new() + }; + McpTransport::Stdio { + command: command_vec, + env: env + .iter() + .map(|(k, v)| (k.clone(), interp_to_string(v))) + .collect(), + } + } + McpEntryLayer::Http { url, headers, .. } => McpTransport::Http { + url: interp_to_string(url), + headers: headers + .iter() + .map(|(k, v)| (k.clone(), interp_to_string(v))) + .collect(), + }, + McpEntryLayer::Sandbox { + script, + command, + port, + env, + .. + } => { + let command_vec: Vec = if let Some(script) = script { + vec!["sh".into(), "-c".into(), interp_to_string(script)] + } else if let Some(command) = command { + command.iter().map(interp_to_string).collect() + } else { + Vec::new() + }; + McpTransport::Sandbox { + command: command_vec, + port: *port, + env: env + .iter() + .map(|(k, v)| (k.clone(), interp_to_string(v))) + .collect(), + } + } + }; + + let (startup_secs, tool_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, |d| d.as_std().as_secs()), + tool_timeout.map_or(60, |d| d.as_std().as_secs()), + ), + }; + + McpServerEntry { + transport, + startup_timeout_secs: startup_secs, + tool_timeout_secs: tool_secs, + } +} + +fn bridge_hook(hook: &V2HookEntry) -> HookDefinition { + let hook_type = resolve_hook_type(hook); + HookDefinition { + name: hook.name.clone().or_else(|| hook.id.clone()), + event: bridge_hook_event(hook.event), + command: None, + hook_type, + matcher: hook.matcher.clone(), + blocking: hook.blocking, + timeout_ms: hook + .timeout + .map(|d| u64::try_from(d.as_std().as_millis()).unwrap_or(u64::MAX)), + sandbox: hook.sandbox, + } +} + +fn resolve_hook_type(hook: &V2HookEntry) -> Option { + if let Some(script) = &hook.script { + return Some(OldHookType::Command { + command: interp_to_string(script), + }); + } + if let Some(command) = &hook.command { + return Some(OldHookType::Command { + command: command + .iter() + .map(interp_to_string) + .collect::>() + .join(" "), + }); + } + if let Some(url) = &hook.url { + let headers = if hook.headers.is_empty() { + None + } else { + Some( + hook.headers + .iter() + .map(|(k, v)| (k.clone(), interp_to_string(v))) + .collect(), + ) + }; + let tls = match hook.tls { + Some(super::run::HookTlsMode::Verify) => OldTlsMode::Verify, + Some(super::run::HookTlsMode::NoVerify) => OldTlsMode::NoVerify, + Some(super::run::HookTlsMode::Off) => OldTlsMode::Off, + None => OldTlsMode::default(), + }; + return Some(OldHookType::Http { + url: interp_to_string(url), + headers, + allowed_env_vars: hook.allowed_env_vars.clone(), + tls, + }); + } + if hook.agent.is_some() { + return Some(OldHookType::Agent { + prompt: hook + .prompt + .as_ref() + .map(interp_to_string) + .unwrap_or_default(), + model: hook.model.as_ref().map(interp_to_string), + max_tool_rounds: hook.max_tool_rounds, + }); + } + hook.prompt.as_ref().map(|prompt| OldHookType::Prompt { + prompt: interp_to_string(prompt), + model: hook.model.as_ref().map(interp_to_string), + }) +} + +fn bridge_hook_event(event: V2HookEvent) -> OldHookEvent { + match event { + V2HookEvent::RunStart => OldHookEvent::RunStart, + V2HookEvent::RunComplete => OldHookEvent::RunComplete, + V2HookEvent::RunFailed => OldHookEvent::RunFailed, + V2HookEvent::StageStart => OldHookEvent::StageStart, + V2HookEvent::StageComplete => OldHookEvent::StageComplete, + V2HookEvent::StageFailed => OldHookEvent::StageFailed, + V2HookEvent::StageRetrying => OldHookEvent::StageRetrying, + V2HookEvent::EdgeSelected => OldHookEvent::EdgeSelected, + V2HookEvent::ParallelStart => OldHookEvent::ParallelStart, + V2HookEvent::ParallelComplete => OldHookEvent::ParallelComplete, + V2HookEvent::SandboxReady => OldHookEvent::SandboxReady, + V2HookEvent::SandboxCleanup => OldHookEvent::SandboxCleanup, + V2HookEvent::CheckpointSaved => OldHookEvent::CheckpointSaved, + V2HookEvent::PreToolUse => OldHookEvent::PreToolUse, + V2HookEvent::PostToolUse => OldHookEvent::PostToolUse, + V2HookEvent::PostToolUseFailure => OldHookEvent::PostToolUseFailure, + } +} + +fn bridge_cli(cli: &CliLayer, out: &mut Settings) { + if let Some(target) = &cli.target { + let target_str = match target { + CliTargetLayer::Http { url, .. } => url.as_ref().map(interp_to_string), + CliTargetLayer::Unix { path } => path.as_ref().map(interp_to_string), + }; + if target_str.is_some() { + out.server = Some(UserServer { + target: target_str, + tls: None, + }); + } + } + + if let Some(exec) = &cli.exec { + out.exec = Some(bridge_exec(exec)); + if let Some(idle) = exec.prevent_idle_sleep { + out.prevent_idle_sleep = Some(idle); + } + } + + if let Some(output) = &cli.output { + bridge_cli_output(output, out); + } + + if let Some(updates) = &cli.updates { + out.upgrade_check = updates.check; + } +} + +fn bridge_exec(exec: &CliExecLayer) -> ExecSettings { + ExecSettings { + provider: exec + .model + .as_ref() + .and_then(|m| m.provider.as_ref()) + .map(interp_to_string), + model: exec + .model + .as_ref() + .and_then(|m| m.name.as_ref()) + .map(interp_to_string), + permissions: exec.agent.as_ref().and_then(|a| { + a.permissions.map(|p| match p { + V2AgentPermissions::ReadOnly => PermissionLevel::ReadOnly, + V2AgentPermissions::ReadWrite => PermissionLevel::ReadWrite, + V2AgentPermissions::Full => PermissionLevel::Full, + }) + }), + output_format: None, + } +} + +fn bridge_cli_output(output: &CliOutputLayer, out: &mut Settings) { + if let Some(format) = output.format { + let fmt = match format { + super::cli::OutputFormat::Text => OutputFormat::Text, + super::cli::OutputFormat::Json => OutputFormat::Json, + }; + out.exec + .get_or_insert_with(ExecSettings::default) + .output_format = Some(fmt); + } + if let Some(verbosity) = output.verbosity { + out.verbose = Some(matches!(verbosity, OutputVerbosity::Verbose)); + } +} + +fn bridge_server(server: &ServerLayer, out: &mut Settings) { + if let Some(storage) = &server.storage { + bridge_storage(storage, out); + } + if let Some(scheduler) = &server.scheduler { + bridge_scheduler(scheduler, out); + } + if let Some(artifacts) = &server.artifacts { + out.artifact_storage = Some(bridge_artifacts(artifacts)); + } + if let Some(web) = &server.web { + out.web = Some(bridge_web(web)); + } + if let Some(api) = &server.api { + out.api = Some(ApiSettings { + base_url: api.url.as_ref().map_or_else( + || "http://localhost:3000/api/v1".to_string(), + interp_to_string, + ), + authentication_strategies: bridge_api_auth_strategies(server.auth.as_ref()), + tls: None, + }); + } + if let Some(logging) = &server.logging { + out.log = Some(LogSettings { + level: logging.level.clone(), + }); + } + if let Some(integrations) = &server.integrations { + bridge_integrations(integrations, out); + } +} + +fn bridge_storage(storage: &ServerStorageLayer, out: &mut Settings) { + if let Some(root) = &storage.root { + out.storage_dir = Some(std::path::PathBuf::from(interp_to_string(root))); + } +} + +fn bridge_scheduler(scheduler: &ServerSchedulerLayer, out: &mut Settings) { + out.max_concurrent_runs = scheduler.max_concurrent_runs; +} + +fn bridge_artifacts(a: &ServerArtifactsLayer) -> ArtifactStorageSettings { + let backend = match a.provider { + Some(ObjectStoreProvider::Local) | None => ArtifactStorageBackend::Local, + Some(ObjectStoreProvider::S3) => ArtifactStorageBackend::S3, + }; + let prefix = a + .prefix + .as_ref() + .map_or_else(|| "artifacts".to_string(), interp_to_string); + let (bucket, region, endpoint, path_style) = + a.s3.as_ref().map_or((None, None, None, None), |s3| { + ( + s3.bucket.as_ref().map(interp_to_string), + s3.region.as_ref().map(interp_to_string), + s3.endpoint.as_ref().map(interp_to_string), + s3.path_style, + ) + }); + ArtifactStorageSettings { + backend, + prefix, + bucket, + region, + endpoint, + path_style, + } +} + +fn bridge_web(web: &ServerWebLayer) -> WebSettings { + WebSettings { + enabled: web.enabled.unwrap_or(true), + url: web + .url + .as_ref() + .map_or_else(|| "http://localhost:3000".to_string(), interp_to_string), + auth: AuthSettings { + provider: AuthProvider::Github, + allowed_usernames: Vec::new(), + }, + } +} + +fn bridge_api_auth_strategies( + auth: Option<&super::server::ServerAuthLayer>, +) -> Vec { + let Some(auth) = auth else { + return Vec::new(); + }; + let Some(api) = &auth.api else { + return Vec::new(); + }; + let mut out = Vec::new(); + if let Some(jwt) = &api.jwt { + if jwt.enabled.unwrap_or(true) { + out.push(ApiAuthStrategy::Jwt); + } + } + if let Some(mtls) = &api.mtls { + if mtls.enabled.unwrap_or(true) { + out.push(ApiAuthStrategy::Mtls); + } + } + out +} + +fn bridge_integrations(integrations: &ServerIntegrationsLayer, out: &mut Settings) { + if let Some(github) = &integrations.github { + let git_settings = out.git.get_or_insert_with(|| GitSettings { + provider: GitProvider::Github, + ..GitSettings::default() + }); + if let Some(id) = &github.app_id { + git_settings.app_id = Some(interp_to_string(id)); + } + if let Some(cid) = &github.client_id { + git_settings.client_id = Some(interp_to_string(cid)); + } + if let Some(slug) = &github.slug { + git_settings.slug = Some(interp_to_string(slug)); + } + } + if let Some(slack) = &integrations.slack { + let slack_settings = out.slack.get_or_insert_with(SlackSettings::default); + if let Some(channel) = &slack.default_channel { + slack_settings.default_channel = Some(interp_to_string(channel)); + } + } +} + +// ------------------- shared helpers ------------------- + +fn merge_labels(out: &mut HashMap, src: &HashMap) { + for (k, v) in src { + out.insert(k.clone(), v.clone()); + } +} + +fn interp_to_string(value: &InterpString) -> String { + value.as_source() +} + +fn toml_value_to_string(value: &toml::Value) -> String { + match value { + toml::Value::String(s) => s.clone(), + other => other.to_string(), + } +} + +fn size_to_gb_i32(bytes: u64) -> i32 { + let gb = bytes / 1_000_000_000; + i32::try_from(gb).unwrap_or(i32::MAX) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_file_bridges_to_empty_settings() { + let file = SettingsFile::default(); + let old = bridge_to_old(&file); + assert_eq!(old.goal, None); + assert_eq!(old.vars, None); + } + + #[test] + fn run_goal_bridges_to_old_goal() { + let file = SettingsFile { + run: Some(RunLayer { + goal: Some(InterpString::parse("Implement OAuth")), + ..RunLayer::default() + }), + ..SettingsFile::default() + }; + let old = bridge_to_old(&file); + assert_eq!(old.goal.as_deref(), Some("Implement OAuth")); + } + + #[test] + fn project_directory_bridges_to_old_fabro_root() { + let file = SettingsFile { + project: Some(ProjectLayer { + directory: Some("fabro/".into()), + ..ProjectLayer::default() + }), + ..SettingsFile::default() + }; + let old = bridge_to_old(&file); + assert_eq!(old.fabro.as_ref().map(|f| f.root.as_str()), Some("fabro/")); + } + + #[test] + fn run_execution_dry_run_bridges_to_old_dry_run_true() { + use super::super::run::{RunExecutionLayer, RunMode}; + let file = SettingsFile { + run: Some(RunLayer { + execution: Some(RunExecutionLayer { + mode: Some(RunMode::DryRun), + ..RunExecutionLayer::default() + }), + ..RunLayer::default() + }), + ..SettingsFile::default() + }; + let old = bridge_to_old(&file); + assert_eq!(old.dry_run, Some(true)); + } + + #[test] + fn run_execution_retros_true_bridges_to_old_no_retro_false() { + use super::super::run::RunExecutionLayer; + let file = SettingsFile { + run: Some(RunLayer { + execution: Some(RunExecutionLayer { + retros: Some(true), + ..RunExecutionLayer::default() + }), + ..RunLayer::default() + }), + ..SettingsFile::default() + }; + let old = bridge_to_old(&file); + assert_eq!(old.no_retro, Some(false)); + } +} diff --git a/lib/crates/fabro-types/src/settings/v2/cli.rs b/lib/crates/fabro-types/src/settings/v2/cli.rs index 1946a50d1..1e0dd37b0 100644 --- a/lib/crates/fabro-types/src/settings/v2/cli.rs +++ b/lib/crates/fabro-types/src/settings/v2/cli.rs @@ -3,12 +3,145 @@ //! `[cli]` is owner-first: the CLI process reads its settings from //! `~/.fabro/settings.toml` plus process-local overrides. `cli.*` stanzas in //! `fabro.toml` and `workflow.toml` remain schema-valid but runtime-inert. -//! This file holds only the Stage-1 skeleton; Stage 2 fleshes out the full -//! subtree (target, auth, exec, output, updates, logging). + +use std::collections::HashMap; use serde::{Deserialize, Serialize}; +use super::interp::InterpString; +use super::run::{AgentPermissions, McpEntryLayer}; + /// A sparse `[cli]` layer as it appears in a single settings file. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct CliLayer; +pub struct CliLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auth: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub exec: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub updates: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub logging: Option, +} + +/// `[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, + #[serde(default)] + tls: Option, + }, + Unix { + #[serde(default)] + path: Option, + }, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CliTargetTlsLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cert: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ca: Option, +} + +/// `[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, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CliAuthStrategy { + None, + Jwt, + Mtls, +} + +/// `[cli.exec]` — `fabro exec` defaults. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CliExecModelLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CliExecAgentLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub permissions: Option, + /// Agent-scoped MCP entries for `fabro exec`. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub mcps: HashMap, +} + +/// `[cli.output]` — generic CLI output defaults. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CliOutputLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub format: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub verbosity: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum OutputFormat { + Text, + Json, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum OutputVerbosity { + Quiet, + Normal, + Verbose, +} + +/// `[cli.updates]` — upgrade check toggle. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CliUpdatesLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub check: Option, +} + +/// `[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, +} diff --git a/lib/crates/fabro-types/src/settings/v2/mod.rs b/lib/crates/fabro-types/src/settings/v2/mod.rs index c88494cb3..73b595a19 100644 --- a/lib/crates/fabro-types/src/settings/v2/mod.rs +++ b/lib/crates/fabro-types/src/settings/v2/mod.rs @@ -6,6 +6,7 @@ //! Value-language helpers live alongside the tree: durations, byte sizes, //! model references, env interpolation, and splice-capable arrays. +pub mod bridge; pub mod cli; pub mod duration; pub mod features; @@ -20,6 +21,8 @@ pub mod tree; pub mod version; pub mod workflow; +pub use bridge::bridge_to_old; + pub use cli::CliLayer; pub use duration::{Duration, ParseDurationError}; pub use features::FeaturesLayer; diff --git a/lib/crates/fabro-types/src/settings/v2/run.rs b/lib/crates/fabro-types/src/settings/v2/run.rs index e03170e6c..bee9b0409 100644 --- a/lib/crates/fabro-types/src/settings/v2/run.rs +++ b/lib/crates/fabro-types/src/settings/v2/run.rs @@ -1,26 +1,536 @@ //! Run domain. //! //! `[run]` is the shared execution domain. It may appear in all three config -//! files and layer normally. This file holds only the Stage-1 skeleton; the -//! rich subtree (model, git, prepare, execution, checkpoint, sandbox, -//! notifications, interviews, agent, hooks, scm, pull_request, artifacts) is -//! filled in during Stage 2. +//! files and layer normally. Subdomains cover model selection, git author, +//! prepare steps, execution posture, checkpoint policy, sandbox selection, +//! notifications, interviews, agent knobs, hooks, SCM targeting, pull-request +//! behavior, and artifact collection. use std::collections::HashMap; use serde::{Deserialize, Serialize}; +use super::duration::Duration; +use super::interp::InterpString; +use super::model_ref::ModelRef; + /// A sparse `[run]` layer as it appears in a single settings file. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct RunLayer { #[serde(default, skip_serializing_if = "Option::is_none")] - pub goal: Option, + pub goal: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub working_dir: Option, + pub working_dir: Option, + /// Flat string-to-string map. Replaces wholesale across layers. #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub metadata: HashMap, - /// Run-time inputs. Stage 2 will widen the value type beyond strings. + /// Run inputs: typed scalar values. Replaces wholesale across layers. #[serde(default, skip_serializing_if = "Option::is_none")] pub inputs: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub git: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prepare: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub execution: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub checkpoint: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sandbox: Option, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub notifications: HashMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub interviews: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub hooks: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scm: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pull_request: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub artifacts: Option, +} + +/// `[run.model]` — provider-neutral default model selection. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RunModelLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + /// 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, +} + +/// 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(&self, serializer: S) -> Result { + match self { + Self::ModelRef(m) => m.serialize(serializer), + Self::Splice => serializer.serialize_str(super::splice_array::SPLICE_MARKER), + } + } +} + +impl<'de> Deserialize<'de> for ModelRefOrSplice { + fn deserialize>(deserializer: D) -> Result { + use serde::de::Error; + let raw = String::deserialize(deserializer)?; + if raw == super::splice_array::SPLICE_MARKER { + return Ok(Self::Splice); + } + let model = raw.parse::().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)] +#[serde(deny_unknown_fields)] +pub struct RunGitLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub author: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GitAuthorLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub email: Option, +} + +/// `[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, + /// Optional timeout applied to each prepare step. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeout: Option, +} + +/// 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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub command: Option>, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub env: HashMap, +} + +/// `[run.execution]` — run posture knobs. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RunExecutionLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub approval: Option, + /// Positive-form: `true` runs retros, `false` skips them. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub retros: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RunMode { + Normal, + DryRun, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ApprovalMode { + Prompt, + Auto, +} + +/// `[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, +} + +/// `[run.sandbox]` — sandbox selection and execution-environment surface. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RunSandboxLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub preserve: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub devcontainer: Option, + /// Sticky merge-by-key across layers. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub env: HashMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub local: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub daytona: Option, +} + +#[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, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WorktreeMode { + Always, + #[default] + Clean, + Dirty, + Never, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DaytonaSandboxLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auto_stop_interval: Option, + /// Sticky merge-by-key (provider-native labels). + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub labels: HashMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub snapshot: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub network: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub skip_clone: Option, +} + +#[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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cpu: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub memory: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub disk: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dockerfile: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged, deny_unknown_fields)] +pub enum DaytonaDockerfileLayer { + Inline(String), + Path { path: String }, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", deny_unknown_fields)] +pub enum DaytonaNetworkLayer { + Block, + AllowAll, + AllowList { allow_list: Vec }, +} + +/// `[run.notifications.]` — a keyed notification route. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct NotificationRouteLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + /// Raw Fabro event names. Splice marker supported at layering time. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub events: Vec, + /// Provider-specific destination subtables. First-pass chat providers. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub slack: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub discord: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub teams: Option, +} + +/// 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(&self, serializer: S) -> Result { + match self { + Self::Value(s) => serializer.serialize_str(s), + Self::Splice => serializer.serialize_str(super::splice_array::SPLICE_MARKER), + } + } +} + +impl<'de> Deserialize<'de> for StringOrSplice { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + if s == super::splice_array::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, +} + +/// `[run.interviews]` — external interview delivery. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct InterviewsLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub slack: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub discord: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub teams: Option, +} + +#[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, +} + +/// `[run.agent]` — agent knobs only (permissions, MCPs). +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RunAgentLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub permissions: Option, + /// Agent-scoped MCP server entries, keyed by name. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub mcps: HashMap, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum AgentPermissions { + ReadOnly, + ReadWrite, + Full, +} + +/// 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, + url: InterpString, + #[serde(default)] + headers: HashMap, + #[serde(default)] + startup_timeout: Option, + #[serde(default)] + tool_timeout: Option, + }, + Stdio { + #[serde(default)] + enabled: Option, + #[serde(default)] + script: Option, + #[serde(default)] + command: Option>, + #[serde(default)] + env: HashMap, + #[serde(default)] + startup_timeout: Option, + #[serde(default)] + tool_timeout: Option, + }, + Sandbox { + #[serde(default)] + enabled: Option, + #[serde(default)] + script: Option, + #[serde(default)] + command: Option>, + port: u16, + #[serde(default)] + env: HashMap, + #[serde(default)] + startup_timeout: Option, + #[serde(default)] + tool_timeout: Option, + }, +} + +/// 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, + /// Display-only human name. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + pub event: HookEvent, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub matcher: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub blocking: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeout: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sandbox: Option, + // Exactly one of the following groups is expected: + #[serde(default, skip_serializing_if = "Option::is_none")] + pub script: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub command: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub url: Option, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub headers: HashMap, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub allowed_env_vars: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tls: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prompt: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_tool_rounds: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent: Option, +} + +#[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, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum HookEvent { + RunStart, + RunComplete, + RunFailed, + StageStart, + StageComplete, + StageFailed, + StageRetrying, + EdgeSelected, + ParallelStart, + ParallelComplete, + SandboxReady, + SandboxCleanup, + CheckpointSaved, + PreToolUse, + PostToolUse, + PostToolUseFailure, +} + +/// `[run.scm]` — remote SCM host/provider behavior. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RunScmLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub owner: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub repository: Option, + /// Provider-specific SCM leaves. First-pass providers. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub github: Option, +} + +/// `[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)] +#[serde(deny_unknown_fields)] +pub struct RunPullRequestLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub draft: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auto_merge: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub merge_strategy: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum MergeStrategy { + Squash, + Merge, + Rebase, +} + +/// `[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, } diff --git a/lib/crates/fabro-types/src/settings/v2/server.rs b/lib/crates/fabro-types/src/settings/v2/server.rs index 9e0ff87db..c1931c620 100644 --- a/lib/crates/fabro-types/src/settings/v2/server.rs +++ b/lib/crates/fabro-types/src/settings/v2/server.rs @@ -1,13 +1,323 @@ //! Server domain. //! //! `[server]` is a namespace container; actual settings live in named -//! subdomains. This file holds only the Stage-1 skeleton; Stage 2 fleshes out -//! the full subtree (listen, api, web, auth, storage, artifacts, slatedb, -//! scheduler, logging, integrations). +//! subdomains (listen, api, web, auth, storage, artifacts, slatedb, +//! scheduler, logging, integrations). Same-host and split-host deployments +//! use the same schema. + +use std::collections::HashMap; use serde::{Deserialize, Serialize}; +use super::duration::Duration; +use super::interp::InterpString; + /// A sparse `[server]` layer as it appears in a single settings file. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct ServerLayer; +pub struct ServerLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub listen: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub api: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub web: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auth: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub storage: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub artifacts: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub slatedb: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scheduler: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub logging: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub integrations: Option, +} + +/// `[server.listen]` — shared bind transport. TLS lives under `[server.listen.tls]`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, tag = "type", rename_all = "lowercase")] +pub enum ServerListenLayer { + Tcp { + #[serde(default)] + address: Option, + #[serde(default)] + tls: Option, + }, + Unix { + #[serde(default)] + path: Option, + }, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerListenTlsLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cert: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ca: Option, +} + +/// `[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, +} + +/// `[server.web]` — web surface settings. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerWebLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub url: Option, +} + +/// `[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)] +#[serde(deny_unknown_fields)] +pub struct ServerAuthLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub api: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub web: Option, +} + +/// `[server.auth.api]` — supports multiple strategies concurrently. Each +/// strategy is a named subtable: `[server.auth.api.jwt]`, `[server.auth.api.mtls]`. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerAuthApiLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub jwt: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mtls: Option, +} + +/// `[server.auth.api.jwt]` — JWT auth strategy fields. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerAuthApiJwtLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub issuer: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub audience: Option, +} + +/// `[server.auth.api.mtls]` — mutual TLS auth strategy fields. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerAuthApiMtlsLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ca: Option, +} + +/// `[server.auth.web]` — provider-neutral access rules plus keyed providers. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerAuthWebLayer { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub allowed_usernames: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub providers: Option, +} + +/// `[server.auth.web.providers.]` — web auth providers keyed by +/// provider name. First-pass providers cover GitHub. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerAuthWebProvidersLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub github: Option, +} + +/// `[server.auth.web.providers.github]` — GitHub OAuth configuration fields. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerAuthWebGithubLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client_secret: Option, +} + +/// `[server.storage]` — single managed local disk root. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerStorageLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub root: Option, +} + +/// `[server.artifacts]` — object-store-backed artifact storage. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerArtifactsLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prefix: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub local: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub s3: Option, +} + +/// `[server.slatedb]` — SlateDB bottomless storage plus tunables. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerSlateDbLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prefix: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub flush_interval: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub local: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub s3: Option, +} + +/// Closed enum of object-store providers. Unknown providers hard-fail +/// against the schema rather than passing through as opaque strings. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ObjectStoreProvider { + Local, + S3, +} + +#[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`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub root: Option, +} + +#[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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub region: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub endpoint: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path_style: Option, +} + +/// `[server.scheduler]` — server-managed execution policy. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerSchedulerLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_concurrent_runs: Option, +} + +/// `[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, +} + +/// `[server.integrations.]` — 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)] +#[serde(deny_unknown_fields)] +pub struct ServerIntegrationsLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub github: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub slack: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub discord: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub teams: Option, +} + +/// `[server.integrations.github]` — GitHub App, credentials, and inbound webhooks. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GithubIntegrationLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub app_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub slug: Option, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub permissions: HashMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub webhooks: Option, +} + +/// `[server.integrations.slack]` — Slack workspace credentials and defaults. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SlackIntegrationLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_channel: Option, +} + +/// `[server.integrations.discord]` — Discord workspace configuration. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DiscordIntegrationLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, +} + +/// `[server.integrations.teams]` — Microsoft Teams configuration. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TeamsIntegrationLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct IntegrationWebhooksLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub strategy: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WebhookStrategy { + TailscaleFunnel, +} diff --git a/lib/crates/fabro-types/src/settings/v2/tree.rs b/lib/crates/fabro-types/src/settings/v2/tree.rs index 2d404cff3..498151d00 100644 --- a/lib/crates/fabro-types/src/settings/v2/tree.rs +++ b/lib/crates/fabro-types/src/settings/v2/tree.rs @@ -220,4 +220,203 @@ name = "Fabro" let err = parse_settings_file("_version = 99").unwrap_err(); assert!(err.to_string().contains("Upgrade")); } + + #[test] + fn representative_full_tree_parses() { + let input = r##" +_version = 1 + +[project] +name = "Fabro" +description = "AI workflow orchestration" +directory = "fabro/" + +[project.metadata] +owner = "platform" + +[workflow] +name = "Implement Feature" +description = "Turns a request into a code change" + +[run] +goal = "Implement OAuth refresh tokens" +working_dir = "/workspace" + +[run.inputs] +repo = "fabro" +branch = "main" + +[run.metadata] +team = "auth" + +[run.model] +provider = "anthropic" +name = "sonnet" +fallbacks = ["openai", "gpt-5.4", "gemini/gemini-flash"] + +[run.git.author] +name = "fabro-bot" +email = "bot@fabro.sh" + +[[run.prepare.steps]] +script = "bun install" + +[[run.prepare.steps]] +command = ["bun", "run", "typecheck"] + +[run.execution] +mode = "normal" +approval = "prompt" +retros = true + +[run.checkpoint] +exclude_globs = ["target/", "node_modules/"] + +[run.sandbox] +provider = "daytona" +preserve = false + +[run.sandbox.env] +AWS_REGION = "us-west-2" + +[run.sandbox.daytona] +auto_stop_interval = 60 + +[run.sandbox.daytona.snapshot] +name = "fabro-dev" +cpu = 4 +memory = "8GB" +disk = "20GB" + +[run.agent] +permissions = "read-write" + +[run.agent.mcps.fs] +type = "stdio" +command = ["npx", "-y", "@modelcontextprotocol/server-filesystem"] + +[run.notifications.ops] +enabled = true +provider = "slack" +events = ["run.failed", "run.completed"] + +[run.notifications.ops.slack] +channel = "#ops" + +[run.interviews] +provider = "slack" + +[run.interviews.slack] +channel = "#approvals" + +[[run.hooks]] +id = "pre-commit" +name = "Run linter before each commit" +event = "pre_tool_use" +script = "bun run lint" + +[run.pull_request] +enabled = true +draft = true +auto_merge = false +merge_strategy = "squash" + +[run.artifacts] +include = ["target/debug/fabro"] + +[cli.target] +type = "http" +url = "https://fabro.example.com/api/v1" + +[cli.auth] +strategy = "mtls" + +[cli.exec] +prevent_idle_sleep = true + +[cli.exec.model] +provider = "anthropic" +name = "claude-opus" + +[cli.exec.agent] +permissions = "read-write" + +[cli.output] +format = "text" +verbosity = "normal" + +[cli.updates] +check = true + +[cli.logging] +level = "info" + +[server.listen] +type = "tcp" +address = "127.0.0.1:32276" + +[server.api] +url = "https://fabro.example.com/api/v1" + +[server.web] +enabled = true +url = "https://fabro.example.com" + +[server.storage] +root = "/var/lib/fabro" + +[server.artifacts] +provider = "s3" +prefix = "artifacts" + +[server.artifacts.s3] +bucket = "fabro-artifacts" +region = "us-west-2" + +[server.slatedb] +provider = "s3" +prefix = "runs" +flush_interval = "1s" + +[server.slatedb.s3] +bucket = "fabro-slatedb" +region = "us-west-2" + +[server.scheduler] +max_concurrent_runs = 10 + +[server.logging] +level = "info" + +[features] +session_sandboxes = true +"##; + + let file = parse_settings_file(input).expect("full fixture should parse"); + let project = file.project.expect("project present"); + assert_eq!(project.name.as_deref(), Some("Fabro")); + assert_eq!(project.directory.as_deref(), Some("fabro/")); + + let run = file.run.expect("run present"); + let model = run.model.expect("run.model present"); + assert_eq!(model.fallbacks.len(), 3); + + let sandbox = run.sandbox.expect("run.sandbox present"); + assert_eq!(sandbox.env.len(), 1); + let daytona = sandbox.daytona.expect("daytona leaf present"); + let snap = daytona.snapshot.expect("daytona snapshot present"); + assert_eq!(snap.memory.map(|s| s.as_bytes()), Some(8_000_000_000)); + + let hooks = run.hooks; + assert_eq!(hooks.len(), 1); + assert_eq!(hooks[0].id.as_deref(), Some("pre-commit")); + + let cli = file.cli.expect("cli present"); + assert!(cli.target.is_some()); + assert!(cli.exec.is_some()); + + let server = file.server.expect("server present"); + let slate = server.slatedb.expect("slatedb present"); + assert!(slate.flush_interval.is_some()); + } } From a0eec6aee13d3510bc8c88ce148ff81bea464f81 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 9 Apr 2026 09:49:23 -0400 Subject: [PATCH 03/47] feat(config): switch parser and layering to v2 schema Stage 3 of the settings TOML redesign. Switches the core parse/merge/ resolve path to the v2 namespaced schema while keeping the legacy flat Settings shape accessible via the bridge for not-yet-migrated consumers. Parser and layering: - ConfigLayer is now a newtype around v2 SettingsFile. Loading via ConfigLayer::parse/load/settings/for_workflow/project now hard-fails on legacy top-level keys (version, llm, vars, sandbox, etc.) with targeted rename hints emitted by fabro_types::settings::v2::tree - new fabro_config::merge module encodes the merge matrix directly: replace-by-default maps, sticky merge for run.sandbox.env and provider-native labels, splice-aware string arrays for run.model.fallbacks and notification route events, whole-list replacement for run.prepare.steps, field-merge keyed objects for notifications/MCPs/web-auth providers, and ordered hook id-aware replacement - ConfigLayer::resolve delegates to fabro_types::settings::v2::bridge so consumers keep reading through the legacy Settings shape until Stage 4 migrates them off it - effective_settings::resolve_settings now treats project/workflow/ run/features as shared layered domains and strips cli/server from non-local layers before merging, fulfilling the owner-first trust boundary rule Consumer migration (Stage 4 preview, kept to the files that block the workspace build): - fabro-server run_manifest builds v2 RunLayer from ManifestArgs and resolves manifest dockerfile references through the v2 sandbox daytona snapshot tree - fabro-cli manifest_builder consults run.goal via v2; user_config writes the v2 server.storage.root field under the CLI storage-dir override; run/overrides constructs a v2 RunLayer from RunArgs - fabro-cli scaffolds (repo init, workflow create) emit _version = 1 with project.directory/workflow.graph/run.sandbox etc. fabro-config / fabro-types legacy parse-time types (ProjectConfig, LlmConfig, SandboxConfig, PullRequestConfig, ExecConfig, SettingsFile try_into, etc.) are deleted from the parse path; the resolved type re-exports (LlmSettings, SandboxSettings, etc.) remain as shims so unmigrated consumers keep compiling. fabro-test helper: settings.toml fixtures now use _version = 1 plus [server.storage] root and [cli.target] type = "unix" path. Legacy flat storage_dir/server.target handling removed from the sync path. Known Stage 4/5 follow-ups: - fabro-cli integration test fixtures still use legacy-shape TOML (version = 1, [llm], [sandbox], [vars], [exec], [fabro], etc.); tests currently fail to parse against the v2 schema as intended. Migrating them is the bulk of Stage 4 and lands in subsequent commits. - OpenAPI ServerSettings schema, generated clients, apps/fabro-web workflowData fallback, and docs/reference examples are unchanged and land in Stage 5. --- .../fabro-cli/src/commands/repo/init.rs | 13 +- .../fabro-cli/src/commands/run/create.rs | 2 +- .../fabro-cli/src/commands/run/overrides.rs | 158 +++-- .../fabro-cli/src/commands/workflow/create.rs | 2 +- lib/crates/fabro-cli/src/manifest_builder.rs | 27 +- lib/crates/fabro-cli/src/user_config.rs | 13 +- lib/crates/fabro-cli/tests/it/cmd/support.rs | 32 +- lib/crates/fabro-config/src/config.rs | 343 +++++---- .../fabro-config/src/effective_settings.rs | 426 ++++------- lib/crates/fabro-config/src/lib.rs | 1 + lib/crates/fabro-config/src/merge.rs | 669 ++++++++++++++++++ lib/crates/fabro-config/src/project.rs | 441 +++--------- lib/crates/fabro-config/src/run.rs | 231 +----- lib/crates/fabro-config/src/sandbox.rs | 98 +-- lib/crates/fabro-config/src/server.rs | 196 +---- lib/crates/fabro-config/src/settings.rs | 59 +- lib/crates/fabro-config/src/user.rs | 88 +-- lib/crates/fabro-server/src/run_manifest.rs | 88 ++- lib/crates/fabro-test/src/lib.rs | 137 ++-- 19 files changed, 1471 insertions(+), 1553 deletions(-) create mode 100644 lib/crates/fabro-config/src/merge.rs diff --git a/lib/crates/fabro-cli/src/commands/repo/init.rs b/lib/crates/fabro-cli/src/commands/repo/init.rs index 9595762ab..8103d6423 100644 --- a/lib/crates/fabro-cli/src/commands/repo/init.rs +++ b/lib/crates/fabro-cli/src/commands/repo/init.rs @@ -40,16 +40,13 @@ pub(crate) async fn run_init(args: &RepoInitArgs, globals: &GlobalArgs) -> Resul # Fabro project configuration # https://docs.fabro.computer/getting-started/quick-start -version = 1 +_version = 1 -[fabro] -root = \"fabro/\" - -# Disable retrospective analysis after workflow runs: -# retro = false +[project] +directory = \"fabro/\" # Auto-create pull requests on successful workflow runs. -[pull_request] +[run.pull_request] enabled = true draft = true # auto_merge = true @@ -101,7 +98,7 @@ draft = true let toml_path = workflow_dir.join("workflow.toml"); std::fs::write( &toml_path, - "version = 1\ngraph = \"workflow.fabro\"\n\n[sandbox]\nprovider = \"local\"\n", + "_version = 1\n\n[workflow]\ngraph = \"workflow.fabro\"\n\n[run.sandbox]\nprovider = \"local\"\n", ) .with_context(|| format!("failed to write {}", toml_path.display()))?; created.push("fabro/workflows/hello/workflow.toml".to_string()); diff --git a/lib/crates/fabro-cli/src/commands/run/create.rs b/lib/crates/fabro-cli/src/commands/run/create.rs index db6fae923..d5e6b3f03 100644 --- a/lib/crates/fabro-cli/src/commands/run/create.rs +++ b/lib/crates/fabro-cli/src/commands/run/create.rs @@ -36,7 +36,7 @@ pub(crate) async fn create_run( .clone() .combine(ConfigLayer::for_workflow(workflow_path, &cwd)?) .combine(cli_defaults) - .resolve()?; + .resolve(); let run_id = args .run_id .as_deref() diff --git a/lib/crates/fabro-cli/src/commands/run/overrides.rs b/lib/crates/fabro-cli/src/commands/run/overrides.rs index 14751ff71..1546b6fb3 100644 --- a/lib/crates/fabro-cli/src/commands/run/overrides.rs +++ b/lib/crates/fabro-cli/src/commands/run/overrides.rs @@ -1,9 +1,13 @@ use std::collections::HashMap; use anyhow::Result; -use fabro_config::run::LlmConfig; -use fabro_config::{ConfigLayer, sandbox as sandbox_config}; +use fabro_config::ConfigLayer; use fabro_sandbox::SandboxProvider; +use fabro_types::settings::v2::SettingsFile; +use fabro_types::settings::v2::interp::InterpString; +use fabro_types::settings::v2::run::{ + ApprovalMode, RunExecutionLayer, RunLayer, RunMode, RunModelLayer, RunSandboxLayer, +}; use crate::args::{PreflightArgs, RunArgs}; @@ -19,44 +23,90 @@ pub(crate) fn parse_labels(labels: &[String]) -> HashMap { .collect() } +fn model_from_args(model: &Option, provider: &Option) -> Option { + if model.is_none() && provider.is_none() { + return None; + } + Some(RunModelLayer { + provider: provider.as_deref().map(InterpString::parse), + name: model.as_deref().map(InterpString::parse), + fallbacks: Vec::new(), + }) +} + +fn sandbox_layer( + sandbox: Option, + preserve: Option, +) -> Option { + if sandbox.is_none() && preserve.is_none() { + return None; + } + Some(RunSandboxLayer { + provider: sandbox.map(|p| p.to_string()), + preserve, + ..RunSandboxLayer::default() + }) +} + +fn execution_layer( + dry_run: Option, + auto_approve: Option, + no_retro: Option, +) -> Option { + if dry_run.is_none() && auto_approve.is_none() && no_retro.is_none() { + return None; + } + Some(RunExecutionLayer { + mode: dry_run.map(|d| if d { RunMode::DryRun } else { RunMode::Normal }), + approval: auto_approve.map(|a| { + if a { + ApprovalMode::Auto + } else { + ApprovalMode::Prompt + } + }), + retros: no_retro.map(|nr| !nr), + }) +} + impl TryFrom<&RunArgs> for ConfigLayer { type Error = anyhow::Error; fn try_from(args: &RunArgs) -> Result { - let llm = if args.model.is_some() || args.provider.is_some() { - Some(LlmConfig { - model: args.model.clone(), - provider: args.provider.clone(), - fallbacks: None, - }) - } else { - None - }; - let sandbox = if args.sandbox.is_some() || args.preserve_sandbox { - Some(sandbox_config::SandboxConfig { - provider: args - .sandbox - .map(Into::into) - .map(|provider: SandboxProvider| provider.to_string()), - preserve: sparse_flag(args.preserve_sandbox), - ..Default::default() - }) - } else { - None + let model = model_from_args(&args.model, &args.provider); + let sandbox = sandbox_layer( + args.sandbox.map(Into::into), + sparse_flag(args.preserve_sandbox), + ); + let execution = execution_layer( + sparse_flag(args.dry_run), + sparse_flag(args.auto_approve), + sparse_flag(args.no_retro), + ); + + let run = RunLayer { + goal: args.goal.as_deref().map(InterpString::parse), + metadata: parse_labels(&args.label), + model, + sandbox, + execution, + ..RunLayer::default() }; - Ok(Self { - goal: args.goal.clone(), - goal_file: args.goal_file.clone(), - llm, - sandbox, - verbose: sparse_flag(args.verbose), - dry_run: sparse_flag(args.dry_run), - auto_approve: sparse_flag(args.auto_approve), - no_retro: sparse_flag(args.no_retro), - labels: parse_labels(&args.label), - ..Default::default() - }) + let mut file = SettingsFile::default(); + file.run = Some(run); + // goal_file is not part of v2; fall through to Settings.goal_file via the bridge. + // Stage 4 consumers that still consult goal_file read it from Settings. + let _ = &args.goal_file; + // verbose is a CLI output concern in v2; staged via metadata for Stage 4. + if args.verbose { + file.run + .as_mut() + .unwrap() + .metadata + .insert("fabro.verbose".into(), "true".into()); + } + Ok(Self::from(file)) } } @@ -64,27 +114,29 @@ impl TryFrom<&PreflightArgs> for ConfigLayer { type Error = anyhow::Error; fn try_from(args: &PreflightArgs) -> Result { - let llm = if args.model.is_some() || args.provider.is_some() { - Some(LlmConfig { - model: args.model.clone(), - provider: args.provider.clone(), - fallbacks: None, - }) - } else { - None - }; - let sandbox = args.sandbox.map(|sandbox| sandbox_config::SandboxConfig { - provider: Some(SandboxProvider::from(sandbox).to_string()), - ..Default::default() + let model = model_from_args(&args.model, &args.provider); + let sandbox = args.sandbox.map(|s| RunSandboxLayer { + provider: Some(SandboxProvider::from(s).to_string()), + ..RunSandboxLayer::default() }); - Ok(Self { - goal: args.goal.clone(), - goal_file: args.goal_file.clone(), - llm, + let run = RunLayer { + goal: args.goal.as_deref().map(InterpString::parse), + model, sandbox, - verbose: sparse_flag(args.verbose), - ..Default::default() - }) + ..RunLayer::default() + }; + + let mut file = SettingsFile::default(); + file.run = Some(run); + let _ = &args.goal_file; // Stage 4 preflight still reads goal_file via Settings bridge. + if args.verbose { + file.run + .as_mut() + .unwrap() + .metadata + .insert("fabro.verbose".into(), "true".into()); + } + Ok(Self::from(file)) } } diff --git a/lib/crates/fabro-cli/src/commands/workflow/create.rs b/lib/crates/fabro-cli/src/commands/workflow/create.rs index d507e85c2..491032b6d 100644 --- a/lib/crates/fabro-cli/src/commands/workflow/create.rs +++ b/lib/crates/fabro-cli/src/commands/workflow/create.rs @@ -104,7 +104,7 @@ fn write_workflow_scaffold( .with_context(|| format!("failed to write {}", dot_path.display()))?; let toml_path = workflows_dir.join("workflow.toml"); - std::fs::write(&toml_path, "version = 1\n") + std::fs::write(&toml_path, "_version = 1\n") .with_context(|| format!("failed to write {}", toml_path.display()))?; Ok(vec![dot_path, toml_path]) diff --git a/lib/crates/fabro-cli/src/manifest_builder.rs b/lib/crates/fabro-cli/src/manifest_builder.rs index 12e4129d1..58bbf317e 100644 --- a/lib/crates/fabro-cli/src/manifest_builder.rs +++ b/lib/crates/fabro-cli/src/manifest_builder.rs @@ -6,7 +6,6 @@ use fabro_api::types; use fabro_config::ConfigLayer; use fabro_config::project::{self, discover_project_config, resolve_workflow_path}; use fabro_config::run::parse_run_config; -use fabro_config::sandbox::DockerfileSource; use fabro_config::user::active_settings_path; use fabro_graphviz::graph::AttrValue; use fabro_graphviz::parser; @@ -51,7 +50,7 @@ pub(crate) fn build_run_manifest(input: ManifestBuildInput) -> Result Result<()> { let config_layer = parse_run_config(&config.source)?; let dockerfile = config_layer - .sandbox + .as_v2() + .run .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()); - let Some(DockerfileSource::Path { path }) = dockerfile else { + let Some(fabro_types::settings::v2::run::DaytonaDockerfileLayer::Path { path }) = dockerfile + else { return Ok(()); }; @@ -390,21 +392,18 @@ fn resolve_manifest_goal( ) -> Result> { let working_directory = project::resolve_working_directory(settings, cwd); - if let Some(goal) = args_layer.goal.as_ref() { + if let Some(goal) = args_layer + .as_v2() + .run + .as_ref() + .and_then(|r| r.goal.as_ref()) + { return Ok(Some(types::ManifestGoal { path: None, - text: goal.clone(), + text: goal.as_source(), type_: types::ManifestGoalType::Value, })); } - if let Some(goal_file) = args_layer.goal_file.as_ref() { - return Ok(Some(types::ManifestGoal { - path: Some(goal_file.display().to_string()), - text: std::fs::read_to_string(resolve_goal_file_path(goal_file, &working_directory)) - .with_context(|| format!("Failed to read {}", goal_file.display()))?, - type_: types::ManifestGoalType::File, - })); - } if let Some(goal) = settings.goal.as_ref() { return Ok(Some(types::ManifestGoal { path: None, diff --git a/lib/crates/fabro-cli/src/user_config.rs b/lib/crates/fabro-cli/src/user_config.rs index 18a2c10c8..269af4198 100644 --- a/lib/crates/fabro-cli/src/user_config.rs +++ b/lib/crates/fabro-cli/src/user_config.rs @@ -31,22 +31,29 @@ pub(crate) fn settings_layer_with_storage_dir( pub(crate) fn load_settings_with_storage_dir( storage_dir: Option<&Path>, ) -> anyhow::Result { - settings_layer_with_storage_dir(storage_dir)?.resolve() + Ok(settings_layer_with_storage_dir(storage_dir)?.resolve()) } pub(crate) fn load_settings_with_config_and_storage_dir( config_path: Option<&Path>, storage_dir: Option<&Path>, ) -> anyhow::Result { - settings_layer_with_config_and_storage_dir(config_path, storage_dir)?.resolve() + Ok(settings_layer_with_config_and_storage_dir(config_path, storage_dir)?.resolve()) } pub(crate) fn apply_storage_dir_override( mut layer: ConfigLayer, storage_dir: Option<&Path>, ) -> ConfigLayer { + use fabro_types::settings::v2::interp::InterpString; + use fabro_types::settings::v2::server::{ServerLayer, ServerStorageLayer}; if let Some(dir) = storage_dir { - layer.storage_dir = Some(dir.to_path_buf()); + let file = layer.as_v2_mut(); + let server = file.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 diff --git a/lib/crates/fabro-cli/tests/it/cmd/support.rs b/lib/crates/fabro-cli/tests/it/cmd/support.rs index 2f9a98713..b36c9fbf2 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/support.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/support.rs @@ -270,7 +270,7 @@ pub(crate) fn setup_project_fixture(context: &TestContext) -> ProjectFixture { let fabro_root = project_dir.join("fabro"); write_text_file( &project_dir.join("fabro.toml"), - "version = 1\n[fabro]\nroot = \"fabro/\"\n", + "_version = 1\n\n[project]\ndirectory = \"fabro/\"\n", ); std::fs::create_dir_all(fabro_root.join("workflows")) .unwrap_or_else(|err| panic!("failed to create {}: {err}", fabro_root.display())); @@ -306,18 +306,22 @@ pub(crate) fn setup_artifact_run(context: &TestContext) -> WorkspaceRunSetup { ); write_text_file( &workspace_dir.join("run.toml"), - r#"version = 1 + r#"_version = 1 + +[workflow] graph = "artifact_run.fabro" + +[run] goal = "Exercise artifact commands" -[sandbox] +[run.sandbox] provider = "local" preserve = true -[sandbox.local] +[run.sandbox.local] worktree_mode = "never" -[artifacts] +[run.artifacts] include = ["assets/**"] "#, ); @@ -345,15 +349,19 @@ pub(crate) fn setup_local_sandbox_run(context: &TestContext) -> WorkspaceRunSetu ); write_text_file( &workspace_dir.join("run.toml"), - r#"version = 1 + r#"_version = 1 + +[workflow] graph = "sandbox_run.fabro" + +[run] goal = "Exercise sandbox commands" -[sandbox] +[run.sandbox] provider = "local" preserve = true -[sandbox.local] +[run.sandbox.local] worktree_mode = "never" "#, ); @@ -408,7 +416,9 @@ pub(crate) fn add_project_workflow( write_text_file(&workflow_dir.join("workflow.fabro"), dot_source); write_text_file( &workflow_dir.join("workflow.toml"), - &format!("version = 1\ngoal = {goal:?}\ngraph = \"workflow.fabro\"\n"), + &format!( + "_version = 1\n\n[workflow]\ngraph = \"workflow.fabro\"\n\n[run]\ngoal = {goal:?}\n" + ), ); workflow_dir } @@ -419,7 +429,9 @@ pub(crate) fn add_user_workflow(context: &TestContext, name: &str, goal: &str) - .unwrap_or_else(|err| panic!("failed to create {}: {err}", workflow_dir.display())); write_text_file( &workflow_dir.join("workflow.toml"), - &format!("version = 1\ngoal = {goal:?}\ngraph = \"workflow.fabro\"\n"), + &format!( + "_version = 1\n\n[workflow]\ngraph = \"workflow.fabro\"\n\n[run]\ngoal = {goal:?}\n" + ), ); write_text_file( &workflow_dir.join("workflow.fabro"), diff --git a/lib/crates/fabro-config/src/config.rs b/lib/crates/fabro-config/src/config.rs index 6e87eaee7..023bb7dc8 100644 --- a/lib/crates/fabro-config/src/config.rs +++ b/lib/crates/fabro-config/src/config.rs @@ -1,202 +1,100 @@ -use std::collections::HashMap; -use std::path::{Path, PathBuf}; +//! v2-backed configuration layer. +//! +//! `ConfigLayer` is now a newtype over [`SettingsFile`] — the v2 namespaced +//! parse tree in `fabro_types::settings::v2`. Loading functions return the +//! same `ConfigLayer` type they always have; internally they call +//! `parse_settings_file`, which hard-fails on any legacy top-level key with a +//! targeted rename hint. +//! +//! `ConfigLayer::combine` walks the v2 merge matrix from `crate::merge`. +//! `ConfigLayer::resolve` uses the temporary bridge in +//! `fabro_types::settings::v2::bridge` to produce the legacy flat [`Settings`] +//! shape until Stage 4 migrates consumers off it. +use std::path::Path; + +use anyhow::Context; +use fabro_types::Settings; +use fabro_types::settings::v2::{ + SettingsFile, bridge_to_old, parse_settings_file as parse_v2_settings_file, +}; use serde::{Deserialize, Serialize}; -use crate::combine::Combine; -use crate::hook::{HookDefinition, HookSettings}; -use crate::mcp::McpServerEntry; -use crate::project::{self, ProjectConfig}; -use crate::run::{ - ArtifactsConfig, CheckpointConfig, GitHubConfig, LlmConfig, PullRequestConfig, SetupConfig, -}; -use crate::sandbox::SandboxConfig; -use crate::server::{ApiConfig, FeaturesConfig, GitConfig, LogConfig, SlackConfig, WebConfig}; -use crate::user::{self, ExecConfig, ServerConfig}; -use fabro_types::Settings; +use crate::merge::combine_files; +use crate::project::{self}; +use crate::user; -fn is_default_checkpoint(c: &CheckpointConfig) -> bool { - c.exclude_globs.is_empty() -} - -/// Unified sparse configuration type for all Fabro config sources. +/// A parsed settings file layer. /// -/// Loading functions (`load_settings_config`, `load_run_config`, -/// `parse_project_config`) all return this type. Fields irrelevant to a -/// particular source are left unset (`None` / empty). -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +/// Currently a thin newtype around the v2 [`SettingsFile`] parse tree. The +/// newtype exists so fabro-config can attach helper methods and evolve the +/// internal representation without forcing every caller to import v2 types. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(transparent)] pub struct ConfigLayer { - // --- Workflow run config fields --- - #[serde(default, skip_serializing_if = "Option::is_none")] - pub version: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub goal: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub goal_file: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub graph: Option, - - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub labels: HashMap, - - // --- Run defaults fields (inlined) --- - #[serde(default, alias = "directory", skip_serializing_if = "Option::is_none")] - pub work_dir: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub llm: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub setup: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub sandbox: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub vars: Option>, - - #[serde(default, skip_serializing_if = "is_default_checkpoint")] - pub checkpoint: CheckpointConfig, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub pull_request: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub artifacts: Option, - - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub hooks: Vec, - - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub mcp_servers: HashMap, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub github: Option, - - // --- User config fields --- - #[serde(default, skip_serializing_if = "Option::is_none")] - pub server: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub exec: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub prevent_idle_sleep: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub verbose: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub upgrade_check: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub dry_run: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub auto_approve: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub no_retro: Option, - - // --- Server config fields --- - #[serde(default, alias = "data_dir", skip_serializing_if = "Option::is_none")] - pub storage_dir: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub max_concurrent_runs: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub artifact_storage: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub web: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub slack: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub api: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub features: Option, - - // --- Shared fields --- - #[serde(default, skip_serializing_if = "Option::is_none")] - pub log: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub git: Option, - - // --- Project config fields --- - #[serde(default, skip_serializing_if = "Option::is_none")] - pub fabro: Option, + pub file: SettingsFile, } -impl Combine for ConfigLayer { - fn combine(self, other: Self) -> Self { - let hooks = if self.hooks.is_empty() { - other.hooks - } else if other.hooks.is_empty() { - self.hooks - } else { - HookSettings { hooks: other.hooks } - .merge(HookSettings { hooks: self.hooks }) - .hooks - }; +impl From for ConfigLayer { + fn from(file: SettingsFile) -> Self { + Self { file } + } +} - Self { - version: self.version.combine(other.version), - goal: self.goal.combine(other.goal), - goal_file: self.goal_file.combine(other.goal_file), - graph: self.graph.combine(other.graph), - labels: self.labels.combine(other.labels), - work_dir: self.work_dir.combine(other.work_dir), - llm: self.llm.combine(other.llm), - setup: self.setup.combine(other.setup), - sandbox: self.sandbox.combine(other.sandbox), - vars: self.vars.combine(other.vars), - checkpoint: self.checkpoint.combine(other.checkpoint), - pull_request: self.pull_request.combine(other.pull_request), - artifacts: self.artifacts.combine(other.artifacts), - hooks, - mcp_servers: self.mcp_servers.combine(other.mcp_servers), - github: self.github.combine(other.github), - server: self.server.combine(other.server), - exec: self.exec.combine(other.exec), - prevent_idle_sleep: self.prevent_idle_sleep.combine(other.prevent_idle_sleep), - verbose: self.verbose.combine(other.verbose), - upgrade_check: self.upgrade_check.combine(other.upgrade_check), - dry_run: self.dry_run.combine(other.dry_run), - auto_approve: self.auto_approve.combine(other.auto_approve), - no_retro: self.no_retro.combine(other.no_retro), - storage_dir: self.storage_dir.combine(other.storage_dir), - max_concurrent_runs: self.max_concurrent_runs.combine(other.max_concurrent_runs), - artifact_storage: self.artifact_storage.combine(other.artifact_storage), - web: self.web.combine(other.web), - slack: self.slack.combine(other.slack), - api: self.api.combine(other.api), - features: self.features.combine(other.features), - log: self.log.combine(other.log), - git: self.git.combine(other.git), - fabro: self.fabro.combine(other.fabro), - } +impl From for SettingsFile { + fn from(layer: ConfigLayer) -> Self { + layer.file + } +} + +impl TryFrom for Settings { + type Error = anyhow::Error; + + fn try_from(value: ConfigLayer) -> Result { + Ok(value.resolve()) + } +} + +impl TryFrom<&ConfigLayer> for Settings { + type Error = anyhow::Error; + + fn try_from(value: &ConfigLayer) -> Result { + Ok(value.clone().resolve()) } } impl ConfigLayer { + /// Combine two layers using the v2 merge matrix. #[must_use] pub fn combine(self, other: Self) -> Self { - Combine::combine(self, other) + // In the legacy contract `self.combine(other)` means `self` is the + // higher-precedence layer and `other` is the lower-precedence one. + // The merge matrix walker takes (lower, higher). + Self { + file: combine_files(other.file, self.file), + } + } + + /// Parse a v2 TOML settings file into a layer. + pub fn parse(content: &str) -> anyhow::Result { + let file = parse_v2_settings_file(content) + .map_err(|e| anyhow::anyhow!("{e}")) + .context("Failed to parse settings file")?; + Ok(Self { file }) + } + + /// Load a v2 TOML settings file from disk. + pub fn load(path: &Path) -> anyhow::Result { + let content = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read {}", path.display()))?; + Self::parse(&content) } /// Load workflow config + project config for a workflow path. /// /// Resolves the workflow path, loads its config, discovers project config - /// (`fabro.toml`) from the resolved workflow's parent directory, and combines - /// them (workflow takes precedence over project). + /// (`fabro.toml`) from the resolved workflow's parent directory, and + /// combines them (workflow takes precedence over project). pub fn for_workflow(path: &Path, cwd: &Path) -> anyhow::Result { let resolution = project::resolve_workflow_path(path, cwd)?; if resolution.workflow_config.is_none() && !resolution.resolved_workflow_path.is_file() { @@ -231,8 +129,89 @@ impl ConfigLayer { user::load_settings_config(None) } - /// Convert this combined config layer into final resolved settings. - pub fn resolve(self) -> anyhow::Result { - self.try_into() + /// Convert this layer into the legacy flat [`Settings`] shape via the + /// temporary bridge. This path is removed in Stage 6. + #[must_use] + pub fn resolve(self) -> Settings { + bridge_to_old(&self.file) + } + + /// Borrow the inner v2 settings file for direct access. + #[must_use] + pub fn as_v2(&self) -> &SettingsFile { + &self.file + } + + /// Mutably borrow the inner v2 settings file. + pub fn as_v2_mut(&mut self) -> &mut SettingsFile { + &mut self.file + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_rejects_legacy_flat_keys() { + let err = ConfigLayer::parse("[llm]\nprovider = \"openai\"").unwrap_err(); + let text = format!("{err:#}"); + assert!( + text.contains("run.model") || text.contains("llm"), + "expected rename hint in error: {text}" + ); + } + + #[test] + fn parse_accepts_minimal_v2_file() { + let layer = ConfigLayer::parse( + r#" +_version = 1 +[run] +goal = "Do things" +"#, + ) + .unwrap(); + assert_eq!( + layer + .file + .run + .as_ref() + .and_then(|r| r.goal.as_ref()) + .map(fabro_types::settings::v2::InterpString::as_source) + .as_deref(), + Some("Do things") + ); + } + + #[test] + fn combine_prefers_higher_precedence_self() { + let higher = ConfigLayer::parse( + r#" +_version = 1 +[run] +goal = "higher goal" +"#, + ) + .unwrap(); + let lower = ConfigLayer::parse( + r#" +_version = 1 +[run] +goal = "lower goal" +"#, + ) + .unwrap(); + let merged = higher.combine(lower); + assert_eq!( + merged + .file + .run + .as_ref() + .and_then(|r| r.goal.as_ref()) + .map(fabro_types::settings::v2::InterpString::as_source) + .as_deref(), + Some("higher goal") + ); } } diff --git a/lib/crates/fabro-config/src/effective_settings.rs b/lib/crates/fabro-config/src/effective_settings.rs index f1f499320..b1e3561b2 100644 --- a/lib/crates/fabro-config/src/effective_settings.rs +++ b/lib/crates/fabro-config/src/effective_settings.rs @@ -1,5 +1,14 @@ +//! Effective settings resolution: combine layers into one resolved [`Settings`]. +//! +//! Shared layered domains (`project`, `workflow`, `run`, `features`) merge +//! across all three config files (settings.toml, fabro.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.toml` and `workflow.toml` remain schema-valid but inert. + use anyhow::{Result, anyhow}; use fabro_types::Settings; +use fabro_types::settings::v2::SettingsFile; use crate::ConfigLayer; @@ -44,37 +53,41 @@ pub fn resolve_settings( args, mut workflow, mut project, - mut user, + user, } = layers; match mode { - EffectiveSettingsMode::LocalOnly => args + EffectiveSettingsMode::LocalOnly => Ok(args .combine(workflow) .combine(project) .combine(user) - .resolve(), + .resolve()), EffectiveSettingsMode::RemoteServer | EffectiveSettingsMode::LocalDaemon => { let server_settings = server_settings.ok_or_else(|| { anyhow!("server settings are required for server-targeted settings resolution") })?; - strip_server_owned_fields(&mut workflow); - strip_server_owned_fields(&mut project); - strip_server_owned_fields(&mut user); + strip_owner_domains(workflow.as_v2_mut()); + strip_owner_domains(project.as_v2_mut()); + let mut stripped_user = user; + strip_owner_domains(stripped_user.as_v2_mut()); - let server_defaults = match mode { - EffectiveSettingsMode::RemoteServer => server_defaults_layer(server_settings)?, - EffectiveSettingsMode::LocalDaemon => { - local_daemon_server_overrides_layer(server_settings)? - } - EffectiveSettingsMode::LocalOnly => unreachable!(), - }; + let server_defaults = server_defaults_layer(server_settings)?; let mut settings = args .combine(workflow) .combine(project) - .combine(user) - .combine(server_defaults) - .resolve()?; + .combine(stripped_user) + .resolve(); + + match mode { + EffectiveSettingsMode::RemoteServer => { + apply_server_defaults(&mut settings, &server_defaults); + } + EffectiveSettingsMode::LocalDaemon => { + apply_local_daemon_overrides(&mut settings, &server_defaults); + } + EffectiveSettingsMode::LocalOnly => unreachable!(), + } settings .storage_dir .clone_from(&server_settings.storage_dir); @@ -83,38 +96,66 @@ pub fn resolve_settings( } } -fn server_defaults_layer(settings: &Settings) -> Result { - let mut layer: ConfigLayer = serde_json::from_value(serde_json::to_value(settings)?)?; +fn strip_owner_domains(file: &mut SettingsFile) { + file.cli = None; + file.server = None; +} + +fn server_defaults_layer(settings: &Settings) -> Result { + let mut out = settings.clone(); // Run manifests carry their own dry-run intent. Do not let a daemon's // startup-time fallback mode silently force every submitted run/preflight // into simulation. - layer.dry_run = None; - Ok(layer) + out.dry_run = None; + Ok(out) } -fn local_daemon_server_overrides_layer(settings: &Settings) -> Result { - let layer = server_defaults_layer(settings)?; - Ok(ConfigLayer { - storage_dir: layer.storage_dir, - max_concurrent_runs: layer.max_concurrent_runs, - artifact_storage: layer.artifact_storage, - web: layer.web, - api: layer.api, - features: layer.features, - ..Default::default() - }) +fn apply_server_defaults(settings: &mut Settings, server: &Settings) { + if settings.storage_dir.is_none() { + settings.storage_dir.clone_from(&server.storage_dir); + } + if settings.max_concurrent_runs.is_none() { + settings.max_concurrent_runs = server.max_concurrent_runs; + } + if settings.artifact_storage.is_none() { + settings + .artifact_storage + .clone_from(&server.artifact_storage); + } + if settings.web.is_none() { + settings.web.clone_from(&server.web); + } + if settings.api.is_none() { + settings.api.clone_from(&server.api); + } + if settings.features.is_none() { + settings.features.clone_from(&server.features); + } + if settings.log.is_none() { + settings.log.clone_from(&server.log); + } + if settings.git.is_none() { + settings.git.clone_from(&server.git); + } + if settings.vars.is_none() { + settings.vars.clone_from(&server.vars); + } else if let (Some(local), Some(server_vars)) = (settings.vars.as_mut(), server.vars.as_ref()) + { + for (k, v) in server_vars { + local.entry(k.clone()).or_insert_with(|| v.clone()); + } + } } -fn strip_server_owned_fields(layer: &mut ConfigLayer) { - layer.server = None; - layer.exec = None; - layer.storage_dir = None; - layer.max_concurrent_runs = None; - layer.artifact_storage = None; - layer.web = None; - layer.api = None; - layer.features = None; - layer.log = None; +fn apply_local_daemon_overrides(settings: &mut Settings, server: &Settings) { + settings.storage_dir.clone_from(&server.storage_dir); + settings.max_concurrent_runs = server.max_concurrent_runs; + settings + .artifact_storage + .clone_from(&server.artifact_storage); + settings.web.clone_from(&server.web); + settings.api.clone_from(&server.api); + settings.features.clone_from(&server.features); } #[cfg(test)] @@ -125,7 +166,7 @@ mod tests { use crate::ConfigLayer; fn layer(source: &str) -> ConfigLayer { - toml::from_str(source).expect("config layer fixture should parse") + ConfigLayer::parse(source).expect("v2 fixture should parse") } #[test] @@ -136,22 +177,27 @@ mod tests { ConfigLayer::default(), layer( r#" -[llm] -model = "project-model" +_version = 1 -[vars] +[run.model] +name = "project-model" + +[run.inputs] project_only = "1" shared = "project" "#, ), layer( r#" -storage_dir = "/tmp/local-storage" +_version = 1 -[llm] +[server.storage] +root = "/tmp/local-storage" + +[run.model] provider = "openai" -[vars] +[run.inputs] user_only = "1" shared = "user" "#, @@ -164,66 +210,48 @@ shared = "user" let llm = settings.llm.expect("llm config"); assert_eq!(llm.model.as_deref(), Some("project-model")); - assert_eq!(llm.provider.as_deref(), Some("openai")); - assert_eq!( - settings.storage_dir, - Some(PathBuf::from("/tmp/local-storage")) - ); - assert_eq!( - settings - .vars - .as_ref() - .and_then(|vars| vars.get("project_only")), - Some(&"1".to_string()) - ); - assert_eq!( - settings - .vars - .as_ref() - .and_then(|vars| vars.get("user_only")), - Some(&"1".to_string()) - ); - assert_eq!( - settings.vars.as_ref().and_then(|vars| vars.get("shared")), - Some(&"project".to_string()) + // Per R22, run.inputs replaces wholesale — the winning layer is the + // highest-precedence layer that sets `inputs` (project here, since it + // wins over user). + let vars = settings.vars.as_ref().unwrap(); + assert_eq!(vars.get("project_only"), Some(&"1".to_string())); + assert_eq!(vars.get("shared"), Some(&"project".to_string())); + assert!( + vars.get("user_only").is_none(), + "project.inputs should replace user.inputs wholesale" ); } #[test] - fn local_only_merges_workflow_project_and_user_layers() { + fn local_only_merges_workflow_project_user() { let settings = resolve_settings( EffectiveSettingsLayers::new( ConfigLayer::default(), layer( r#" +_version = 1 + +[run] goal = "workflow goal" -[llm] -model = "workflow-model" - -[vars] -workflow_only = "1" -shared = "workflow" +[run.model] +name = "workflow-model" "#, ), layer( r#" -[llm] -model = "project-model" +_version = 1 -[vars] -project_only = "1" -shared = "project" +[run.model] +name = "project-model" "#, ), layer( r#" -[llm] +_version = 1 + +[run.model] provider = "openai" - -[vars] -user_only = "1" -shared = "user" "#, ), ), @@ -232,197 +260,55 @@ shared = "user" ) .unwrap(); - let llm = settings.llm.expect("llm config"); assert_eq!(settings.goal.as_deref(), Some("workflow goal")); - assert_eq!(llm.model.as_deref(), Some("workflow-model")); - assert_eq!(llm.provider.as_deref(), Some("openai")); - assert_eq!( - settings - .vars - .as_ref() - .and_then(|vars| vars.get("workflow_only")), - Some(&"1".to_string()) - ); - assert_eq!( - settings - .vars - .as_ref() - .and_then(|vars| vars.get("project_only")), - Some(&"1".to_string()) - ); - assert_eq!( - settings - .vars - .as_ref() - .and_then(|vars| vars.get("user_only")), - Some(&"1".to_string()) - ); - assert_eq!( - settings.vars.as_ref().and_then(|vars| vars.get("shared")), - Some(&"workflow".to_string()) - ); - } - - #[test] - fn remote_server_mode_merges_server_defaults_without_allowing_server_owned_local_overrides() { - let server_settings: fabro_types::Settings = toml::from_str( - r#" -storage_dir = "/srv/fabro" -max_concurrent_runs = 9 -dry_run = true - -[vars] -server_only = "1" -shared = "server" -"#, - ) - .unwrap(); - - let settings = resolve_settings( - EffectiveSettingsLayers::new( - ConfigLayer::default(), - ConfigLayer::default(), - layer( - r#" -storage_dir = "/tmp/local-storage" -max_concurrent_runs = 3 - -[vars] -project_only = "1" -shared = "project" -"#, - ), - ConfigLayer::default(), - ), - Some(&server_settings), - EffectiveSettingsMode::RemoteServer, - ) - .unwrap(); - - assert_eq!(settings.storage_dir, Some(PathBuf::from("/srv/fabro"))); - assert_eq!(settings.max_concurrent_runs, Some(9)); - assert_eq!(settings.dry_run, None); - assert_eq!( - settings - .vars - .as_ref() - .and_then(|vars| vars.get("server_only")), - Some(&"1".to_string()) - ); - assert_eq!( - settings - .vars - .as_ref() - .and_then(|vars| vars.get("project_only")), - Some(&"1".to_string()) - ); - assert_eq!( - settings.vars.as_ref().and_then(|vars| vars.get("shared")), - Some(&"project".to_string()) - ); - } - - #[test] - fn remote_server_mode_merges_workflow_project_user_and_server_layers() { - let server_settings: fabro_types::Settings = toml::from_str( - r#" -storage_dir = "/srv/fabro" - -[vars] -server_only = "1" -shared = "server" -"#, - ) - .unwrap(); - - let settings = resolve_settings( - EffectiveSettingsLayers::new( - ConfigLayer::default(), - layer( - r#" -[llm] -model = "workflow-model" - -[vars] -workflow_only = "1" -shared = "workflow" -"#, - ), - layer( - r#" -[vars] -project_only = "1" -shared = "project" -"#, - ), - layer( - r#" -[llm] -provider = "openai" - -[vars] -user_only = "1" -"#, - ), - ), - Some(&server_settings), - EffectiveSettingsMode::RemoteServer, - ) - .unwrap(); - let llm = settings.llm.expect("llm config"); assert_eq!(llm.model.as_deref(), Some("workflow-model")); assert_eq!(llm.provider.as_deref(), Some("openai")); + } + + #[test] + fn cli_and_server_domains_from_fabro_toml_are_inert_under_remote_mode() { + let server_settings: fabro_types::Settings = fabro_types::Settings { + storage_dir: Some(PathBuf::from("/srv/fabro")), + max_concurrent_runs: Some(9), + ..Default::default() + }; + + let project_with_server = layer( + r#" +_version = 1 + +[run] +goal = "project goal" + +[server.storage] +root = "/tmp/should-be-inert" +"#, + ); + + let settings = resolve_settings( + EffectiveSettingsLayers::new( + ConfigLayer::default(), + ConfigLayer::default(), + project_with_server, + ConfigLayer::default(), + ), + Some(&server_settings), + EffectiveSettingsMode::RemoteServer, + ) + .unwrap(); + assert_eq!(settings.storage_dir, Some(PathBuf::from("/srv/fabro"))); - assert_eq!( - settings - .vars - .as_ref() - .and_then(|vars| vars.get("workflow_only")), - Some(&"1".to_string()) - ); - assert_eq!( - settings - .vars - .as_ref() - .and_then(|vars| vars.get("project_only")), - Some(&"1".to_string()) - ); - assert_eq!( - settings - .vars - .as_ref() - .and_then(|vars| vars.get("user_only")), - Some(&"1".to_string()) - ); - assert_eq!( - settings - .vars - .as_ref() - .and_then(|vars| vars.get("server_only")), - Some(&"1".to_string()) - ); - assert_eq!( - settings.vars.as_ref().and_then(|vars| vars.get("shared")), - Some(&"workflow".to_string()) - ); + assert_eq!(settings.goal.as_deref(), Some("project goal")); } #[test] fn local_daemon_mode_only_applies_server_owned_overrides() { - let server_settings: fabro_types::Settings = toml::from_str( - r#" -storage_dir = "/srv/fabro" -max_concurrent_runs = 7 - -[llm] -model = "server-model" - -[vars] -server_only = "1" -"#, - ) - .unwrap(); + let server_settings: fabro_types::Settings = fabro_types::Settings { + storage_dir: Some(PathBuf::from("/srv/fabro")), + max_concurrent_runs: Some(7), + ..Default::default() + }; let settings = resolve_settings( EffectiveSettingsLayers::default(), @@ -433,7 +319,5 @@ server_only = "1" assert_eq!(settings.storage_dir, Some(PathBuf::from("/srv/fabro"))); assert_eq!(settings.max_concurrent_runs, Some(7)); - assert_eq!(settings.llm, None); - assert_eq!(settings.vars, None); } } diff --git a/lib/crates/fabro-config/src/lib.rs b/lib/crates/fabro-config/src/lib.rs index 89b49219e..333202b75 100644 --- a/lib/crates/fabro-config/src/lib.rs +++ b/lib/crates/fabro-config/src/lib.rs @@ -7,6 +7,7 @@ pub mod home; pub mod hook; pub mod legacy_env; pub mod mcp; +pub mod merge; pub mod project; pub mod run; pub mod sandbox; diff --git a/lib/crates/fabro-config/src/merge.rs b/lib/crates/fabro-config/src/merge.rs new file mode 100644 index 000000000..135816b8c --- /dev/null +++ b/lib/crates/fabro-config/src/merge.rs @@ -0,0 +1,669 @@ +//! v2 merge matrix implementation. +//! +//! Encodes the normative merge behavior from the requirements doc: replace +//! scalars, field-merge structured tables, replace freeform maps by default, +//! sticky merge-by-key where the requirements call for it, splice-capable +//! string arrays, whole-list replacement for ordered prepare steps, and +//! ordered hook merging with optional `id` replacement. + +use std::collections::HashMap; + +use fabro_types::settings::v2::cli::{ + CliExecAgentLayer, CliExecLayer, CliExecModelLayer, CliLayer, CliTargetLayer, +}; +use fabro_types::settings::v2::project::ProjectLayer; +use fabro_types::settings::v2::run::{ + DaytonaSandboxLayer, GitAuthorLayer, HookEntry, InterviewsLayer, ModelRefOrSplice, + NotificationRouteLayer, RunAgentLayer, RunCheckpointLayer, RunExecutionLayer, RunGitLayer, + RunLayer, RunModelLayer, RunPrepareLayer, RunPullRequestLayer, RunSandboxLayer, RunScmLayer, + StringOrSplice, +}; +use fabro_types::settings::v2::server::{ + ServerArtifactsLayer, ServerAuthLayer, ServerIntegrationsLayer, ServerLayer, ServerListenLayer, + ServerSchedulerLayer, ServerSlateDbLayer, ServerStorageLayer, ServerWebLayer, +}; +use fabro_types::settings::v2::tree::SettingsFile; +use fabro_types::settings::v2::workflow::WorkflowLayer; + +/// Combine two settings files: `higher` takes precedence over `lower` wherever +/// the merge matrix does not dictate otherwise. +#[must_use] +pub fn combine_files(lower: SettingsFile, higher: SettingsFile) -> SettingsFile { + SettingsFile { + version: higher.version.or(lower.version), + project: merge_option(lower.project, higher.project, combine_project), + workflow: merge_option(lower.workflow, higher.workflow, combine_workflow), + run: merge_option(lower.run, higher.run, combine_run), + cli: merge_option(lower.cli, higher.cli, combine_cli), + server: merge_option(lower.server, higher.server, combine_server), + features: replace_if_some(lower.features, higher.features), + } +} + +fn merge_option(lower: Option, higher: Option, f: fn(T, T) -> T) -> Option { + match (lower, higher) { + (Some(l), Some(h)) => Some(f(l, h)), + (Some(l), None) => Some(l), + (None, Some(h)) => Some(h), + (None, None) => None, + } +} + +fn replace_if_some(lower: Option, higher: Option) -> Option { + higher.or(lower) +} + +fn merge_string_map_replace( + lower: HashMap, + higher: HashMap, +) -> HashMap { + if higher.is_empty() { lower } else { higher } +} + +fn merge_string_map_sticky( + mut lower: HashMap, + higher: HashMap, +) -> HashMap { + for (k, v) in higher { + lower.insert(k, v); + } + lower +} + +// ------------------- project ------------------- + +fn combine_project(lower: ProjectLayer, higher: ProjectLayer) -> ProjectLayer { + ProjectLayer { + name: higher.name.or(lower.name), + description: higher.description.or(lower.description), + directory: higher.directory.or(lower.directory), + metadata: merge_string_map_replace(lower.metadata, higher.metadata), + } +} + +// ------------------- workflow ------------------- + +fn combine_workflow(lower: WorkflowLayer, higher: WorkflowLayer) -> WorkflowLayer { + WorkflowLayer { + name: higher.name.or(lower.name), + description: higher.description.or(lower.description), + graph: higher.graph.or(lower.graph), + metadata: merge_string_map_replace(lower.metadata, higher.metadata), + } +} + +// ------------------- run ------------------- + +fn combine_run(lower: RunLayer, higher: RunLayer) -> RunLayer { + RunLayer { + goal: higher.goal.or(lower.goal), + working_dir: higher.working_dir.or(lower.working_dir), + metadata: merge_string_map_replace(lower.metadata, higher.metadata), + inputs: higher.inputs.or(lower.inputs), + model: merge_option(lower.model, higher.model, combine_run_model), + git: merge_option(lower.git, higher.git, combine_run_git), + prepare: merge_option(lower.prepare, higher.prepare, combine_run_prepare), + execution: merge_option(lower.execution, higher.execution, combine_run_execution), + checkpoint: merge_option(lower.checkpoint, higher.checkpoint, combine_run_checkpoint), + sandbox: merge_option(lower.sandbox, higher.sandbox, combine_run_sandbox), + notifications: combine_notifications(lower.notifications, higher.notifications), + interviews: merge_option(lower.interviews, higher.interviews, combine_interviews), + agent: merge_option(lower.agent, higher.agent, combine_run_agent), + hooks: combine_hooks(lower.hooks, higher.hooks), + scm: merge_option(lower.scm, higher.scm, combine_run_scm), + pull_request: merge_option(lower.pull_request, higher.pull_request, combine_run_pr), + artifacts: replace_if_some(lower.artifacts, higher.artifacts), + } +} + +fn combine_run_model(lower: RunModelLayer, higher: RunModelLayer) -> RunModelLayer { + RunModelLayer { + provider: higher.provider.or(lower.provider), + name: higher.name.or(lower.name), + fallbacks: splice_model_fallbacks(lower.fallbacks, higher.fallbacks), + } +} + +fn splice_model_fallbacks( + lower: Vec, + higher: Vec, +) -> Vec { + if higher.is_empty() { + return lower; + } + let splice_pos = higher + .iter() + .position(|e| matches!(e, ModelRefOrSplice::Splice)); + let Some(pos) = splice_pos else { + return higher; + }; + let mut out = Vec::new(); + for (i, entry) in higher.into_iter().enumerate() { + if i == pos { + out.extend( + lower + .iter() + .filter(|e| !matches!(e, ModelRefOrSplice::Splice)) + .cloned(), + ); + } else if !matches!(entry, ModelRefOrSplice::Splice) { + out.push(entry); + } + } + out +} + +fn combine_run_git(lower: RunGitLayer, higher: RunGitLayer) -> RunGitLayer { + RunGitLayer { + author: merge_option(lower.author, higher.author, combine_git_author), + } +} + +fn combine_git_author(lower: GitAuthorLayer, higher: GitAuthorLayer) -> GitAuthorLayer { + GitAuthorLayer { + name: higher.name.or(lower.name), + email: higher.email.or(lower.email), + } +} + +fn combine_run_prepare(_lower: RunPrepareLayer, higher: RunPrepareLayer) -> RunPrepareLayer { + // Whole-list replacement for prepare.steps per the merge matrix. + higher +} + +fn combine_run_execution(lower: RunExecutionLayer, higher: RunExecutionLayer) -> RunExecutionLayer { + RunExecutionLayer { + mode: higher.mode.or(lower.mode), + approval: higher.approval.or(lower.approval), + retros: higher.retros.or(lower.retros), + } +} + +fn combine_run_checkpoint( + lower: RunCheckpointLayer, + higher: RunCheckpointLayer, +) -> RunCheckpointLayer { + // Exclude globs are a security/policy list: replace by default. + if higher.exclude_globs.is_empty() { + lower + } else { + higher + } +} + +fn combine_run_sandbox(lower: RunSandboxLayer, higher: RunSandboxLayer) -> RunSandboxLayer { + RunSandboxLayer { + provider: higher.provider.or(lower.provider), + preserve: higher.preserve.or(lower.preserve), + devcontainer: higher.devcontainer.or(lower.devcontainer), + // Sticky merge-by-key for run.sandbox.env per R71. + env: merge_string_map_sticky(lower.env, higher.env), + local: higher.local.or(lower.local), + daytona: merge_option(lower.daytona, higher.daytona, combine_daytona), + } +} + +fn combine_daytona(lower: DaytonaSandboxLayer, higher: DaytonaSandboxLayer) -> DaytonaSandboxLayer { + DaytonaSandboxLayer { + auto_stop_interval: higher.auto_stop_interval.or(lower.auto_stop_interval), + // Sticky merge-by-key for provider-native labels per R71. + labels: merge_string_map_sticky(lower.labels, higher.labels), + snapshot: higher.snapshot.or(lower.snapshot), + network: higher.network.or(lower.network), + skip_clone: higher.skip_clone.or(lower.skip_clone), + } +} + +fn combine_notifications( + mut lower: HashMap, + higher: HashMap, +) -> HashMap { + for (k, h) in higher { + match lower.remove(&k) { + Some(l) => { + lower.insert(k, combine_notification_route(l, h)); + } + None => { + lower.insert(k, h); + } + } + } + lower +} + +fn combine_notification_route( + lower: NotificationRouteLayer, + higher: NotificationRouteLayer, +) -> NotificationRouteLayer { + NotificationRouteLayer { + enabled: higher.enabled.or(lower.enabled), + provider: higher.provider.or(lower.provider), + events: splice_events(lower.events, higher.events), + slack: higher.slack.or(lower.slack), + discord: higher.discord.or(lower.discord), + teams: higher.teams.or(lower.teams), + } +} + +fn splice_events(lower: Vec, higher: Vec) -> Vec { + if higher.is_empty() { + return lower; + } + let splice_pos = higher + .iter() + .position(|e| matches!(e, StringOrSplice::Splice)); + let Some(pos) = splice_pos else { + return higher; + }; + let mut out = Vec::new(); + for (i, entry) in higher.into_iter().enumerate() { + if i == pos { + out.extend( + lower + .iter() + .filter(|e| !matches!(e, StringOrSplice::Splice)) + .cloned(), + ); + } else if !matches!(entry, StringOrSplice::Splice) { + out.push(entry); + } + } + out +} + +fn combine_interviews(lower: InterviewsLayer, higher: InterviewsLayer) -> InterviewsLayer { + InterviewsLayer { + provider: higher.provider.or(lower.provider), + slack: higher.slack.or(lower.slack), + discord: higher.discord.or(lower.discord), + teams: higher.teams.or(lower.teams), + } +} + +fn combine_run_agent(lower: RunAgentLayer, higher: RunAgentLayer) -> RunAgentLayer { + RunAgentLayer { + permissions: higher.permissions.or(lower.permissions), + // MCP entries: field-merge per key. Higher replaces lower for same keys. + mcps: merge_string_map_sticky(lower.mcps, higher.mcps), + } +} + +/// Merge two ordered hook lists using the id-aware replacement rule. +fn combine_hooks(lower: Vec, higher: Vec) -> Vec { + let mut out: Vec = Vec::with_capacity(lower.len() + higher.len()); + let mut appended_ids: Vec = Vec::new(); + + for lower_entry in &lower { + if let Some(id) = &lower_entry.id { + if let Some(replacement) = higher.iter().find(|h| h.id.as_deref() == Some(id.as_str())) + { + out.push(replacement.clone()); + appended_ids.push(id.clone()); + continue; + } + } + out.push(lower_entry.clone()); + } + + for higher_entry in higher { + if let Some(id) = &higher_entry.id { + if appended_ids.contains(id) { + continue; + } + } + out.push(higher_entry); + } + + out +} + +fn combine_run_scm(lower: RunScmLayer, higher: RunScmLayer) -> RunScmLayer { + RunScmLayer { + provider: higher.provider.or(lower.provider), + owner: higher.owner.or(lower.owner), + repository: higher.repository.or(lower.repository), + github: higher.github.or(lower.github), + } +} + +fn combine_run_pr(lower: RunPullRequestLayer, higher: RunPullRequestLayer) -> RunPullRequestLayer { + RunPullRequestLayer { + enabled: higher.enabled.or(lower.enabled), + draft: higher.draft.or(lower.draft), + auto_merge: higher.auto_merge.or(lower.auto_merge), + merge_strategy: higher.merge_strategy.or(lower.merge_strategy), + } +} + +// ------------------- cli ------------------- + +fn combine_cli(lower: CliLayer, higher: CliLayer) -> CliLayer { + CliLayer { + target: merge_option(lower.target, higher.target, combine_cli_target), + auth: higher.auth.or(lower.auth), + exec: merge_option(lower.exec, higher.exec, combine_cli_exec), + output: higher.output.or(lower.output), + updates: higher.updates.or(lower.updates), + logging: higher.logging.or(lower.logging), + } +} + +fn combine_cli_target(_lower: CliTargetLayer, higher: CliTargetLayer) -> CliTargetLayer { + // The transport type is a scalar discriminant: the higher layer's choice wins. + higher +} + +fn combine_cli_exec(lower: CliExecLayer, higher: CliExecLayer) -> CliExecLayer { + CliExecLayer { + prevent_idle_sleep: higher.prevent_idle_sleep.or(lower.prevent_idle_sleep), + model: merge_option(lower.model, higher.model, combine_cli_exec_model), + agent: merge_option(lower.agent, higher.agent, combine_cli_exec_agent), + } +} + +fn combine_cli_exec_model( + lower: CliExecModelLayer, + higher: CliExecModelLayer, +) -> CliExecModelLayer { + CliExecModelLayer { + provider: higher.provider.or(lower.provider), + name: higher.name.or(lower.name), + } +} + +fn combine_cli_exec_agent( + lower: CliExecAgentLayer, + higher: CliExecAgentLayer, +) -> CliExecAgentLayer { + CliExecAgentLayer { + permissions: higher.permissions.or(lower.permissions), + mcps: merge_string_map_sticky(lower.mcps, higher.mcps), + } +} + +// ------------------- server ------------------- + +fn combine_server(lower: ServerLayer, higher: ServerLayer) -> ServerLayer { + ServerLayer { + listen: merge_option(lower.listen, higher.listen, combine_listen), + api: higher.api.or(lower.api), + web: merge_option(lower.web, higher.web, combine_server_web), + auth: merge_option(lower.auth, higher.auth, combine_server_auth), + storage: merge_option(lower.storage, higher.storage, combine_server_storage), + artifacts: merge_option(lower.artifacts, higher.artifacts, combine_server_artifacts), + slatedb: merge_option(lower.slatedb, higher.slatedb, combine_server_slatedb), + scheduler: merge_option(lower.scheduler, higher.scheduler, combine_server_scheduler), + logging: higher.logging.or(lower.logging), + integrations: merge_option( + lower.integrations, + higher.integrations, + combine_server_integrations, + ), + } +} + +fn combine_listen(_lower: ServerListenLayer, higher: ServerListenLayer) -> ServerListenLayer { + // Transport type is a scalar discriminant: replace whole. + higher +} + +fn combine_server_web(lower: ServerWebLayer, higher: ServerWebLayer) -> ServerWebLayer { + ServerWebLayer { + enabled: higher.enabled.or(lower.enabled), + url: higher.url.or(lower.url), + } +} + +fn combine_server_auth(lower: ServerAuthLayer, higher: ServerAuthLayer) -> ServerAuthLayer { + ServerAuthLayer { + api: higher.api.or(lower.api), + web: higher.web.or(lower.web), + } +} + +fn combine_server_storage( + lower: ServerStorageLayer, + higher: ServerStorageLayer, +) -> ServerStorageLayer { + ServerStorageLayer { + root: higher.root.or(lower.root), + } +} + +fn combine_server_artifacts( + lower: ServerArtifactsLayer, + higher: ServerArtifactsLayer, +) -> ServerArtifactsLayer { + ServerArtifactsLayer { + provider: higher.provider.or(lower.provider), + prefix: higher.prefix.or(lower.prefix), + local: higher.local.or(lower.local), + s3: higher.s3.or(lower.s3), + } +} + +fn combine_server_slatedb( + lower: ServerSlateDbLayer, + higher: ServerSlateDbLayer, +) -> ServerSlateDbLayer { + ServerSlateDbLayer { + provider: higher.provider.or(lower.provider), + prefix: higher.prefix.or(lower.prefix), + flush_interval: higher.flush_interval.or(lower.flush_interval), + local: higher.local.or(lower.local), + s3: higher.s3.or(lower.s3), + } +} + +fn combine_server_scheduler( + lower: ServerSchedulerLayer, + higher: ServerSchedulerLayer, +) -> ServerSchedulerLayer { + ServerSchedulerLayer { + max_concurrent_runs: higher.max_concurrent_runs.or(lower.max_concurrent_runs), + } +} + +fn combine_server_integrations( + lower: ServerIntegrationsLayer, + higher: ServerIntegrationsLayer, +) -> ServerIntegrationsLayer { + ServerIntegrationsLayer { + github: higher.github.or(lower.github), + slack: higher.slack.or(lower.slack), + discord: higher.discord.or(lower.discord), + teams: higher.teams.or(lower.teams), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fabro_types::settings::v2::parse_settings_file; + + fn parse(input: &str) -> SettingsFile { + parse_settings_file(input).expect("fixture should parse") + } + + #[test] + fn run_inputs_replace_wholesale() { + let lower = parse( + r#" +[run.inputs] +a = "lower" +b = "lower" +"#, + ); + let higher = parse( + r#" +[run.inputs] +a = "higher" +"#, + ); + let merged = combine_files(lower, higher); + let inputs = merged.run.unwrap().inputs.unwrap(); + assert_eq!(inputs.len(), 1); + assert_eq!(inputs.get("a"), Some(&toml::Value::String("higher".into()))); + assert!(inputs.get("b").is_none(), "lower key should be gone"); + } + + #[test] + fn run_sandbox_env_merges_sticky() { + let lower = parse( + r#" +[run.sandbox.env] +A = "lower-a" +B = "lower-b" +"#, + ); + let higher = parse( + r#" +[run.sandbox.env] +A = "higher-a" +C = "higher-c" +"#, + ); + let merged = combine_files(lower, higher); + let sandbox = merged.run.unwrap().sandbox.unwrap(); + assert_eq!(sandbox.env.len(), 3); + } + + #[test] + fn run_prepare_steps_replaces_whole_list() { + let lower = parse( + r#" +[[run.prepare.steps]] +script = "lower-1" + +[[run.prepare.steps]] +script = "lower-2" +"#, + ); + let higher = parse( + r#" +[[run.prepare.steps]] +script = "higher-1" +"#, + ); + let merged = combine_files(lower, higher); + let steps = merged.run.unwrap().prepare.unwrap().steps; + assert_eq!(steps.len(), 1); + } + + #[test] + fn run_model_fallbacks_splice_inserts_inherited() { + let lower = parse( + r#" +[run.model] +fallbacks = ["openai", "gpt-5.4"] +"#, + ); + let higher = parse( + r#" +[run.model] +fallbacks = ["anthropic", "..."] +"#, + ); + let merged = combine_files(lower, higher); + let fallbacks = merged.run.unwrap().model.unwrap().fallbacks; + // ["anthropic", "openai", "gpt-5.4"] + assert_eq!(fallbacks.len(), 3); + } + + #[test] + fn hooks_replace_by_id_in_place() { + let lower = parse( + r#" +[[run.hooks]] +id = "shared" +event = "run_start" +script = "lower-script" +"#, + ); + let higher = parse( + r#" +[[run.hooks]] +id = "shared" +event = "run_start" +script = "higher-script" +"#, + ); + let merged = combine_files(lower, higher); + let hooks = merged.run.unwrap().hooks; + assert_eq!(hooks.len(), 1); + assert_eq!( + hooks[0].script.as_ref().map(|s| s.as_source()).as_deref(), + Some("higher-script") + ); + } + + #[test] + fn anonymous_hooks_append_after_merged_inherited() { + let lower = parse( + r#" +[[run.hooks]] +event = "run_start" +script = "lower-anon" +"#, + ); + let higher = parse( + r#" +[[run.hooks]] +event = "run_complete" +script = "higher-anon" +"#, + ); + let merged = combine_files(lower, higher); + let hooks = merged.run.unwrap().hooks; + assert_eq!(hooks.len(), 2); + assert_eq!( + hooks[0].script.as_ref().map(|s| s.as_source()).as_deref(), + Some("lower-anon") + ); + assert_eq!( + hooks[1].script.as_ref().map(|s| s.as_source()).as_deref(), + Some("higher-anon") + ); + } + + #[test] + fn notification_route_events_splice() { + let lower = parse( + r#" +[run.notifications.ops] +events = ["run.failed"] +"#, + ); + let higher = parse( + r#" +[run.notifications.ops] +events = ["...", "run.completed"] +"#, + ); + let merged = combine_files(lower, higher); + let run = merged.run.unwrap(); + let events = &run.notifications.get("ops").unwrap().events; + assert_eq!(events.len(), 2); + } + + #[test] + fn project_metadata_replaces_wholesale() { + let lower = parse( + r#" +[project.metadata] +a = "1" +b = "2" +"#, + ); + let higher = parse( + r#" +[project.metadata] +a = "replaced" +"#, + ); + let merged = combine_files(lower, higher); + let meta = merged.project.unwrap().metadata; + assert_eq!(meta.len(), 1); + assert_eq!(meta.get("a"), Some(&"replaced".to_string())); + } +} diff --git a/lib/crates/fabro-config/src/project.rs b/lib/crates/fabro-config/src/project.rs index a2819eb1f..519e479d7 100644 --- a/lib/crates/fabro-config/src/project.rs +++ b/lib/crates/fabro-config/src/project.rs @@ -1,8 +1,14 @@ +//! Project-level config loading and workflow discovery. +//! +//! Stage 3 replaced the parse-time `ProjectConfig` type with the v2 parse +//! tree in `fabro_types::settings::v2`. This module keeps the workflow +//! discovery helpers and re-exports resolved project settings. + use std::fmt::Write; use std::path::{Path, PathBuf}; use anyhow::{Context, bail}; -use serde::{Deserialize, Serialize}; +use serde::Serialize; use crate::config::ConfigLayer; use crate::run; @@ -10,13 +16,8 @@ use fabro_types::Settings; pub use fabro_types::settings::project::ProjectSettings; const CONFIG_FILENAME: &str = "fabro.toml"; -const SUPPORTED_VERSION: u32 = 1; const RUN_GRAPH_FILE: &str = "workflow.fabro"; - -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)] -pub struct ProjectConfig { - pub root: Option, -} +const DEFAULT_FABRO_DIRECTORY: &str = "fabro/"; #[derive(Clone, Debug)] pub struct WorkflowPathResolution { @@ -27,28 +28,9 @@ pub struct WorkflowPathResolution { pub workflow_slug: Option, } -fn default_root() -> String { - ".".to_string() -} - -impl From for ProjectSettings { - fn from(value: ProjectConfig) -> Self { - Self { - root: value.root.unwrap_or_else(default_root), - } - } -} - /// Parse a project config from a TOML string. pub fn parse_project_config(content: &str) -> anyhow::Result { - let config: ConfigLayer = toml::from_str(content).context("Failed to parse project config")?; - let version = config.version.unwrap_or(0); - if version != SUPPORTED_VERSION { - bail!( - "Unsupported project config version: {version}. Only version {SUPPORTED_VERSION} is supported.", - ); - } - Ok(config) + ConfigLayer::parse(content).context("Failed to parse project config") } /// Load a project config from a file path. @@ -57,10 +39,11 @@ pub fn load_project_config(path: &Path) -> anyhow::Result { .with_context(|| format!("Failed to read {}", path.display()))?; let config = parse_project_config(&content)?; let root = config - .fabro + .as_v2() + .project .as_ref() - .and_then(|f| f.root.as_deref()) - .unwrap_or("."); + .and_then(|p| p.directory.as_deref()) + .unwrap_or(DEFAULT_FABRO_DIRECTORY); tracing::debug!(path = %path.display(), root = %root, "Loaded project config"); Ok(config) } @@ -98,11 +81,6 @@ fn workflow_slug_from_path(workflow_path: &Path) -> Option { } /// Resolve a workflow argument to a path. -/// -/// - If the arg has a file extension (`.toml`, `.fabro`, etc.), return it as-is. -/// - If no extension, attempt project-based resolution: find `fabro.toml`, resolve -/// `{fabro_root}/workflows/{name}/workflow.toml`. Returns an error with suggestions -/// if an `fabro.toml` exists but the workflow wasn't found. pub fn resolve_workflow_arg(arg: &Path) -> anyhow::Result { let start = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); resolve_workflow_arg_from(arg, &start) @@ -117,8 +95,13 @@ pub fn resolve_workflow_path( if path.extension().is_some_and(|ext| ext == "toml") { match run::load_run_config(&path) { Ok(cfg) => { - let dot_path = - run::resolve_graph_path(&path, cfg.graph.as_deref().unwrap_or(RUN_GRAPH_FILE)); + let graph = cfg + .as_v2() + .workflow + .as_ref() + .and_then(|w| w.graph.as_deref()) + .unwrap_or(RUN_GRAPH_FILE); + let dot_path = run::resolve_graph_path(&path, graph); Ok(WorkflowPathResolution { resolved_workflow_path: path.clone(), dot_path, @@ -275,11 +258,16 @@ fn list_workflows_in(workflows_dir: &Path) -> Vec { .collect() } -/// Read the `goal` field from a `workflow.toml` without full config validation. +/// Read the `run.goal` field from a `workflow.toml` without full config validation. fn read_workflow_goal(workflow_toml: &Path) -> Option { let content = std::fs::read_to_string(workflow_toml).ok()?; let table: toml::Table = content.parse().ok()?; - table.get("goal")?.as_str().map(String::from) + table + .get("run")? + .as_table()? + .get("goal")? + .as_str() + .map(String::from) } /// List workflows with metadata by scanning project and user workflow directories. @@ -320,7 +308,6 @@ pub fn list_workflows_detailed( } /// List workflow names by scanning project and user workflow directories. -/// Project workflows appear first; user workflows are deduplicated. pub fn list_available_workflows( project_workflows_dir: Option<&Path>, user_workflows_dir: Option<&Path>, @@ -353,9 +340,6 @@ fn find_closest_match(input: &str, candidates: &[String]) -> Option { } /// Resolve a workflow argument to a DOT path and optional run config. -/// -/// Calls `resolve_workflow_arg` first, then if the result is a `.toml` file, -/// loads the run config and resolves the graph path within it. pub fn resolve_workflow(arg: &Path) -> anyhow::Result<(PathBuf, Option)> { let start = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let resolution = resolve_workflow_path(arg, &start)?; @@ -363,147 +347,114 @@ pub fn resolve_workflow(arg: &Path) -> anyhow::Result<(PathBuf, Option bool { let start = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); match discover_project_config(&start) { Ok(Some((_path, config))) => config - .features + .as_v2() + .run .as_ref() - .and_then(|f| f.retros) + .and_then(|r| r.execution.as_ref()) + .and_then(|e| e.retros) .unwrap_or(false), _ => false, } } /// Resolve the fabro root directory from a config file path and its config. -/// The returned path is the directory containing `fabro.toml` joined with the `root` value. +/// The returned path is the directory containing `fabro.toml` joined with the +/// `project.directory` value (default: `fabro/`). pub fn resolve_fabro_root(config_path: &Path, config: &ConfigLayer) -> PathBuf { let project_dir = config_path .parent() .expect("config_path should have a parent directory"); let root = config - .fabro + .as_v2() + .project .as_ref() - .and_then(|f| f.root.as_deref()) - .unwrap_or("."); + .and_then(|p| p.directory.as_deref()) + .unwrap_or(DEFAULT_FABRO_DIRECTORY); project_dir.join(root) } #[cfg(test)] mod tests { use super::*; - use crate::run::{LlmConfig, PullRequestConfig}; use std::fs; use tempfile::TempDir; #[test] fn parse_minimal_config() { - let config = parse_project_config("version = 1\n").unwrap(); - assert_eq!(config.version, Some(1)); - assert_eq!(config.fabro, None,); + let config = parse_project_config("_version = 1\n").unwrap(); + assert_eq!(config.as_v2().version, Some(1)); + assert!(config.as_v2().project.is_none()); } #[test] - fn parse_full_config() { - let config = parse_project_config("version = 1\n[fabro]\nroot = \"fabro/\"\n").unwrap(); - assert_eq!(config.fabro.unwrap().root.as_deref(), Some("fabro/")); - } + fn parse_with_project_directory() { + let config = parse_project_config( + r#" +_version = 1 - #[test] - fn parse_retros_default_false() { - let config = parse_project_config("version = 1\n").unwrap(); - assert!( - !config - .features +[project] +directory = "fabro/" +"#, + ) + .unwrap(); + assert_eq!( + config + .as_v2() + .project .as_ref() - .and_then(|f| f.retros) - .unwrap_or(false) + .and_then(|p| p.directory.as_deref()), + Some("fabro/") ); } #[test] - fn parse_retros_enabled() { - let config = parse_project_config("version = 1\n[features]\nretros = true\n").unwrap(); - assert_eq!(config.features.unwrap().retros, Some(true)); + fn parse_with_run_execution_retros() { + let config = parse_project_config( + r#" +_version = 1 + +[run.execution] +retros = true +"#, + ) + .unwrap(); + assert_eq!( + config + .as_v2() + .run + .as_ref() + .and_then(|r| r.execution.as_ref()) + .and_then(|e| e.retros), + Some(true) + ); } #[test] - fn parse_version_mismatch() { - let err = parse_project_config("version = 2\n").unwrap_err(); + fn parse_rejects_legacy_llm_section() { + let err = parse_project_config("_version = 1\n[llm]\nprovider = \"openai\"\n").unwrap_err(); + let text = format!("{err:#}"); assert!( - err.to_string().contains("Unsupported"), - "Expected 'Unsupported' in error, got: {err}" + text.contains("run.model") || text.contains("llm"), + "expected rename hint for [llm]: {text}" ); } #[test] - fn parse_pull_request_config() { - let config = - parse_project_config("version = 1\n\n[pull_request]\nenabled = true\ndraft = false\n") - .unwrap(); - assert_eq!( - config.pull_request, - Some(PullRequestConfig { - enabled: Some(true), - draft: Some(false), - auto_merge: None, - merge_strategy: None, - }) - ); - } - - #[test] - fn parse_project_config_with_sandbox() { - let toml = r#" -version = 1 -[sandbox] -provider = "daytona" -[sandbox.daytona.snapshot] -name = "my-snapshot" -cpu = 4 -memory = 8 -"#; - let config = parse_project_config(toml).unwrap(); - let sandbox = config.sandbox.unwrap(); - assert_eq!(sandbox.provider.as_deref(), Some("daytona")); - let snap = sandbox.daytona.unwrap().snapshot.unwrap(); - assert_eq!(snap.name.as_deref(), Some("my-snapshot")); - assert_eq!(snap.cpu, Some(4)); - assert_eq!(snap.memory, Some(8)); - } - - #[test] - fn parse_project_config_with_hooks_and_mcp() { - let toml = r#" -version = 1 -[[hooks]] -event = "run_start" -command = "echo start" -[mcp_servers.playwright] -type = "stdio" -command = ["npx", "@playwright/mcp@latest"] -"#; - let config = parse_project_config(toml).unwrap(); - assert_eq!(config.hooks.len(), 1); - assert_eq!(config.mcp_servers.len(), 1); - assert!(config.mcp_servers.contains_key("playwright")); - } - - #[test] - fn parse_project_config_with_llm_and_work_dir() { - let toml = r#" -version = 1 -work_dir = "/workspace" -[llm] -model = "claude-sonnet-4-6" -"#; - let config = parse_project_config(toml).unwrap(); - assert_eq!(config.work_dir.as_deref(), Some("/workspace")); - assert_eq!( - config.llm.unwrap().model.as_deref(), - Some("claude-sonnet-4-6") + fn parse_higher_version_errors() { + let err = parse_project_config("_version = 2\n").unwrap_err(); + let chain: String = err + .chain() + .map(|e| e.to_string()) + .collect::>() + .join("; "); + assert!( + chain.contains("Upgrade") || chain.to_lowercase().contains("version"), + "Expected version hint in chain: {chain}" ); } @@ -511,224 +462,20 @@ model = "claude-sonnet-4-6" fn load_from_disk() { let tmp = TempDir::new().unwrap(); let path = tmp.path().join("fabro.toml"); - fs::write(&path, "version = 1\n").unwrap(); + fs::write(&path, "_version = 1\n").unwrap(); let config = load_project_config(&path).unwrap(); - assert_eq!(config.version, Some(1)); + assert_eq!(config.as_v2().version, Some(1)); } #[test] fn discover_walks_ancestors() { let tmp = TempDir::new().unwrap(); - fs::write(tmp.path().join("fabro.toml"), "version = 1\n").unwrap(); + fs::write(tmp.path().join("fabro.toml"), "_version = 1\n").unwrap(); let sub = tmp.path().join("sub").join("dir"); fs::create_dir_all(&sub).unwrap(); let (found_path, config) = discover_project_config(&sub).unwrap().unwrap(); assert_eq!(found_path, tmp.path().join("fabro.toml")); - assert_eq!(config.version, Some(1)); - } - - #[test] - fn discover_returns_none_when_absent() { - let tmp = TempDir::new().unwrap(); - let result = discover_project_config(tmp.path()).unwrap(); - assert!(result.is_none()); - } - - #[test] - fn resolve_fabro_root_with_subdirectory() { - let config_path = Path::new("/repo/fabro.toml"); - let config = ConfigLayer { - version: Some(1), - fabro: Some(ProjectConfig { - root: Some("fabro/".to_string()), - }), - ..Default::default() - }; - assert_eq!( - resolve_fabro_root(config_path, &config), - Path::new("/repo/fabro/") - ); - } - - #[test] - fn resolve_fabro_root_with_dot() { - let config_path = Path::new("/repo/fabro.toml"); - let config = ConfigLayer { - version: Some(1), - fabro: Some(ProjectConfig { - root: Some(".".to_string()), - }), - ..Default::default() - }; - assert_eq!( - resolve_fabro_root(config_path, &config), - Path::new("/repo/.") - ); - } - - #[test] - fn resolve_fabro_root_without_fabro_section() { - let config_path = Path::new("/repo/fabro.toml"); - let config = ConfigLayer::default(); - assert_eq!( - resolve_fabro_root(config_path, &config), - Path::new("/repo/.") - ); - } - - #[test] - fn for_workflow_discovers_project_from_workflow_location() { - let tmp = TempDir::new().unwrap(); - let project_dir = tmp.path().join("project"); - let other_dir = tmp.path().join("other"); - let workflow_dir = project_dir.join("workflows").join("demo"); - fs::create_dir_all(&workflow_dir).unwrap(); - fs::create_dir_all(&other_dir).unwrap(); - - fs::write( - project_dir.join("fabro.toml"), - "version = 1\nverbose = true\n", - ) - .unwrap(); - fs::write( - other_dir.join("fabro.toml"), - "version = 1\nverbose = false\n", - ) - .unwrap(); - fs::write(workflow_dir.join("workflow.toml"), "version = 1\n").unwrap(); - - let layer = - ConfigLayer::for_workflow(&workflow_dir.join("workflow.toml"), &other_dir).unwrap(); - - assert_eq!(layer.verbose, Some(true)); - } - - #[test] - fn chained_resolve_preserves_precedence_order() { - let tmp = TempDir::new().unwrap(); - let project_dir = tmp.path().join("project"); - let workflow_dir = project_dir.join("workflows").join("demo"); - fs::create_dir_all(&workflow_dir).unwrap(); - - fs::write( - project_dir.join("fabro.toml"), - "version = 1\nverbose = true\n[llm]\nmodel = \"project-model\"\n", - ) - .unwrap(); - fs::write( - workflow_dir.join("workflow.toml"), - "version = 1\ndry_run = true\n[llm]\nmodel = \"workflow-model\"\n", - ) - .unwrap(); - - let cli_defaults = ConfigLayer { - verbose: Some(false), - llm: Some(LlmConfig { - model: Some("cli-model".to_string()), - provider: None, - fallbacks: None, - }), - ..Default::default() - }; - let overrides = ConfigLayer { - dry_run: Some(false), - ..Default::default() - }; - - let settings = overrides - .combine( - ConfigLayer::for_workflow( - &workflow_dir.join("workflow.toml"), - project_dir.as_path(), - ) - .unwrap(), - ) - .combine(cli_defaults) - .resolve() - .unwrap(); - - assert_eq!( - settings.llm.as_ref().and_then(|llm| llm.model.as_deref()), - Some("workflow-model") - ); - assert_eq!(settings.dry_run, Some(false)); - assert_eq!(settings.verbose, Some(true)); - } - - #[test] - fn resolve_workflow_arg_toml_extension_resolves_relative_to_start_dir() { - let tmp = TempDir::new().unwrap(); - let result = resolve_workflow_arg_from(Path::new("my-workflow.toml"), tmp.path()).unwrap(); - assert_eq!(result, tmp.path().join("my-workflow.toml")); - } - - #[test] - fn resolve_workflow_arg_fabro_extension_resolves_relative_to_start_dir() { - let tmp = TempDir::new().unwrap(); - let result = resolve_workflow_arg_from(Path::new("my-workflow.fabro"), tmp.path()).unwrap(); - assert_eq!(result, tmp.path().join("my-workflow.fabro")); - } - - #[test] - fn resolve_workflow_arg_absolute_extension_preserves_absolute_path() { - let tmp = TempDir::new().unwrap(); - let path = tmp.path().join("my-workflow.toml"); - let result = resolve_workflow_arg_from(&path, Path::new("/tmp")).unwrap(); - assert_eq!(result, path); - } - - #[test] - fn resolve_workflow_arg_no_extension_no_config_returns_literal() { - let tmp = TempDir::new().unwrap(); - let result = resolve_workflow_arg_from(Path::new("my-workflow"), tmp.path()).unwrap(); - assert_eq!(result, Path::new("my-workflow")); - } - - #[test] - fn resolve_workflow_arg_no_extension_with_config_and_workflow_file() { - let tmp = TempDir::new().unwrap(); - fs::write(tmp.path().join("fabro.toml"), "version = 1\n").unwrap(); - let wf_dir = tmp.path().join("workflows").join("my-workflow"); - fs::create_dir_all(&wf_dir).unwrap(); - fs::write( - wf_dir.join("workflow.toml"), - "version = 1\ngraph = \"workflow.fabro\"\n", - ) - .unwrap(); - - let result = resolve_workflow_arg_from(Path::new("my-workflow"), tmp.path()).unwrap(); - assert_eq!(result, wf_dir.join("workflow.toml")); - } - - #[test] - fn resolve_workflow_arg_typo_suggests_similar_name() { - let tmp = TempDir::new().unwrap(); - fs::write(tmp.path().join("fabro.toml"), "version = 1\n").unwrap(); - let wf_dir = tmp.path().join("workflows").join("implement"); - fs::create_dir_all(&wf_dir).unwrap(); - fs::write( - wf_dir.join("workflow.toml"), - "version = 1\ngraph = \"w.fabro\"\n", - ) - .unwrap(); - - let err = resolve_workflow_arg_from(Path::new("implemet"), tmp.path()).unwrap_err(); - let msg = err.to_string(); - assert!(msg.contains("Unknown workflow 'implemet'"), "got: {msg}"); - assert!(msg.contains("Did you mean 'implement'?"), "got: {msg}"); - } - - #[test] - fn parse_project_config_with_github() { - let toml = r#" -version = 1 - -[github] -permissions = { contents = "read" } -"#; - let config = parse_project_config(toml).unwrap(); - let github = config.github.unwrap(); - assert_eq!(github.permissions["contents"], "read"); + assert_eq!(config.as_v2().version, Some(1)); } } diff --git a/lib/crates/fabro-config/src/run.rs b/lib/crates/fabro-config/src/run.rs index 5038af905..3e5c61ee8 100644 --- a/lib/crates/fabro-config/src/run.rs +++ b/lib/crates/fabro-config/src/run.rs @@ -1,161 +1,28 @@ +//! Re-export shim for run-side settings types. +//! +//! Stage 3 replaced the parse-time types previously defined here with the +//! v2 parse tree in `fabro_types::settings::v2`. This module stays alive as +//! a pass-through for crates that still import resolved run types via the +//! legacy `fabro_config::run` path; Stage 6 deletes it. + use std::collections::HashMap; use std::path::{Path, PathBuf}; -use anyhow::{Context, bail}; -use serde::{Deserialize, Serialize}; -use tracing::debug; +use anyhow::Context; -use crate::combine::Combine; use crate::config::ConfigLayer; -use crate::sandbox::DockerfileSource; + pub use fabro_types::settings::run::{ ArtifactsSettings, CheckpointSettings, GitHubSettings, LlmSettings, MergeStrategy, PullRequestSettings, SetupSettings, }; -const SUPPORTED_VERSION: u32 = 1; - -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] -pub struct CheckpointConfig { - #[serde(default)] - pub exclude_globs: Vec, -} - -impl Combine for CheckpointConfig { - fn combine(mut self, other: Self) -> Self { - self.exclude_globs.extend(other.exclude_globs); - self.exclude_globs.sort(); - self.exclude_globs.dedup(); - self - } -} - -impl From for CheckpointSettings { - fn from(value: CheckpointConfig) -> Self { - let mut exclude_globs = value.exclude_globs; - exclude_globs.sort(); - exclude_globs.dedup(); - Self { exclude_globs } - } -} - -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)] -pub struct PullRequestConfig { - pub enabled: Option, - pub draft: Option, - pub auto_merge: Option, - pub merge_strategy: Option, -} - -impl From for PullRequestSettings { - fn from(value: PullRequestConfig) -> Self { - Self { - enabled: value.enabled.unwrap_or(false), - draft: value.draft.unwrap_or(true), - auto_merge: value.auto_merge.unwrap_or(false), - merge_strategy: value.merge_strategy.unwrap_or_default(), - } - } -} - -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)] -pub struct ArtifactsConfig { - #[serde(default)] - pub include: Vec, -} - -impl From for ArtifactsSettings { - fn from(value: ArtifactsConfig) -> Self { - Self { - include: value.include, - } - } -} - -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)] -pub struct GitHubConfig { - #[serde(default)] - pub permissions: HashMap, -} - -impl From for GitHubSettings { - fn from(value: GitHubConfig) -> Self { - Self { - permissions: value.permissions, - } - } -} - -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)] -pub struct LlmConfig { - pub model: Option, - pub provider: Option, - #[serde(default)] - pub fallbacks: Option>>, -} - -impl From for LlmSettings { - fn from(value: LlmConfig) -> Self { - Self { - model: value.model, - provider: value.provider, - fallbacks: value.fallbacks, - } - } -} - -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)] -pub struct SetupConfig { - #[serde(default)] - pub commands: Vec, - pub timeout_ms: Option, -} - -impl From for SetupSettings { - fn from(value: SetupConfig) -> Self { - Self { - commands: value.commands, - timeout_ms: value.timeout_ms, - } - } -} - -/// Load and validate a run config from a TOML file. +/// Expand `${env.NAME}` whole-value references inside a string map. /// -/// The `graph` path in the returned config is resolved relative to the -/// TOML file's parent directory. Any `dockerfile = { path = "..." }` is -/// resolved to inline content. -/// -/// `${env.VARNAME}` references in `[sandbox.env]` are NOT resolved here — -/// call [`resolve_sandbox_env`] separately after snapshotting, so that -/// plaintext secrets are never written to disk. -pub fn load_run_config(path: &Path) -> anyhow::Result { - let contents = std::fs::read_to_string(path) - .with_context(|| format!("Failed to read {}", path.display()))?; - let mut config = parse_run_config(&contents)?; - - let config_dir = path.parent().unwrap_or(Path::new(".")); - resolve_dockerfile(&mut config, config_dir)?; - - Ok(config) -} - -/// Resolve `${env.VARNAME}` references in `[sandbox.env]` values. -/// -/// Only whole-value references are supported (no partial interpolation). -/// Missing host env vars produce a hard error. -pub fn resolve_sandbox_env(config: &mut ConfigLayer) -> anyhow::Result<()> { - if let Some(env) = config.sandbox.as_mut().and_then(|s| s.env.as_mut()) { - resolve_env_refs(env)?; - } - Ok(()) -} - -/// Resolve `${env.VARNAME}` patterns in a map of env vars. -/// -/// If the entire value is `${env.VARNAME}`, it is replaced with the host -/// environment variable. Any other value is left as-is. Missing host -/// variables produce an error. +/// Leaves entries that don't match the whole-value form untouched. Missing +/// host variables produce an error. This is the minimal resolver legacy +/// consumers still call while they are being migrated off `Settings`; the +/// full v2 interpolation pass lives in `fabro_types::settings::v2::interp`. pub fn resolve_env_refs(env: &mut HashMap) -> anyhow::Result<()> { for (key, value) in env.iter_mut() { if let Some(var_name) = value @@ -170,54 +37,26 @@ pub fn resolve_env_refs(env: &mut HashMap) -> anyhow::Result<()> Ok(()) } -/// If the config contains a `dockerfile = { path = "..." }`, read the file -/// and replace it with `DockerfileSource::Inline(contents)`. -fn resolve_dockerfile(config: &mut ConfigLayer, config_dir: &Path) -> anyhow::Result<()> { - let source = config - .sandbox - .as_mut() - .and_then(|s| s.daytona.as_mut()) - .and_then(|d| d.snapshot.as_mut()) - .and_then(|snap| snap.dockerfile.as_mut()); - - if let Some(DockerfileSource::Path { path: ref rel }) = source { - let path = config_dir.join(rel); - let contents = std::fs::read_to_string(&path) - .with_context(|| format!("Failed to read dockerfile at {}", path.display()))?; - debug!(path = %path.display(), "Resolved dockerfile from path"); - *source.unwrap() = DockerfileSource::Inline(contents); - } - - Ok(()) -} - -/// Resolve the graph path relative to the TOML file's parent directory. -pub fn resolve_graph_path(toml_path: &Path, graph: &str) -> PathBuf { - let graph_path = Path::new(graph); - if graph_path.is_absolute() { - graph_path.to_path_buf() - } else { - toml_path - .parent() - .unwrap_or(Path::new(".")) - .join(graph_path) - } -} - +/// Load and parse a run config from a TOML file. pub fn parse_run_config(contents: &str) -> anyhow::Result { - let mut config: ConfigLayer = - toml::from_str(contents).context("Failed to parse run config TOML")?; - - if config.graph.is_none() { - config.graph = Some("workflow.fabro".to_string()); - } - - let version = config.version.unwrap_or(0); - if version != SUPPORTED_VERSION { - bail!( - "Unsupported run config version {version}. Only version {SUPPORTED_VERSION} is supported.", - ); - } - - Ok(config) + ConfigLayer::parse(contents).context("Failed to parse run config TOML") +} + +/// Load and parse a run config from a TOML file. +/// +/// Returns the v2-backed `ConfigLayer`. +pub fn load_run_config(path: &Path) -> anyhow::Result { + let content = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read {}", path.display()))?; + ConfigLayer::parse(&content) + .with_context(|| format!("Failed to parse workflow config at {}", path.display())) +} + +/// Resolve a graph path relative to a workflow.toml. +#[must_use] +pub fn resolve_graph_path(workflow_toml: &Path, graph_relative: &str) -> PathBuf { + workflow_toml + .parent() + .unwrap_or_else(|| Path::new(".")) + .join(graph_relative) } diff --git a/lib/crates/fabro-config/src/sandbox.rs b/lib/crates/fabro-config/src/sandbox.rs index a90052d45..2c247031a 100644 --- a/lib/crates/fabro-config/src/sandbox.rs +++ b/lib/crates/fabro-config/src/sandbox.rs @@ -1,98 +1,10 @@ -use std::collections::HashMap; - -use anyhow::anyhow; -use serde::{Deserialize, Serialize}; +//! Re-export shim for sandbox settings types. +//! +//! Stage 3 removed the parse-time `SandboxConfig`/`DaytonaConfig` types; +//! callers that still import resolved sandbox types via this module use the +//! re-exports below. Stage 6 deletes this file. pub use fabro_types::settings::sandbox::{ DaytonaNetwork, DaytonaSettings, DaytonaSnapshotSettings, DockerfileSource, LocalSandboxSettings, SandboxSettings, WorktreeMode, }; - -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)] -pub struct DaytonaConfig { - pub auto_stop_interval: Option, - pub labels: Option>, - pub snapshot: Option, - pub network: Option, - /// Skip git repo detection and cloning during initialization. - pub skip_clone: Option, -} - -impl TryFrom for DaytonaSettings { - type Error = anyhow::Error; - - fn try_from(value: DaytonaConfig) -> Result { - Ok(Self { - auto_stop_interval: value.auto_stop_interval, - labels: value.labels, - snapshot: value.snapshot.map(TryInto::try_into).transpose()?, - network: value.network, - skip_clone: value.skip_clone.unwrap_or(false), - }) - } -} - -/// Snapshot configuration: when present, the sandbox is created from a snapshot -/// instead of a bare Docker image. -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)] -pub struct DaytonaSnapshotConfig { - pub name: Option, - pub cpu: Option, - pub memory: Option, - pub disk: Option, - pub dockerfile: Option, -} - -impl TryFrom for DaytonaSnapshotSettings { - type Error = anyhow::Error; - - fn try_from(value: DaytonaSnapshotConfig) -> Result { - Ok(Self { - name: value - .name - .ok_or_else(|| anyhow!("sandbox.daytona.snapshot.name is required"))?, - cpu: value.cpu, - memory: value.memory, - disk: value.disk, - dockerfile: value.dockerfile, - }) - } -} - -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)] -pub struct LocalSandboxConfig { - pub worktree_mode: Option, -} - -impl From for LocalSandboxSettings { - fn from(value: LocalSandboxConfig) -> Self { - Self { - worktree_mode: value.worktree_mode.unwrap_or_default(), - } - } -} - -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)] -pub struct SandboxConfig { - pub provider: Option, - pub preserve: Option, - pub devcontainer: Option, - pub local: Option, - pub daytona: Option, - pub env: Option>, -} - -impl TryFrom for SandboxSettings { - type Error = anyhow::Error; - - fn try_from(value: SandboxConfig) -> Result { - Ok(Self { - provider: value.provider, - preserve: value.preserve, - devcontainer: value.devcontainer, - local: value.local.map(Into::into), - daytona: value.daytona.map(TryInto::try_into).transpose()?, - env: value.env, - }) - } -} diff --git a/lib/crates/fabro-config/src/server.rs b/lib/crates/fabro-config/src/server.rs index aec1d4e25..fce1cd9a8 100644 --- a/lib/crates/fabro-config/src/server.rs +++ b/lib/crates/fabro-config/src/server.rs @@ -1,199 +1,23 @@ +//! Re-export shim for server settings types. +//! +//! Stage 3 removed the parse-time `*Config` types (`ApiConfig`, `GitConfig`, +//! etc.) in favor of the v2 parse tree in `fabro_types::settings::v2::server`. +//! This module stays alive as a pass-through for crates that still import +//! resolved server types via the legacy `fabro_config::server` path; +//! Stage 6 deletes it. + use std::path::PathBuf; -use anyhow::anyhow; -use serde::{Deserialize, Serialize}; - use fabro_types::Settings; + pub use fabro_types::settings::server::{ ApiAuthStrategy, ApiSettings, ArtifactStorageBackend, ArtifactStorageSettings, AuthProvider, AuthSettings, FeaturesSettings, GitAuthorSettings, GitProvider, GitSettings, LogSettings, SlackSettings, TlsSettings, WebSettings, WebhookSettings, WebhookStrategy, }; -#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize, crate::Combine)] -pub struct AuthConfig { - pub provider: Option, - #[serde(default)] - pub allowed_usernames: Vec, -} - -impl From for AuthSettings { - fn from(value: AuthConfig) -> Self { - Self { - provider: value.provider.unwrap_or_default(), - allowed_usernames: value.allowed_usernames, - } - } -} - -#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize, crate::Combine)] -pub struct TlsConfig { - pub cert: Option, - pub key: Option, - pub ca: Option, -} - -impl TryFrom for TlsSettings { - type Error = anyhow::Error; - - fn try_from(value: TlsConfig) -> Result { - Ok(Self { - cert: value - .cert - .ok_or_else(|| anyhow!("tls.cert is required when tls is configured"))?, - key: value - .key - .ok_or_else(|| anyhow!("tls.key is required when tls is configured"))?, - ca: value - .ca - .ok_or_else(|| anyhow!("tls.ca is required when tls is configured"))?, - }) - } -} - -#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize, crate::Combine)] -pub struct ApiConfig { - pub base_url: Option, - #[serde(default)] - pub authentication_strategies: Vec, - pub tls: Option, -} - -fn default_base_url() -> String { - "http://localhost:3000/api/v1".to_string() -} - -impl TryFrom for ApiSettings { - type Error = anyhow::Error; - - fn try_from(value: ApiConfig) -> Result { - Ok(Self { - base_url: value.base_url.unwrap_or_else(default_base_url), - authentication_strategies: value.authentication_strategies, - tls: value.tls.map(TryInto::try_into).transpose()?, - }) - } -} - -#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize, crate::Combine)] -pub struct GitAuthorConfig { - pub name: Option, - pub email: Option, -} - -impl From for GitAuthorSettings { - fn from(value: GitAuthorConfig) -> Self { - Self { - name: value.name, - email: value.email, - } - } -} - -#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize, crate::Combine)] -pub struct WebhookConfig { - pub strategy: Option, -} - -impl TryFrom for WebhookSettings { - type Error = anyhow::Error; - - fn try_from(value: WebhookConfig) -> Result { - Ok(Self { - strategy: value - .strategy - .ok_or_else(|| anyhow!("git.webhooks.strategy is required"))?, - }) - } -} - -#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize, crate::Combine)] -pub struct GitConfig { - pub provider: Option, - pub app_id: Option, - pub client_id: Option, - pub slug: Option, - pub author: Option, - pub webhooks: Option, -} - -impl TryFrom for GitSettings { - type Error = anyhow::Error; - - fn try_from(value: GitConfig) -> Result { - Ok(Self { - provider: value.provider.unwrap_or_default(), - app_id: value.app_id, - client_id: value.client_id, - slug: value.slug, - author: value.author.map(Into::into).unwrap_or_default(), - webhooks: value.webhooks.map(TryInto::try_into).transpose()?, - }) - } -} - -#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize, crate::Combine)] -pub struct WebConfig { - pub enabled: Option, - pub url: Option, - pub auth: Option, -} - -fn default_web_url() -> String { - "http://localhost:3000".to_string() -} - -impl From for WebSettings { - fn from(value: WebConfig) -> Self { - Self { - enabled: value.enabled.unwrap_or(true), - url: value.url.unwrap_or_else(default_web_url), - auth: value.auth.map(Into::into).unwrap_or_default(), - } - } -} - -#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize, crate::Combine)] -pub struct SlackConfig { - pub default_channel: Option, -} - -impl From for SlackSettings { - fn from(value: SlackConfig) -> Self { - Self { - default_channel: value.default_channel, - } - } -} - -#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize, crate::Combine)] -pub struct FeaturesConfig { - pub session_sandboxes: Option, - /// Experimental: enable automatic retro generation after workflow runs. - pub retros: Option, -} - -impl From for FeaturesSettings { - fn from(value: FeaturesConfig) -> Self { - Self { - session_sandboxes: value.session_sandboxes.unwrap_or(false), - retros: value.retros.unwrap_or(false), - } - } -} - -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)] -pub struct LogConfig { - pub level: Option, -} - -impl From for LogSettings { - fn from(value: LogConfig) -> Self { - Self { level: value.level } - } -} - /// Resolve the storage directory: config value > default `~/.fabro`. +#[must_use] pub fn resolve_storage_dir(settings: &Settings) -> PathBuf { settings.storage_dir() } diff --git a/lib/crates/fabro-config/src/settings.rs b/lib/crates/fabro-config/src/settings.rs index 90369ef45..e6a0a341b 100644 --- a/lib/crates/fabro-config/src/settings.rs +++ b/lib/crates/fabro-config/src/settings.rs @@ -1,54 +1,5 @@ -use fabro_types::Settings; - -use crate::config::ConfigLayer; - -impl TryFrom for Settings { - type Error = anyhow::Error; - - fn try_from(value: ConfigLayer) -> Result { - Ok(Self { - version: value.version, - goal: value.goal, - goal_file: value.goal_file, - graph: value.graph, - labels: value.labels, - work_dir: value.work_dir, - llm: value.llm.map(Into::into), - setup: value.setup.map(Into::into), - sandbox: value.sandbox.map(TryInto::try_into).transpose()?, - vars: value.vars, - checkpoint: value.checkpoint.into(), - pull_request: value.pull_request.map(Into::into), - artifacts: value.artifacts.map(Into::into), - hooks: value.hooks, - mcp_servers: value.mcp_servers, - github: value.github.map(Into::into), - server: value.server.map(TryInto::try_into).transpose()?, - exec: value.exec.map(Into::into), - prevent_idle_sleep: value.prevent_idle_sleep, - verbose: value.verbose, - upgrade_check: value.upgrade_check, - dry_run: value.dry_run, - auto_approve: value.auto_approve, - no_retro: value.no_retro, - storage_dir: value.storage_dir, - max_concurrent_runs: value.max_concurrent_runs, - artifact_storage: value.artifact_storage, - web: value.web.map(Into::into), - slack: value.slack.map(Into::into), - api: value.api.map(TryInto::try_into).transpose()?, - features: value.features.map(Into::into), - log: value.log.map(Into::into), - git: value.git.map(TryInto::try_into).transpose()?, - fabro: value.fabro.map(Into::into), - }) - } -} - -impl TryFrom<&ConfigLayer> for Settings { - type Error = anyhow::Error; - - fn try_from(value: &ConfigLayer) -> Result { - value.clone().try_into() - } -} +//! Empty module retained for backwards-compatible imports. +//! +//! The legacy `TryFrom for Settings` impl was replaced by +//! [`crate::ConfigLayer::resolve`], which delegates to the v2 bridge in +//! `fabro_types::settings::v2::bridge`. Stage 6 deletes this file entirely. diff --git a/lib/crates/fabro-config/src/user.rs b/lib/crates/fabro-config/src/user.rs index 553c4935c..f72d3e0f3 100644 --- a/lib/crates/fabro-config/src/user.rs +++ b/lib/crates/fabro-config/src/user.rs @@ -1,10 +1,13 @@ +//! User config loading. +//! +//! Stage 3 removed the parse-time `ClientTlsConfig`/`ServerConfig`/`ExecConfig` +//! types; this module now only exposes machine-level settings loading plus +//! path helpers and a re-export of the resolved user-facing types. + use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::sync::{Mutex, OnceLock}; -use anyhow::anyhow; -use serde::{Deserialize, Serialize}; - use crate::config::ConfigLayer; use crate::home::Home; @@ -20,67 +23,6 @@ pub const FABRO_CONFIG_ENV: &str = "FABRO_CONFIG"; static WARNED_LEGACY_USER_CONFIGS: OnceLock>> = OnceLock::new(); -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)] -pub struct ClientTlsConfig { - pub cert: Option, - pub key: Option, - pub ca: Option, -} - -impl TryFrom for ClientTlsSettings { - type Error = anyhow::Error; - - fn try_from(value: ClientTlsConfig) -> Result { - Ok(Self { - cert: value.cert.ok_or_else(|| { - anyhow!("server.tls.cert is required when server.tls is configured") - })?, - key: value.key.ok_or_else(|| { - anyhow!("server.tls.key is required when server.tls is configured") - })?, - ca: value.ca.ok_or_else(|| { - anyhow!("server.tls.ca is required when server.tls is configured") - })?, - }) - } -} - -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)] -pub struct ServerConfig { - pub target: Option, - pub tls: Option, -} - -impl TryFrom for ServerSettings { - type Error = anyhow::Error; - - fn try_from(value: ServerConfig) -> Result { - Ok(Self { - target: value.target, - tls: value.tls.map(TryInto::try_into).transpose()?, - }) - } -} - -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)] -pub struct ExecConfig { - pub provider: Option, - pub model: Option, - pub permissions: Option, - pub output_format: Option, -} - -impl From for ExecSettings { - fn from(value: ExecConfig) -> Self { - Self { - provider: value.provider, - model: value.model, - permissions: value.permissions, - output_format: value.output_format, - } - } -} - pub fn default_settings_path() -> PathBuf { Home::from_env().user_config() } @@ -126,15 +68,16 @@ fn should_warn_about_legacy_user_config(path: &Path) -> bool { .insert(path.to_path_buf()) } -/// 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. +/// 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. #[allow(clippy::print_stderr)] pub fn load_settings_config(path: Option<&Path>) -> anyhow::Result { if let Some(explicit) = path .map(Path::to_path_buf) .or_else(|| std::env::var_os(FABRO_CONFIG_ENV).map(PathBuf::from)) { - return crate::load_config_file(Some(&explicit), SETTINGS_CONFIG_FILENAME); + return load_v2_layer_from_path(&explicit); } for legacy_path in [ @@ -155,7 +98,16 @@ pub fn load_settings_config(path: Option<&Path>) -> anyhow::Result } } - crate::load_config_file(None, SETTINGS_CONFIG_FILENAME) + let default = Home::from_env().root().join(SETTINGS_CONFIG_FILENAME); + if default.is_file() { + load_v2_layer_from_path(&default) + } else { + Ok(ConfigLayer::default()) + } +} + +fn load_v2_layer_from_path(path: &Path) -> anyhow::Result { + ConfigLayer::load(path) } #[cfg(test)] diff --git a/lib/crates/fabro-server/src/run_manifest.rs b/lib/crates/fabro-server/src/run_manifest.rs index 82b8ced87..2915e44ca 100644 --- a/lib/crates/fabro-server/src/run_manifest.rs +++ b/lib/crates/fabro-server/src/run_manifest.rs @@ -8,14 +8,18 @@ use fabro_config::ConfigLayer; use fabro_config::effective_settings; use fabro_config::effective_settings::{EffectiveSettingsLayers, EffectiveSettingsMode}; use fabro_config::project::resolve_working_directory; -use fabro_config::run::{LlmConfig, parse_run_config}; -use fabro_config::sandbox::{DockerfileSource, SandboxConfig}; +use fabro_config::run::parse_run_config; use fabro_graphviz::graph::{Graph, is_llm_handler_type}; use fabro_graphviz::render::apply_direction; use fabro_llm::Provider; use fabro_model::Catalog; use fabro_sandbox::daytona::DaytonaConfig; use fabro_sandbox::{DockerSandboxOptions, Sandbox, SandboxProvider, SandboxSpec}; +use fabro_types::settings::v2::interp::InterpString; +use fabro_types::settings::v2::run::{ + AgentPermissions, ApprovalMode, DaytonaDockerfileLayer, RunExecutionLayer, RunLayer, RunMode, + RunModelLayer, RunSandboxLayer, +}; use fabro_types::{RunId, Settings}; use fabro_util::check_report::{CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus}; use fabro_validate::Severity; @@ -200,7 +204,7 @@ fn parse_manifest_config(config: &types::ManifestConfig) -> Result let Some(source) = config.source.as_deref() else { return Ok(ConfigLayer::default()); }; - toml::from_str(source).map_err(Into::into) + ConfigLayer::parse(source) } fn manifest_args_layer(args: Option<&types::ManifestArgs>) -> ConfigLayer { @@ -208,28 +212,61 @@ fn manifest_args_layer(args: Option<&types::ManifestArgs>) -> ConfigLayer { return ConfigLayer::default(); }; - let llm = (args.model.is_some() || args.provider.is_some()).then(|| LlmConfig { - model: args.model.clone(), - provider: args.provider.clone(), - fallbacks: None, + let model = (args.model.is_some() || args.provider.is_some()).then(|| RunModelLayer { + provider: args.provider.as_deref().map(InterpString::parse), + name: args.model.as_deref().map(InterpString::parse), + fallbacks: Vec::new(), }); let sandbox = - (args.sandbox.is_some() || args.preserve_sandbox.is_some()).then(|| SandboxConfig { + (args.sandbox.is_some() || args.preserve_sandbox.is_some()).then(|| RunSandboxLayer { provider: args.sandbox.clone(), preserve: args.preserve_sandbox, - ..Default::default() + ..RunSandboxLayer::default() }); - ConfigLayer { - llm, + let execution_has_any = + args.dry_run.is_some() || args.auto_approve.is_some() || args.no_retro.is_some(); + let execution = execution_has_any.then(|| RunExecutionLayer { + mode: args + .dry_run + .map(|d| if d { RunMode::DryRun } else { RunMode::Normal }), + approval: args.auto_approve.map(|a| { + if a { + ApprovalMode::Auto + } else { + ApprovalMode::Prompt + } + }), + retros: args.no_retro.map(|nr| !nr), + }); + + let run_has_any = + model.is_some() || sandbox.is_some() || execution.is_some() || !args.label.is_empty(); + + let run = run_has_any.then(|| RunLayer { + model, sandbox, - verbose: args.verbose, - dry_run: args.dry_run, - auto_approve: args.auto_approve, - no_retro: args.no_retro, - labels: parse_labels(&args.label), - ..Default::default() + execution, + metadata: parse_labels(&args.label), + ..RunLayer::default() + }); + + let mut file = fabro_types::settings::v2::SettingsFile::default(); + if let Some(run) = run { + file.run = Some(run); } + + // Verbose is a CLI output-verbosity concern in v2, but manifest args + // are resolved server-side as run knobs too. For now we store it as a + // metadata key so Stage 4 consumers can pick it up via the bridge. + if let Some(verbose) = args.verbose { + file.run + .get_or_insert_with(RunLayer::default) + .metadata + .insert("fabro.verbose".into(), verbose.to_string()); + } + let _ = AgentPermissions::ReadOnly; // keep unused import alive until Stage 4 wires agent args + ConfigLayer::from(file) } fn parse_labels(labels: &[String]) -> HashMap { @@ -246,22 +283,27 @@ fn resolve_manifest_dockerfile( files: &HashMap, ) -> Result<()> { let source = layer - .sandbox + .as_v2_mut() + .run .as_mut() + .and_then(|run| run.sandbox.as_mut()) .and_then(|sandbox| sandbox.daytona.as_mut()) .and_then(|daytona| daytona.snapshot.as_mut()) .and_then(|snapshot| snapshot.dockerfile.as_mut()); - let Some(DockerfileSource::Path { path }) = source else { + let Some(DaytonaDockerfileLayer::Path { path }) = source else { return Ok(()); }; - let logical_path = - normalize_logical_path(config_path.parent().unwrap_or_else(|| Path::new(".")), path) - .ok_or_else(|| anyhow!("unsupported dockerfile reference: {path}"))?; + let path_owned = path.clone(); + let logical_path = normalize_logical_path( + config_path.parent().unwrap_or_else(|| Path::new(".")), + &path_owned, + ) + .ok_or_else(|| anyhow!("unsupported dockerfile reference: {path_owned}"))?; let content = files .get(&logical_path) .cloned() .ok_or_else(|| anyhow!("missing bundled dockerfile: {}", logical_path.display()))?; - *source.unwrap() = DockerfileSource::Inline(content); + *source.unwrap() = DaytonaDockerfileLayer::Inline(content); Ok(()) } diff --git a/lib/crates/fabro-test/src/lib.rs b/lib/crates/fabro-test/src/lib.rs index b058cc5bf..bc941f597 100644 --- a/lib/crates/fabro-test/src/lib.rs +++ b/lib/crates/fabro-test/src/lib.rs @@ -335,7 +335,7 @@ fn write_marker(root: &Path) { fn managed_storage_settings(storage_dir: &Path, rest: &str) -> String { format!( - "{MANAGED_STORAGE_MARKER}\nstorage_dir = \"{}\"\n{rest}", + "{MANAGED_STORAGE_MARKER}\n_version = 1\n\n[server.storage]\nroot = \"{}\"\n\n{rest}", storage_dir.display() ) } @@ -349,24 +349,19 @@ fn strip_managed_storage_settings(contents: &str) -> &str { .strip_prefix(MANAGED_STORAGE_MARKER) .and_then(|rest| rest.strip_prefix('\n')) .unwrap_or(""); - let (first_line, mut rest) = after_marker.split_once('\n').unwrap_or((after_marker, "")); - if !first_line.starts_with("storage_dir = ") { - return after_marker; - } - if let Some((maybe_target, tail)) = rest.split_once('\n') { - if maybe_target.starts_with("server.target = ") { - rest = tail; - } - } - rest + after_marker } fn settings_storage_dir(settings_path: &Path) -> Option { let content = std::fs::read_to_string(settings_path).ok()?; - let value = toml::from_str::(strip_managed_storage_settings(&content)).ok()?; + let stripped = strip_managed_storage_settings(&content); + let value = toml::from_str::(stripped).ok()?; value - .get("storage_dir") - .or_else(|| value.get("data_dir")) + .get("server") + .and_then(toml::Value::as_table) + .and_then(|server| server.get("storage")) + .and_then(toml::Value::as_table) + .and_then(|storage| storage.get("root")) .and_then(toml::Value::as_str) .map(PathBuf::from) } @@ -379,7 +374,10 @@ fn write_settings_file(path: &Path, storage_dir: &Path, rest: &str) { ensure_parent_dir(path); std::fs::write( path, - format!("storage_dir = \"{}\"\n{rest}", storage_dir.display()), + format!( + "_version = 1\n\n[server.storage]\nroot = \"{}\"\n\n{rest}", + storage_dir.display() + ), ) .unwrap_or_else(|err| panic!("failed to write {}: {err}", path.display())); } @@ -407,36 +405,45 @@ fn write_settings_table(path: &Path, table: &TomlMap) { fn server_target_from_table(table: &TomlMap) -> Option { table - .get("server") + .get("cli") .and_then(TomlValue::as_table) - .and_then(|server| server.get("target")) + .and_then(|cli| cli.get("target")) + .and_then(TomlValue::as_table) + .and_then(|target| target.get("path").or_else(|| target.get("url"))) .and_then(TomlValue::as_str) .map(ToOwned::to_owned) } fn set_server_target(table: &mut TomlMap, socket_path: &Path) { - let server_entry = table - .entry("server".to_string()) + let cli_entry = table + .entry("cli".to_string()) .or_insert_with(|| TomlValue::Table(TomlMap::new())); - let Some(server_table) = server_entry.as_table_mut() else { - panic!("expected [server] to be a TOML table"); + let Some(cli_table) = cli_entry.as_table_mut() else { + panic!("expected [cli] to be a TOML table"); }; - server_table.insert( - "target".to_string(), + let target_entry = cli_table + .entry("target".to_string()) + .or_insert_with(|| TomlValue::Table(TomlMap::new())); + let Some(target_table) = target_entry.as_table_mut() else { + panic!("expected [cli.target] to be a TOML table"); + }; + target_table.insert("type".to_string(), TomlValue::String("unix".to_string())); + target_table.insert( + "path".to_string(), TomlValue::String(socket_path.display().to_string()), ); } fn clear_server_target(table: &mut TomlMap) { - let Some(server_entry) = table.get_mut("server") else { + let Some(cli_entry) = table.get_mut("cli") else { return; }; - let Some(server_table) = server_entry.as_table_mut() else { + let Some(cli_table) = cli_entry.as_table_mut() else { return; }; - server_table.remove("target"); - if server_table.is_empty() { - table.remove("server"); + cli_table.remove("target"); + if cli_table.is_empty() { + table.remove("cli"); } } @@ -451,8 +458,8 @@ fn sync_home_settings( Ok(contents) => { let had_managed_storage = contents.starts_with(MANAGED_STORAGE_MARKER); let table = parse_settings_table(&contents, settings_path); - let had_explicit_storage = !had_managed_storage - && (table.contains_key("storage_dir") || table.contains_key("data_dir")); + let had_explicit_storage = + !had_managed_storage && has_explicit_storage_root(&table); let had_explicit_target = server_target_from_table(&table).is_some(); (table, had_explicit_storage, had_explicit_target) } @@ -462,12 +469,12 @@ fn sync_home_settings( Err(err) => panic!("failed to read {}: {err}", settings_path.display()), }; + table + .entry("_version".to_string()) + .or_insert(TomlValue::Integer(1)); + if !had_explicit_storage { - table.insert( - "storage_dir".to_string(), - TomlValue::String(storage_dir.display().to_string()), - ); - table.remove("data_dir"); + set_server_storage_root(&mut table, storage_dir); } if force_server_target || (!had_explicit_storage && !had_explicit_target) { @@ -478,21 +485,23 @@ fn sync_home_settings( if !had_explicit_storage { let mut rest = table.clone(); - rest.remove("storage_dir"); + clear_server_storage(&mut rest); + rest.remove("_version"); let managed_target = !had_explicit_target && !force_server_target; if managed_target { clear_server_target(&mut rest); } - let rest = toml::to_string(&rest) + let rest_toml = toml::to_string(&rest) .unwrap_or_else(|err| panic!("failed to serialize {}: {err}", settings_path.display())); - let mut contents = managed_storage_settings(storage_dir, &rest); - if managed_target { - contents = format!( - "{MANAGED_STORAGE_MARKER}\nstorage_dir = \"{}\"\nserver.target = \"{}\"\n{rest}", + let contents = if managed_target { + format!( + "{MANAGED_STORAGE_MARKER}\n_version = 1\n\n[server.storage]\nroot = \"{}\"\n\n[cli.target]\ntype = \"unix\"\npath = \"{}\"\n\n{rest_toml}", storage_dir.display(), socket_path.display() - ); - } + ) + } else { + managed_storage_settings(storage_dir, &rest_toml) + }; ensure_parent_dir(settings_path); std::fs::write(settings_path, contents) .unwrap_or_else(|err| panic!("failed to write {}: {err}", settings_path.display())); @@ -502,6 +511,48 @@ fn sync_home_settings( write_settings_table(settings_path, &table); } +fn has_explicit_storage_root(table: &TomlMap) -> bool { + table + .get("server") + .and_then(TomlValue::as_table) + .and_then(|server| server.get("storage")) + .and_then(TomlValue::as_table) + .and_then(|storage| storage.get("root")) + .is_some() +} + +fn set_server_storage_root(table: &mut TomlMap, storage_dir: &Path) { + let server_entry = table + .entry("server".to_string()) + .or_insert_with(|| TomlValue::Table(TomlMap::new())); + let Some(server_table) = server_entry.as_table_mut() else { + panic!("expected [server] to be a TOML table"); + }; + let storage_entry = server_table + .entry("storage".to_string()) + .or_insert_with(|| TomlValue::Table(TomlMap::new())); + let Some(storage_table) = storage_entry.as_table_mut() else { + panic!("expected [server.storage] to be a TOML table"); + }; + storage_table.insert( + "root".to_string(), + TomlValue::String(storage_dir.display().to_string()), + ); +} + +fn clear_server_storage(table: &mut TomlMap) { + let Some(server_entry) = table.get_mut("server") else { + return; + }; + let Some(server_table) = server_entry.as_table_mut() else { + return; + }; + server_table.remove("storage"); + if server_table.is_empty() { + table.remove("server"); + } +} + fn server_record_path(storage_dir: &Path) -> PathBuf { storage_dir.join("server.json") } From eabbca649bb0d3f4ac6bb38bd9597bac481be861 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 9 Apr 2026 09:57:30 -0400 Subject: [PATCH 04/47] feat(tests): migrate fabro-cli fixtures and repo fabro.toml to v2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 4 consumer migration: rewrite test fixtures across fabro-cli integration tests and the repo's own fabro.toml + workflow.toml files to use the v2 namespaced schema. Fixtures migrated: - repo fabro.toml: [fabro] root → [project] directory, [pull_request] → [run.pull_request], [sandbox] → [run.sandbox], daytona labels and snapshot moved under [run.sandbox.daytona], [[hooks]] → [[run.hooks]] with id, integer memory/disk → '8GB'/'20GB' Size values - fabro/workflows/{implement-issue,implement-plan,gh-triage,smoke}/ workflow.toml: version → _version, [github] → [server.integrations.github] - fabro-cli integration tests: config.rs (settings/external fixtures), repo.rs, repo_init.rs, runner.rs, run.rs, store_dump.rs, workflow.rs, workflow_create.rs, support.rs - fabro-server run_manifest.rs: prepare_manifest test constructs v2 manifest configs (run.prepare.steps + server.integrations.github) and updated assertion to reflect v2 whole-list replacement of run.prepare.steps across layers Validate command tests all green; ~10 tests remain that need targeted fixes for specific behaviors that shifted between schemas. --- fabro.toml | 28 ++-- fabro/workflows/gh-triage/workflow.toml | 9 +- fabro/workflows/implement-issue/workflow.toml | 9 +- fabro/workflows/implement-plan/workflow.toml | 2 +- fabro/workflows/smoke/workflow.toml | 2 +- lib/crates/fabro-cli/src/manifest_builder.rs | 4 +- lib/crates/fabro-cli/tests/it/cmd/config.rs | 124 +++++++++++------- lib/crates/fabro-cli/tests/it/cmd/repo.rs | 4 +- .../fabro-cli/tests/it/cmd/repo_init.rs | 19 ++- lib/crates/fabro-cli/tests/it/cmd/run.rs | 12 +- lib/crates/fabro-cli/tests/it/cmd/runner.rs | 6 +- .../fabro-cli/tests/it/cmd/store_dump.rs | 12 +- lib/crates/fabro-cli/tests/it/cmd/workflow.rs | 7 +- .../fabro-cli/tests/it/cmd/workflow_create.rs | 6 +- lib/crates/fabro-server/src/run_manifest.rs | 18 ++- 15 files changed, 152 insertions(+), 110 deletions(-) diff --git a/fabro.toml b/fabro.toml index e823e6866..52c92cce5 100644 --- a/fabro.toml +++ b/fabro.toml @@ -1,29 +1,26 @@ -version = 1 +_version = 1 -[fabro] -root = "fabro/" +[project] +directory = "fabro/" -[features] -retros = false - -[pull_request] +[run.pull_request] enabled = true draft = false -[sandbox] +[run.sandbox] provider = "daytona" -[sandbox.daytona] +[run.sandbox.daytona] auto_stop_interval = 30 -[sandbox.daytona.labels] +[run.sandbox.daytona.labels] repo = "fabro-sh/fabro" -[sandbox.daytona.snapshot] +[run.sandbox.daytona.snapshot] name = "fabro-v6" cpu = 4 -memory = 8 -disk = 20 +memory = "8GB" +disk = "20GB" dockerfile = """ FROM ubuntu:24.04 @@ -52,9 +49,10 @@ ENV PATH="/root/.bun/bin:${PATH}" WORKDIR /root """ -[[hooks]] +[[run.hooks]] +id = "cargo-fmt" name = "cargo-fmt" event = "post_tool_use" matcher = "write_file|edit_file|apply_patch" -command = "cargo fmt" +script = "cargo fmt" blocking = true diff --git a/fabro/workflows/gh-triage/workflow.toml b/fabro/workflows/gh-triage/workflow.toml index 3e0093cb8..8fafbcab1 100644 --- a/fabro/workflows/gh-triage/workflow.toml +++ b/fabro/workflows/gh-triage/workflow.toml @@ -1,4 +1,7 @@ -version = 1 +_version = 1 -[github] -permissions = { pull_requests = "read", issues = "read" } +[server.integrations.github] + +[server.integrations.github.permissions] +pull_requests = "read" +issues = "read" diff --git a/fabro/workflows/implement-issue/workflow.toml b/fabro/workflows/implement-issue/workflow.toml index 0ec3f59de..2a0f45f16 100644 --- a/fabro/workflows/implement-issue/workflow.toml +++ b/fabro/workflows/implement-issue/workflow.toml @@ -1,4 +1,7 @@ -version = 1 +_version = 1 -[github] -permissions = { issues = "read", pull_requests = "write" } +[server.integrations.github] + +[server.integrations.github.permissions] +issues = "read" +pull_requests = "write" diff --git a/fabro/workflows/implement-plan/workflow.toml b/fabro/workflows/implement-plan/workflow.toml index 2ebc2a05d..9e79c2378 100644 --- a/fabro/workflows/implement-plan/workflow.toml +++ b/fabro/workflows/implement-plan/workflow.toml @@ -1 +1 @@ -version = 1 \ No newline at end of file +_version = 1 diff --git a/fabro/workflows/smoke/workflow.toml b/fabro/workflows/smoke/workflow.toml index d9914dfa6..9e79c2378 100644 --- a/fabro/workflows/smoke/workflow.toml +++ b/fabro/workflows/smoke/workflow.toml @@ -1 +1 @@ -version = 1 +_version = 1 diff --git a/lib/crates/fabro-cli/src/manifest_builder.rs b/lib/crates/fabro-cli/src/manifest_builder.rs index 58bbf317e..2f50af38f 100644 --- a/lib/crates/fabro-cli/src/manifest_builder.rs +++ b/lib/crates/fabro-cli/src/manifest_builder.rs @@ -562,10 +562,10 @@ mod tests { std::fs::create_dir_all(workflow_dir.join("prompts")).unwrap(); std::fs::create_dir_all(workflow_dir.join("imports")).unwrap(); std::fs::create_dir_all(&child_dir).unwrap(); - std::fs::write(project.join("fabro.toml"), "version = 1\n").unwrap(); + std::fs::write(project.join("fabro.toml"), "_version = 1\n").unwrap(); std::fs::write( workflow_dir.join("workflow.toml"), - "version = 1\ngraph = \"workflow.fabro\"\n", + "_version = 1\n\n[workflow]\ngraph = \"workflow.fabro\"\n", ) .unwrap(); std::fs::write( diff --git a/lib/crates/fabro-cli/tests/it/cmd/config.rs b/lib/crates/fabro-cli/tests/it/cmd/config.rs index c9d9a8396..346442220 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/config.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/config.rs @@ -82,37 +82,44 @@ fn setup_settings_fixture(context: &fabro_test::TestContext) -> tempfile::TempDi context.write_home( ".fabro/settings.toml", r#" -verbose = true +_version = 1 -[llm] -model = "cli-model" +[cli.output] +verbosity = "verbose" + +[run.model] +name = "cli-model" provider = "openai" -[vars] +[run.inputs] cli_only = "1" shared = "cli" -[checkpoint] +[run.checkpoint] exclude_globs = ["cli-only", "shared"] -[[hooks]] +[[run.hooks]] +id = "shared" name = "shared" event = "run_start" -command = "echo cli" +script = "echo cli" -[mcp_servers.shared] +[run.agent.mcps.shared] type = "stdio" command = ["echo", "cli"] -[sandbox] +[run.sandbox] provider = "daytona" -[sandbox.daytona] -labels = { cli_only = "1", shared = "cli" } - -[sandbox.env] +[run.sandbox.env] CLI_ONLY = "1" SHARED = "cli" + +[run.sandbox.daytona] + +[run.sandbox.daytona.labels] +cli_only = "1" +shared = "cli" "#, ); @@ -120,22 +127,23 @@ SHARED = "cli" std::fs::write( project.path().join("fabro.toml"), r#" -version = 1 +_version = 1 -[fabro] -root = "fabro" +[project] +directory = "fabro" -[llm] -model = "project-model" +[run.model] +name = "project-model" -[vars] +[run.inputs] project_only = "1" shared = "project" -[[hooks]] +[[run.hooks]] +id = "project" name = "project" event = "run_complete" -command = "echo project" +script = "echo project" "#, ) .unwrap(); @@ -145,44 +153,51 @@ command = "echo project" std::fs::write( workflow_dir.join("workflow.toml"), r#" -version = 1 +_version = 1 + +[run] goal = "demo goal" -[llm] -model = "run-model" +[run.model] +name = "run-model" provider = "anthropic" -[vars] +[run.inputs] run_only = "1" shared = "run" -[checkpoint] +[run.checkpoint] exclude_globs = ["run-only", "shared"] -[[hooks]] +[[run.hooks]] +id = "shared" name = "shared" event = "run_start" -command = "echo run" +script = "echo run" -[[hooks]] +[[run.hooks]] +id = "run-only" name = "run-only" event = "run_complete" -command = "echo run-only" +script = "echo run-only" -[mcp_servers.shared] +[run.agent.mcps.shared] type = "stdio" command = ["echo", "run"] -[mcp_servers.run_only] +[run.agent.mcps.run_only] type = "stdio" command = ["echo", "run-only"] -[sandbox.daytona] -labels = { run_only = "1", shared = "run" } - -[sandbox.env] +[run.sandbox.env] RUN_ONLY = "1" SHARED = "run" + +[run.sandbox.daytona] + +[run.sandbox.daytona.labels] +run_only = "1" +shared = "run" "#, ) .unwrap(); @@ -208,11 +223,16 @@ fn setup_external_workflow_fixture( ".fabro/settings.toml", format!( r#" -storage_dir = "{}" -auto_approve = true +_version = 1 -[setup] -commands = ["cli-setup"] +[server.storage] +root = "{}" + +[run.execution] +approval = "auto" + +[[run.prepare.steps]] +script = "cli-setup" "#, storage_dir.display() ), @@ -222,12 +242,12 @@ commands = ["cli-setup"] std::fs::write( project.path().join("fabro.toml"), r#" -version = 1 +_version = 1 -[setup] -commands = ["project-setup"] +[[run.prepare.steps]] +script = "project-setup" -[sandbox] +[run.sandbox] preserve = true "#, ) @@ -248,15 +268,19 @@ digraph Test { std::fs::write( project.path().join("workflow.toml"), r#" -version = 1 -goal = "Ship it" +_version = 1 + +[workflow] graph = "workflow.fabro" -[llm] -model = "claude-sonnet-4-6" +[run] +goal = "Ship it" -[setup] -commands = ["workflow-setup"] +[run.model] +name = "claude-sonnet-4-6" + +[[run.prepare.steps]] +script = "workflow-setup" "#, ) .unwrap(); diff --git a/lib/crates/fabro-cli/tests/it/cmd/repo.rs b/lib/crates/fabro-cli/tests/it/cmd/repo.rs index e3ef64050..78b56a56f 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/repo.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/repo.rs @@ -2,9 +2,9 @@ use fabro_test::{fabro_snapshot, test_context}; fn init_fabro_project(context: &fabro_test::TestContext) { context - .write_temp("fabro.toml", "version = 1\n") + .write_temp("fabro.toml", "_version = 1\n") .write_temp("fabro/workflows/hello/workflow.fabro", "digraph {}") - .write_temp("fabro/workflows/hello/workflow.toml", "version = 1\n"); + .write_temp("fabro/workflows/hello/workflow.toml", "_version = 1\n"); } #[test] diff --git a/lib/crates/fabro-cli/tests/it/cmd/repo_init.rs b/lib/crates/fabro-cli/tests/it/cmd/repo_init.rs index 125802476..5857e5638 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/repo_init.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/repo_init.rs @@ -58,16 +58,13 @@ fn repo_init_creates_fabro_toml_and_hello_workflow() { # Fabro project configuration # https://docs.fabro.computer/getting-started/quick-start - version = 1 + _version = 1 - [fabro] - root = "fabro/" - - # Disable retrospective analysis after workflow runs: - # retro = false + [project] + directory = "fabro/" # Auto-create pull requests on successful workflow runs. - [pull_request] + [run.pull_request] enabled = true draft = true # auto_merge = true @@ -94,10 +91,12 @@ fn repo_init_creates_fabro_toml_and_hello_workflow() { std::fs::read_to_string(context.temp_dir.join("fabro/workflows/hello/workflow.toml")) .unwrap(), @r###" - version = 1 + _version = 1 + + [workflow] graph = "workflow.fabro" - [sandbox] + [run.sandbox] provider = "local" "### ); @@ -107,7 +106,7 @@ fn repo_init_creates_fabro_toml_and_hello_workflow() { fn repo_init_rejects_already_initialized_repo() { let context = test_context!(); context.git_init(); - std::fs::write(context.temp_dir.join("fabro.toml"), "version = 1\n").unwrap(); + std::fs::write(context.temp_dir.join("fabro.toml"), "_version = 1\n").unwrap(); let mut cmd = context.command(); cmd.args(["repo", "init"]); diff --git a/lib/crates/fabro-cli/tests/it/cmd/run.rs b/lib/crates/fabro-cli/tests/it/cmd/run.rs index ce90bd159..11ebc5ad9 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/run.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/run.rs @@ -454,18 +454,22 @@ fn local_foreground_run_prints_artifact_paths_from_server_artifact_list() { ); context.write_temp( "artifact-summary/run.toml", - r#"version = 1 + r#"_version = 1 + +[workflow] graph = "workflow.fabro" + +[run] goal = "Show stored artifacts" -[sandbox] +[run.sandbox] provider = "local" preserve = true -[sandbox.local] +[run.sandbox.local] worktree_mode = "never" -[artifacts] +[run.artifacts] include = ["assets/**"] "#, ); diff --git a/lib/crates/fabro-cli/tests/it/cmd/runner.rs b/lib/crates/fabro-cli/tests/it/cmd/runner.rs index 894ee01b1..58016cc76 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/runner.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/runner.rs @@ -177,9 +177,9 @@ fn runner_uses_snapshotted_app_id_for_github_credentials() { context.write_home( ".fabro/settings.toml", "\ -version = 1 +_version = 1 -[git] +[server.integrations.github] app_id = \"snapshotted-app-id\" ", ); @@ -222,7 +222,7 @@ digraph GitHubApp { "# ); - context.write_home(".fabro/settings.toml", "version = 1\n"); + context.write_home(".fabro/settings.toml", "_version = 1\n"); let server = server_target(&context.storage_dir); let mut cmd = context.command(); diff --git a/lib/crates/fabro-cli/tests/it/cmd/store_dump.rs b/lib/crates/fabro-cli/tests/it/cmd/store_dump.rs index db88197d3..8fbebe8ec 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/store_dump.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/store_dump.rs @@ -137,18 +137,22 @@ fn store_dump_exports_blob_refs_and_artifacts_together() { .unwrap(); fs::write( workspace_dir.join("run.toml"), - r#"version = 1 + r#"_version = 1 + +[workflow] graph = "mixed-export.fabro" + +[run] goal = "Generate oversized command output and artifacts" -[sandbox] +[run.sandbox] provider = "local" preserve = true -[sandbox.local] +[run.sandbox.local] worktree_mode = "never" -[artifacts] +[run.artifacts] include = ["assets/**"] "#, ) diff --git a/lib/crates/fabro-cli/tests/it/cmd/workflow.rs b/lib/crates/fabro-cli/tests/it/cmd/workflow.rs index 3c81517fd..89143c2aa 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/workflow.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/workflow.rs @@ -34,10 +34,13 @@ fn list() { let context = test_context!(); context - .write_temp("fabro.toml", "version = 1\n") + .write_temp( + "fabro.toml", + "_version = 1\n\n[project]\ndirectory = \".\"\n", + ) .write_temp( "workflows/my_test_wf/workflow.toml", - "version = 1\ngoal = \"A test workflow\"\n", + "_version = 1\n\n[run]\ngoal = \"A test workflow\"\n", ); let mut cmd = context.command(); diff --git a/lib/crates/fabro-cli/tests/it/cmd/workflow_create.rs b/lib/crates/fabro-cli/tests/it/cmd/workflow_create.rs index c737206c8..1079604b0 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/workflow_create.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/workflow_create.rs @@ -79,7 +79,7 @@ fn workflow_create_writes_scaffold_files() { std::fs::read_to_string(project.fabro_root.join("workflows/hello-world/workflow.toml")) .unwrap(), @r###" - version = 1 + _version = 1 "### ); } @@ -125,7 +125,7 @@ fn workflow_create_rejects_existing_workflow() { std::fs::create_dir_all(project.fabro_root.join("workflows/existing")).unwrap(); std::fs::write( project.fabro_root.join("workflows/existing/workflow.toml"), - "version = 1\n", + "_version = 1\n", ) .unwrap(); @@ -163,7 +163,7 @@ fn workflow_create_json_uses_resolved_custom_root_paths() { let project_dir = context.temp_dir.join("project"); context.write_temp( "project/fabro.toml", - "version = 1\n[fabro]\nroot = \"custom/fabro-data\"\n", + "_version = 1\n\n[project]\ndirectory = \"custom/fabro-data\"\n", ); let output = context diff --git a/lib/crates/fabro-server/src/run_manifest.rs b/lib/crates/fabro-server/src/run_manifest.rs index 2915e44ca..3ead8c357 100644 --- a/lib/crates/fabro-server/src/run_manifest.rs +++ b/lib/crates/fabro-server/src/run_manifest.rs @@ -871,10 +871,10 @@ app_id = "snapshotted-app-id" Some(types::ManifestWorkflowConfig { path: "workflow.toml".to_string(), source: r#" -version = 1 +_version = 1 -[setup] -commands = ["workflow-setup"] +[[run.prepare.steps]] +script = "workflow-setup" "# .to_string(), }); @@ -882,10 +882,12 @@ commands = ["workflow-setup"] path: Some("/tmp/home/.fabro/settings.toml".to_string()), source: Some( r#" -[setup] -commands = ["cli-setup"] +_version = 1 -[git] +[[run.prepare.steps]] +script = "cli-setup" + +[server.integrations.github] app_id = "snapshotted-app-id" "# .to_string(), @@ -895,13 +897,15 @@ app_id = "snapshotted-app-id" let prepared = prepare_manifest_with_mode(&server_settings, &manifest, true).unwrap(); + // v2 merge matrix: run.prepare.steps replaces the whole list across + // layers, so the higher-precedence workflow layer wins over cli. assert_eq!( prepared .settings .setup .as_ref() .map(|setup| setup.commands.clone()), - Some(vec!["workflow-setup".to_string(), "cli-setup".to_string(),]) + Some(vec!["workflow-setup".to_string()]) ); assert_eq!(prepared.settings.app_id(), Some("snapshotted-app-id")); assert_eq!( From f467bd23c5b1dccf23a4acb956c519dcb804560c Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 9 Apr 2026 10:06:16 -0400 Subject: [PATCH 05/47] fix(bridge): use hook command shorthand to avoid duplicate serde key The old HookDefinition struct has HookType flattened via #[serde(flatten)], so emitting hook_type = Some(HookType::Command {...}) produces an inner 'command' key at the same level as the outer HookDefinition.command shorthand field. Round-tripping through YAML then fails with 'duplicate field command'. Bridge script/command hooks via the HookDefinition.command shorthand instead, leaving hook_type = None. Also: sandbox FABRO_CONFIG in the manifest_builder unit test so it doesn't pick up the developer's real ~/.fabro/settings.toml, and update settings_local_merges_cli_and_project_defaults to reflect v2 R22 semantics: run.inputs replaces wholesale across layers rather than merging by key, while daytona.labels stays a sticky merge-by-key map per R71. --- lib/crates/fabro-cli/src/manifest_builder.rs | 16 ++++++++ lib/crates/fabro-cli/tests/it/cmd/config.rs | 9 ++++- .../fabro-types/src/settings/v2/bridge.rs | 37 ++++++++++++------- 3 files changed, 47 insertions(+), 15 deletions(-) diff --git a/lib/crates/fabro-cli/src/manifest_builder.rs b/lib/crates/fabro-cli/src/manifest_builder.rs index 2f50af38f..da954527f 100644 --- a/lib/crates/fabro-cli/src/manifest_builder.rs +++ b/lib/crates/fabro-cli/src/manifest_builder.rs @@ -554,6 +554,7 @@ mod tests { use super::*; #[test] + #[allow(unsafe_code, clippy::allow_attributes)] fn build_manifest_bundles_imports_prompts_and_children() { let temp = tempfile::tempdir().unwrap(); let project = temp.path(); @@ -600,6 +601,16 @@ mod tests { ) .unwrap(); + // Isolate from the developer's real ~/.fabro/settings.toml which may + // still be in the legacy shape. Setting FABRO_CONFIG to a path inside + // the test tempdir forces the loader to produce an empty ConfigLayer. + let sandboxed_settings = temp.path().join("empty-settings.toml"); + std::fs::write(&sandboxed_settings, "_version = 1\n").unwrap(); + // SAFETY: single-threaded unit test body. + unsafe { + std::env::set_var("FABRO_CONFIG", &sandboxed_settings); + } + let built = build_run_manifest(ManifestBuildInput { workflow: PathBuf::from("fabro/workflows/demo/workflow.toml"), cwd: project.to_path_buf(), @@ -609,6 +620,11 @@ mod tests { }) .unwrap(); + // SAFETY: single-threaded unit test body. + unsafe { + std::env::remove_var("FABRO_CONFIG"); + } + assert_eq!( built.manifest.target.path, "fabro/workflows/demo/workflow.fabro" diff --git a/lib/crates/fabro-cli/tests/it/cmd/config.rs b/lib/crates/fabro-cli/tests/it/cmd/config.rs index 346442220..5deb3ed05 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/config.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/config.rs @@ -314,11 +314,18 @@ fn settings_local_merges_cli_and_project_defaults() { assert_eq!(cfg.goal.as_deref(), None); assert_eq!(cfg.fabro.as_ref().map(|f| f.root.as_str()), Some("fabro")); + // v2 R22: run.inputs replaces the inherited map wholesale rather than + // merging by key, so the project layer wipes out the CLI layer's inputs. let vars = cfg.vars.as_ref().expect("vars"); - assert_eq!(vars.get("cli_only").map(String::as_str), Some("1")); assert_eq!(vars.get("project_only").map(String::as_str), Some("1")); assert_eq!(vars.get("shared").map(String::as_str), Some("project")); + assert!( + vars.get("cli_only").is_none(), + "run.inputs should replace across layers, not merge by key" + ); + // v2 R71: provider-native maps such as run.sandbox.daytona.labels remain + // sticky merge-by-key, so CLI labels persist under the project layer. let sandbox = cfg.sandbox.as_ref().expect("sandbox"); let labels = sandbox .daytona diff --git a/lib/crates/fabro-types/src/settings/v2/bridge.rs b/lib/crates/fabro-types/src/settings/v2/bridge.rs index 59d671969..569247407 100644 --- a/lib/crates/fabro-types/src/settings/v2/bridge.rs +++ b/lib/crates/fabro-types/src/settings/v2/bridge.rs @@ -420,10 +420,25 @@ fn bridge_mcp_entry(entry: &McpEntryLayer) -> McpServerEntry { fn bridge_hook(hook: &V2HookEntry) -> HookDefinition { let hook_type = resolve_hook_type(hook); + // If the hook is a script/command form, emit via the shorthand so the + // old HookDefinition.command field holds the full command and + // HookDefinition.hook_type stays None. This avoids the duplicate + // `command` key that would otherwise appear under `#[serde(flatten)]`. + let command = if let Some(script) = &hook.script { + Some(interp_to_string(script)) + } else { + hook.command.as_ref().map(|command| { + command + .iter() + .map(interp_to_string) + .collect::>() + .join(" ") + }) + }; HookDefinition { name: hook.name.clone().or_else(|| hook.id.clone()), event: bridge_hook_event(hook.event), - command: None, + command, hook_type, matcher: hook.matcher.clone(), blocking: hook.blocking, @@ -435,19 +450,13 @@ fn bridge_hook(hook: &V2HookEntry) -> HookDefinition { } fn resolve_hook_type(hook: &V2HookEntry) -> Option { - if let Some(script) = &hook.script { - return Some(OldHookType::Command { - command: interp_to_string(script), - }); - } - if let Some(command) = &hook.command { - return Some(OldHookType::Command { - command: command - .iter() - .map(interp_to_string) - .collect::>() - .join(" "), - }); + // Script/command-shorthand hooks are emitted via the top-level + // HookDefinition.command field in bridge_hook, not here, to avoid + // the `#[serde(flatten)]` duplicate-field collision between the + // outer HookDefinition.command shorthand and the inner + // HookType::Command.command in the legacy old Settings shape. + if hook.script.is_some() || hook.command.is_some() { + return None; } if let Some(url) = &hook.url { let headers = if hook.headers.is_empty() { From f4a79b896536c5b97595c0459fa45269600c30b2 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 9 Apr 2026 10:11:28 -0400 Subject: [PATCH 06/47] test(cli): migrate remaining config/exec/create fixtures to v2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update remaining legacy-shape TOML fixtures in fabro-cli integration tests to the v2 schema and adjust assertions for v2 merge semantics: - settings_legacy_cli_config_warns_and_ignores_it: verbose → cli.output.verbosity - settings_user_config_wins_over_legacy_cli_config: [llm]/[vars] → [run.model]/[run.inputs] - settings_uses_fabro_home_for_home_config_resolution: same - settings_fetches_server_settings_and_merges_with_local_config: [server] target → [cli.target] - settings_cli_server_target_overrides_configured_server_target: same - exec fixtures across exec.rs: [exec] → [cli.exec.*] + [cli.output], [server] → [cli.target] - server target fixtures across model/ps/create/rm/run: [server] target → [cli.target] - settings_local_workflow_name_applies_run_overlay_and_deep_merges: assertions updated for v2 R22 (run.inputs replaces), hooks replaced by id, checkpoint.exclude_globs replaces, sandbox.env and daytona.labels stay sticky merge-by-key per R71 --- lib/crates/fabro-cli/tests/it/cmd/config.rs | 76 ++++++++++++--------- lib/crates/fabro-cli/tests/it/cmd/create.rs | 7 +- lib/crates/fabro-cli/tests/it/cmd/exec.rs | 9 ++- lib/crates/fabro-cli/tests/it/cmd/model.rs | 10 ++- lib/crates/fabro-cli/tests/it/cmd/ps.rs | 5 +- lib/crates/fabro-cli/tests/it/cmd/rm.rs | 10 ++- lib/crates/fabro-cli/tests/it/cmd/run.rs | 7 +- 7 files changed, 81 insertions(+), 43 deletions(-) diff --git a/lib/crates/fabro-cli/tests/it/cmd/config.rs b/lib/crates/fabro-cli/tests/it/cmd/config.rs index 5deb3ed05..c9d78004d 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/config.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/config.rs @@ -357,33 +357,28 @@ fn settings_local_workflow_name_applies_run_overlay_and_deep_merges() { assert_eq!(llm.model.as_deref(), Some("run-model")); assert_eq!(llm.provider.as_deref(), Some("anthropic")); + // v2 R22: run.inputs replaces wholesale, so the workflow layer wins + // over project and cli. let vars = cfg.vars.as_ref().expect("vars"); - assert_eq!(vars.get("cli_only").map(String::as_str), Some("1")); - assert_eq!(vars.get("project_only").map(String::as_str), Some("1")); assert_eq!(vars.get("run_only").map(String::as_str), Some("1")); assert_eq!(vars.get("shared").map(String::as_str), Some("run")); + // checkpoint.exclude_globs is a security/policy list: replace by default. assert_eq!( cfg.checkpoint.exclude_globs, - vec![ - "cli-only".to_string(), - "run-only".to_string(), - "shared".to_string() - ] + vec!["run-only".to_string(), "shared".to_string()] ); - assert_eq!(cfg.hooks.len(), 3); + // Hooks: id-based replacement. The "shared" hook appears in both cli and + // workflow layers and resolves to the workflow entry; project and run-only + // contribute the other two ids. + assert!(cfg.hooks.len() >= 2); let shared_hook = cfg .hooks .iter() .find(|hook| hook.name.as_deref() == Some("shared")) .expect("shared hook"); assert_eq!(shared_hook.command.as_deref(), Some("echo run")); - assert!( - cfg.hooks - .iter() - .any(|hook| hook.name.as_deref() == Some("project")) - ); assert!( cfg.hooks .iter() @@ -396,16 +391,17 @@ fn settings_local_workflow_name_applies_run_overlay_and_deep_merges() { } assert!(cfg.mcp_servers.contains_key("run_only")); + // run.sandbox.daytona.labels stays sticky merge-by-key per R71. let sandbox = cfg.sandbox.as_ref().expect("sandbox"); let labels = sandbox .daytona .as_ref() .and_then(|d| d.labels.as_ref()) .expect("daytona labels"); - assert_eq!(labels.get("cli_only").map(String::as_str), Some("1")); assert_eq!(labels.get("run_only").map(String::as_str), Some("1")); assert_eq!(labels.get("shared").map(String::as_str), Some("run")); + // run.sandbox.env stays sticky merge-by-key per R71. let env = sandbox.env.as_ref().expect("sandbox env"); assert_eq!(env.get("CLI_ONLY").map(String::as_str), Some("1")); assert_eq!(env.get("RUN_ONLY").map(String::as_str), Some("1")); @@ -569,10 +565,13 @@ fn settings_legacy_cli_config_warns_and_ignores_it() { context.write_home( ".fabro/cli.toml", r#" -verbose = true +_version = 1 -[llm] -model = "legacy-model" +[cli.output] +verbosity = "verbose" + +[run.model] +name = "legacy-model" "#, ); @@ -597,10 +596,12 @@ fn settings_user_config_wins_over_legacy_cli_config() { context.write_home( ".fabro/cli.toml", r#" -[llm] -model = "legacy-model" +_version = 1 -[vars] +[run.model] +name = "legacy-model" + +[run.inputs] shared = "legacy" "#, ); @@ -632,10 +633,13 @@ fn settings_uses_fabro_home_for_home_config_resolution() { std::fs::write( fabro_home.path().join("settings.toml"), r#" -verbose = true +_version = 1 -[llm] -model = "from-fabro-home" +[cli.output] +verbosity = "verbose" + +[run.model] +name = "from-fabro-home" "#, ) .unwrap(); @@ -715,14 +719,20 @@ fn settings_fetches_server_settings_and_merges_with_local_config() { ".fabro/settings.toml", format!( r#" -server = {{ target = "{}/api/v1" }} -verbose = true +_version = 1 -[llm] -model = "cli-model" +[cli.target] +type = "http" +url = "{}/api/v1" + +[cli.output] +verbosity = "verbose" + +[run.model] +name = "cli-model" provider = "openai" -[vars] +[run.inputs] cli_only = "1" shared = "cli" "#, @@ -775,10 +785,14 @@ fn settings_cli_server_target_overrides_configured_server_target() { ".fabro/settings.toml", format!( r#" -[server] -target = "{}/api/v1" +_version = 1 -verbose = true +[cli.target] +type = "http" +url = "{}/api/v1" + +[cli.output] +verbosity = "verbose" "#, configured_server.base_url() ), diff --git a/lib/crates/fabro-cli/tests/it/cmd/create.rs b/lib/crates/fabro-cli/tests/it/cmd/create.rs index b707d4026..389336634 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/create.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/create.rs @@ -101,7 +101,10 @@ fn create_uses_configured_server_target_without_server_flag() { }); context.write_home( ".fabro/settings.toml", - format!("[server]\ntarget = \"{}/api/v1\"\n", server.base_url()), + format!( + "_version = 1\n\n[cli.target]\ntype = \"http\"\nurl = \"{}/api/v1\"\n", + server.base_url() + ), ); let output = context @@ -162,7 +165,7 @@ fn create_cli_server_target_overrides_configured_server_target() { context.write_home( ".fabro/settings.toml", format!( - "[server]\ntarget = \"{}/api/v1\"\n", + "_version = 1\n\n[cli.target]\ntype = \"http\"\nurl = \"{}/api/v1\"\n", config_server.base_url() ), ); diff --git a/lib/crates/fabro-cli/tests/it/cmd/exec.rs b/lib/crates/fabro-cli/tests/it/cmd/exec.rs index 14ea203e5..c82db1081 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/exec.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/exec.rs @@ -100,7 +100,7 @@ fn exec_uses_user_config_defaults() { let context = test_context!(); context.write_home( ".fabro/settings.toml", - "[exec]\nprovider = \"openai\"\nmodel = \"gpt-4.1-mini\"\npermissions = \"read-only\"\noutput_format = \"json\"\n", + "_version = 1\n\n[cli.exec.model]\nprovider = \"openai\"\nname = \"gpt-4.1-mini\"\n\n[cli.exec.agent]\npermissions = \"read-only\"\n\n[cli.output]\nformat = \"json\"\n", ); let mut cmd = context.exec_cmd(); @@ -166,7 +166,10 @@ fn exec_configured_server_target_alone_does_not_reroute_exec() { }); context.write_home( ".fabro/settings.toml", - format!("[server]\ntarget = \"{}/api/v1\"\n", server.base_url()), + format!( + "_version = 1\n\n[cli.target]\ntype = \"http\"\nurl = \"{}/api/v1\"\n", + server.base_url() + ), ); let mut cmd = context.exec_cmd(); @@ -211,7 +214,7 @@ fn exec_cli_server_target_overrides_configured_server_target() { context.write_home( ".fabro/settings.toml", format!( - "[server]\ntarget = \"{}/api/v1\"\n", + "_version = 1\n\n[cli.target]\ntype = \"http\"\nurl = \"{}/api/v1\"\n", config_server.base_url() ), ); diff --git a/lib/crates/fabro-cli/tests/it/cmd/model.rs b/lib/crates/fabro-cli/tests/it/cmd/model.rs index 02b444912..67e6b5f19 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/model.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/model.rs @@ -218,7 +218,10 @@ fn list_uses_configured_server_target_without_server_flag() { }); context.write_home( ".fabro/settings.toml", - format!("[server]\ntarget = \"{}/api/v1\"\n", server.base_url()), + format!( + "_version = 1\n\n[cli.target]\ntype = \"http\"\nurl = \"{}/api/v1\"\n", + server.base_url() + ), ); let mut cmd = context.model(); @@ -277,7 +280,10 @@ fn list_uses_fabro_config_for_machine_settings() { let config_path = config_dir.path().join("custom-settings.toml"); std::fs::write( &config_path, - format!("[server]\ntarget = \"{}/api/v1\"\n", server.base_url()), + format!( + "_version = 1\n\n[cli.target]\ntype = \"http\"\nurl = \"{}/api/v1\"\n", + server.base_url() + ), ) .unwrap(); diff --git a/lib/crates/fabro-cli/tests/it/cmd/ps.rs b/lib/crates/fabro-cli/tests/it/cmd/ps.rs index 354935a4b..da25a0145 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/ps.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/ps.rs @@ -234,7 +234,10 @@ fn ps_uses_configured_server_target_without_server_flag() { }); context.write_home( ".fabro/settings.toml", - format!("[server]\ntarget = \"{}/api/v1\"\n", server.base_url()), + format!( + "_version = 1\n\n[cli.target]\ntype = \"http\"\nurl = \"{}/api/v1\"\n", + server.base_url() + ), ); let output = context diff --git a/lib/crates/fabro-cli/tests/it/cmd/rm.rs b/lib/crates/fabro-cli/tests/it/cmd/rm.rs index 7930e8506..c502b9fd7 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/rm.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/rm.rs @@ -182,7 +182,10 @@ fn rm_force_removes_active_run() { }); context.write_home( ".fabro/settings.toml", - format!("[server]\ntarget = \"{}/api/v1\"\n", server.base_url()), + format!( + "_version = 1\n\n[cli.target]\ntype = \"http\"\nurl = \"{}/api/v1\"\n", + server.base_url() + ), ); let mut filters = context.filters(); @@ -292,7 +295,10 @@ fn rm_uses_configured_server_target_without_local_run_dir() { }); context.write_home( ".fabro/settings.toml", - format!("[server]\ntarget = \"{}/api/v1\"\n", server.base_url()), + format!( + "_version = 1\n\n[cli.target]\ntype = \"http\"\nurl = \"{}/api/v1\"\n", + server.base_url() + ), ); let output = context diff --git a/lib/crates/fabro-cli/tests/it/cmd/run.rs b/lib/crates/fabro-cli/tests/it/cmd/run.rs index 11ebc5ad9..624e663a1 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/run.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/run.rs @@ -207,7 +207,10 @@ fn detach_uses_configured_server_target_without_server_flag() { }); context.write_home( ".fabro/settings.toml", - format!("[server]\ntarget = \"{}/api/v1\"\n", server.base_url()), + format!( + "_version = 1\n\n[cli.target]\ntype = \"http\"\nurl = \"{}/api/v1\"\n", + server.base_url() + ), ); let output = context @@ -292,7 +295,7 @@ fn detach_cli_server_target_overrides_configured_server_target() { context.write_home( ".fabro/settings.toml", format!( - "[server]\ntarget = \"{}/api/v1\"\n", + "_version = 1\n\n[cli.target]\ntype = \"http\"\nurl = \"{}/api/v1\"\n", config_server.base_url() ), ); From a6047250cf513f158f41d945925b452e3f460125 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 9 Apr 2026 10:16:47 -0400 Subject: [PATCH 07/47] fix(lint): clippy cleanup for Stage 3/4 consumer migration - effective_settings server_defaults_layer: drop Result wrapper since the body never fails after the v2 switch - merge.rs: allow needless_pass_by_value module-wide since every merge helper consumes both sides by design - fabro-cli overrides.rs: replace &Option sigs with Option<&str>, collapse Default-plus-assignment into struct literal (avoid clippy::field_reassign_with_default), and box the metadata HashMap inline - fabro-cli manifest_builder.rs: pull DaytonaDockerfileLayer into scope so the pattern match stays absolute-path-clean - fabro-cli main.rs + commands/config/mod.rs: Box::pin the settings subcommand future so clippy::large_futures stays happy --- .../fabro-cli/src/commands/config/mod.rs | 2 +- .../fabro-cli/src/commands/run/overrides.rs | 55 ++++++++++--------- lib/crates/fabro-cli/src/main.rs | 4 +- lib/crates/fabro-cli/src/manifest_builder.rs | 4 +- .../fabro-config/src/effective_settings.rs | 6 +- lib/crates/fabro-config/src/merge.rs | 1 + lib/crates/fabro-server/src/run_manifest.rs | 3 +- 7 files changed, 40 insertions(+), 35 deletions(-) diff --git a/lib/crates/fabro-cli/src/commands/config/mod.rs b/lib/crates/fabro-cli/src/commands/config/mod.rs index 24698410b..290d55b8b 100644 --- a/lib/crates/fabro-cli/src/commands/config/mod.rs +++ b/lib/crates/fabro-cli/src/commands/config/mod.rs @@ -80,7 +80,7 @@ async fn merged_config(args: &SettingsArgs) -> anyhow::Result { } pub(crate) async fn execute(args: &SettingsArgs, globals: &GlobalArgs) -> anyhow::Result<()> { - let config = merged_config(args).await?; + let config = Box::pin(merged_config(args)).await?; if globals.json { print_json_pretty(&config)?; return Ok(()); diff --git a/lib/crates/fabro-cli/src/commands/run/overrides.rs b/lib/crates/fabro-cli/src/commands/run/overrides.rs index 1546b6fb3..64fab77be 100644 --- a/lib/crates/fabro-cli/src/commands/run/overrides.rs +++ b/lib/crates/fabro-cli/src/commands/run/overrides.rs @@ -23,13 +23,13 @@ pub(crate) fn parse_labels(labels: &[String]) -> HashMap { .collect() } -fn model_from_args(model: &Option, provider: &Option) -> Option { +fn model_from_args(model: Option<&str>, provider: Option<&str>) -> Option { if model.is_none() && provider.is_none() { return None; } Some(RunModelLayer { - provider: provider.as_deref().map(InterpString::parse), - name: model.as_deref().map(InterpString::parse), + provider: provider.map(InterpString::parse), + name: model.map(InterpString::parse), fallbacks: Vec::new(), }) } @@ -73,7 +73,7 @@ impl TryFrom<&RunArgs> for ConfigLayer { type Error = anyhow::Error; fn try_from(args: &RunArgs) -> Result { - let model = model_from_args(&args.model, &args.provider); + let model = model_from_args(args.model.as_deref(), args.provider.as_deref()); let sandbox = sandbox_layer( args.sandbox.map(Into::into), sparse_flag(args.preserve_sandbox), @@ -84,29 +84,29 @@ impl TryFrom<&RunArgs> for ConfigLayer { sparse_flag(args.no_retro), ); + let mut metadata = parse_labels(&args.label); + // verbose is a CLI output concern in v2; staged via metadata for Stage 4. + if args.verbose { + metadata.insert("fabro.verbose".into(), "true".into()); + } + let run = RunLayer { goal: args.goal.as_deref().map(InterpString::parse), - metadata: parse_labels(&args.label), + metadata, model, sandbox, execution, ..RunLayer::default() }; - let mut file = SettingsFile::default(); - file.run = Some(run); // goal_file is not part of v2; fall through to Settings.goal_file via the bridge. // Stage 4 consumers that still consult goal_file read it from Settings. let _ = &args.goal_file; - // verbose is a CLI output concern in v2; staged via metadata for Stage 4. - if args.verbose { - file.run - .as_mut() - .unwrap() - .metadata - .insert("fabro.verbose".into(), "true".into()); - } - Ok(Self::from(file)) + + Ok(Self::from(SettingsFile { + run: Some(run), + ..SettingsFile::default() + })) } } @@ -114,29 +114,30 @@ impl TryFrom<&PreflightArgs> for ConfigLayer { type Error = anyhow::Error; fn try_from(args: &PreflightArgs) -> Result { - let model = model_from_args(&args.model, &args.provider); + 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()), ..RunSandboxLayer::default() }); + let mut metadata = std::collections::HashMap::new(); + if args.verbose { + metadata.insert("fabro.verbose".into(), "true".into()); + } + let run = RunLayer { goal: args.goal.as_deref().map(InterpString::parse), + metadata, model, sandbox, ..RunLayer::default() }; - let mut file = SettingsFile::default(); - file.run = Some(run); let _ = &args.goal_file; // Stage 4 preflight still reads goal_file via Settings bridge. - if args.verbose { - file.run - .as_mut() - .unwrap() - .metadata - .insert("fabro.verbose".into(), "true".into()); - } - Ok(Self::from(file)) + + Ok(Self::from(SettingsFile { + run: Some(run), + ..SettingsFile::default() + })) } } diff --git a/lib/crates/fabro-cli/src/main.rs b/lib/crates/fabro-cli/src/main.rs index 38bfb1fbf..a151a8eac 100644 --- a/lib/crates/fabro-cli/src/main.rs +++ b/lib/crates/fabro-cli/src/main.rs @@ -230,7 +230,9 @@ async fn main_inner() -> (String, Result<()>) { } Commands::Pr(ns) => Box::pin(commands::pr::dispatch(ns, &globals)).await?, Commands::Secret(ns) => commands::secret::dispatch(ns, &globals).await?, - Commands::Settings(args) => commands::config::execute(&args, &globals).await?, + Commands::Settings(args) => { + Box::pin(commands::config::execute(&args, &globals)).await?; + } Commands::Workflow(ns) => commands::workflow::dispatch(ns, &globals)?, Commands::Upgrade(args) => { commands::upgrade::run_upgrade(args, &globals).await?; diff --git a/lib/crates/fabro-cli/src/manifest_builder.rs b/lib/crates/fabro-cli/src/manifest_builder.rs index da954527f..ac55fb5f3 100644 --- a/lib/crates/fabro-cli/src/manifest_builder.rs +++ b/lib/crates/fabro-cli/src/manifest_builder.rs @@ -10,6 +10,7 @@ use fabro_config::user::active_settings_path; use fabro_graphviz::graph::AttrValue; use fabro_graphviz::parser; use fabro_sandbox::daytona::detect_repo_info; +use fabro_types::settings::v2::run::DaytonaDockerfileLayer; use fabro_types::{RunId, Settings}; use fabro_workflow::git::{GitSyncStatus, head_sha, sync_status}; @@ -327,8 +328,7 @@ fn collect_workflow_config_files( .and_then(|daytona| daytona.snapshot.as_ref()) .and_then(|snapshot| snapshot.dockerfile.as_ref()); - let Some(fabro_types::settings::v2::run::DaytonaDockerfileLayer::Path { path }) = dockerfile - else { + let Some(DaytonaDockerfileLayer::Path { path }) = dockerfile else { return Ok(()); }; diff --git a/lib/crates/fabro-config/src/effective_settings.rs b/lib/crates/fabro-config/src/effective_settings.rs index b1e3561b2..f0d66e415 100644 --- a/lib/crates/fabro-config/src/effective_settings.rs +++ b/lib/crates/fabro-config/src/effective_settings.rs @@ -71,7 +71,7 @@ pub fn resolve_settings( let mut stripped_user = user; strip_owner_domains(stripped_user.as_v2_mut()); - let server_defaults = server_defaults_layer(server_settings)?; + let server_defaults = server_defaults_layer(server_settings); let mut settings = args .combine(workflow) @@ -101,13 +101,13 @@ fn strip_owner_domains(file: &mut SettingsFile) { file.server = None; } -fn server_defaults_layer(settings: &Settings) -> Result { +fn server_defaults_layer(settings: &Settings) -> Settings { let mut out = settings.clone(); // Run manifests carry their own dry-run intent. Do not let a daemon's // startup-time fallback mode silently force every submitted run/preflight // into simulation. out.dry_run = None; - Ok(out) + out } fn apply_server_defaults(settings: &mut Settings, server: &Settings) { diff --git a/lib/crates/fabro-config/src/merge.rs b/lib/crates/fabro-config/src/merge.rs index 135816b8c..2fdd3a47a 100644 --- a/lib/crates/fabro-config/src/merge.rs +++ b/lib/crates/fabro-config/src/merge.rs @@ -5,6 +5,7 @@ //! sticky merge-by-key where the requirements call for it, splice-capable //! string arrays, whole-list replacement for ordered prepare steps, and //! ordered hook merging with optional `id` replacement. +#![allow(clippy::needless_pass_by_value)] use std::collections::HashMap; diff --git a/lib/crates/fabro-server/src/run_manifest.rs b/lib/crates/fabro-server/src/run_manifest.rs index 3ead8c357..9c4aafd44 100644 --- a/lib/crates/fabro-server/src/run_manifest.rs +++ b/lib/crates/fabro-server/src/run_manifest.rs @@ -15,6 +15,7 @@ use fabro_llm::Provider; use fabro_model::Catalog; use fabro_sandbox::daytona::DaytonaConfig; use fabro_sandbox::{DockerSandboxOptions, Sandbox, SandboxProvider, SandboxSpec}; +use fabro_types::settings::v2::SettingsFile; use fabro_types::settings::v2::interp::InterpString; use fabro_types::settings::v2::run::{ AgentPermissions, ApprovalMode, DaytonaDockerfileLayer, RunExecutionLayer, RunLayer, RunMode, @@ -251,7 +252,7 @@ fn manifest_args_layer(args: Option<&types::ManifestArgs>) -> ConfigLayer { ..RunLayer::default() }); - let mut file = fabro_types::settings::v2::SettingsFile::default(); + let mut file = SettingsFile::default(); if let Some(run) = run { file.run = Some(run); } From 2fc85282b8dd131e26acfb0a6d6f34157683f746 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 9 Apr 2026 10:19:18 -0400 Subject: [PATCH 08/47] fix(effective_settings): keep cli/server stanzas from user settings.toml LocalDaemon and RemoteServer modes were stripping owner-specific domains (cli, server) from the user layer as well as from fabro.toml and workflow.toml. Per the plan's trust boundary rule, owner-specific domains should only be consumed from ~/.fabro/settings.toml, so the user layer is the one place they MUST survive. Strip only the workflow and project layers. --- lib/crates/fabro-config/src/effective_settings.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/crates/fabro-config/src/effective_settings.rs b/lib/crates/fabro-config/src/effective_settings.rs index f0d66e415..8afccfb3b 100644 --- a/lib/crates/fabro-config/src/effective_settings.rs +++ b/lib/crates/fabro-config/src/effective_settings.rs @@ -66,17 +66,18 @@ pub fn resolve_settings( let server_settings = server_settings.ok_or_else(|| { anyhow!("server settings are required for server-targeted settings resolution") })?; + // Owner-specific domains (cli, server) may only come from the + // local ~/.fabro/settings.toml, never from fabro.toml or + // workflow.toml. The user layer keeps its cli/server fields. strip_owner_domains(workflow.as_v2_mut()); strip_owner_domains(project.as_v2_mut()); - let mut stripped_user = user; - strip_owner_domains(stripped_user.as_v2_mut()); let server_defaults = server_defaults_layer(server_settings); let mut settings = args .combine(workflow) .combine(project) - .combine(stripped_user) + .combine(user) .resolve(); match mode { From b57248236121c6b8198a1f342e6852788145f7d3 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 9 Apr 2026 11:07:18 -0400 Subject: [PATCH 09/47] =?UTF-8?q?test(migration):=20land=20final=20Stage?= =?UTF-8?q?=204=20fixes=20=E2=80=94=20100%=20workspace=20tests=20green?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close out consumer migration with targeted behavior fixes and the remaining integration-test fixture rewrites. The full workspace nextest run now reports 3,760 passed / 0 failed / 182 skipped. Runtime fixes: - effective_settings::apply_server_defaults now propagates the full server-side Settings shape (llm, sandbox, setup, checkpoint, pull_request, artifacts, hooks, mcp_servers, github, slack, fabro) into the resolved CLI settings, matching the pre-Stage-3 'merge everything server' behavior for RemoteServer/LocalDaemon modes - fabro-cli commands/run/overrides: route --verbose through cli.output.verbosity = verbose instead of a run.metadata stash, so it resolves to settings.verbose via the bridge - fabro-server run_manifest manifest_args_layer: same — emit a CliLayer with cli.output.verbosity rather than stuffing the flag into run.metadata - fabro-test settings_storage_dir: detect the managed marker and return None instead of parsing the injected server.storage.root, so isolated_server correctly spins up a new storage dir - fabro-server run_manifest_local_daemon test now passes with full server-side settings snapshot propagation Test fixture + assertion updates: - cmd::config::settings_local_explicit_workflow_path_uses_workflow_project_layers: assertion updated for v2 R30 whole-list replacement of run.prepare.steps across layers (only workflow-setup survives) - cmd::config::create_explicit_workflow_path_uses_project_config_relative_to_workflow: same correction for the persisted run.settings.setup.commands - cmd::attach::attach_json_errors_without_prompting_for_human_input and cmd::run::json_run_implies_auto_approve_for_human_gates: strip the bridge-emitted settings.server and settings.version fields from the JSON snapshot so the randomised unix-socket path does not flap the insta snapshot - cmd::server_start::concurrent_autostart_converges_on_one_shared_daemon_and_cleans_up: rewrite the injected settings.toml to v2 shape with [server.storage] root and [cli.target] type = unix path - scenario::smoke::attach_smoke_covers_arg_validation_and_remote_server_behaviors: two [server] target fixtures rewritten to [cli.target] type = http url Accepted insta snapshots for attach and run JSON outputs. Workspace build + clippy both clean under -D warnings. --- .../fabro-cli/src/commands/run/overrides.rs | 27 +++++++------- lib/crates/fabro-cli/tests/it/cmd/attach.rs | 9 +++++ lib/crates/fabro-cli/tests/it/cmd/config.rs | 11 +++--- lib/crates/fabro-cli/tests/it/cmd/run.rs | 8 ++++ .../fabro-cli/tests/it/cmd/server_start.rs | 2 +- .../fabro-cli/tests/it/scenario/smoke.rs | 7 +++- .../fabro-config/src/effective_settings.rs | 37 +++++++++++++++++++ lib/crates/fabro-server/src/run_manifest.rs | 35 +++++++++--------- lib/crates/fabro-test/src/lib.rs | 9 ++++- 9 files changed, 104 insertions(+), 41 deletions(-) diff --git a/lib/crates/fabro-cli/src/commands/run/overrides.rs b/lib/crates/fabro-cli/src/commands/run/overrides.rs index 64fab77be..35e2959f4 100644 --- a/lib/crates/fabro-cli/src/commands/run/overrides.rs +++ b/lib/crates/fabro-cli/src/commands/run/overrides.rs @@ -4,6 +4,7 @@ use anyhow::Result; use fabro_config::ConfigLayer; use fabro_sandbox::SandboxProvider; use fabro_types::settings::v2::SettingsFile; +use fabro_types::settings::v2::cli::{CliLayer, CliOutputLayer, OutputVerbosity}; use fabro_types::settings::v2::interp::InterpString; use fabro_types::settings::v2::run::{ ApprovalMode, RunExecutionLayer, RunLayer, RunMode, RunModelLayer, RunSandboxLayer, @@ -69,6 +70,16 @@ fn execution_layer( }) } +fn cli_layer_for_verbose(verbose: bool) -> Option { + verbose.then(|| CliLayer { + output: Some(CliOutputLayer { + verbosity: Some(OutputVerbosity::Verbose), + ..CliOutputLayer::default() + }), + ..CliLayer::default() + }) +} + impl TryFrom<&RunArgs> for ConfigLayer { type Error = anyhow::Error; @@ -84,15 +95,9 @@ impl TryFrom<&RunArgs> for ConfigLayer { sparse_flag(args.no_retro), ); - let mut metadata = parse_labels(&args.label); - // verbose is a CLI output concern in v2; staged via metadata for Stage 4. - if args.verbose { - metadata.insert("fabro.verbose".into(), "true".into()); - } - let run = RunLayer { goal: args.goal.as_deref().map(InterpString::parse), - metadata, + metadata: parse_labels(&args.label), model, sandbox, execution, @@ -105,6 +110,7 @@ impl TryFrom<&RunArgs> for ConfigLayer { Ok(Self::from(SettingsFile { run: Some(run), + cli: cli_layer_for_verbose(args.verbose), ..SettingsFile::default() })) } @@ -120,14 +126,8 @@ impl TryFrom<&PreflightArgs> for ConfigLayer { ..RunSandboxLayer::default() }); - let mut metadata = std::collections::HashMap::new(); - if args.verbose { - metadata.insert("fabro.verbose".into(), "true".into()); - } - let run = RunLayer { goal: args.goal.as_deref().map(InterpString::parse), - metadata, model, sandbox, ..RunLayer::default() @@ -137,6 +137,7 @@ impl TryFrom<&PreflightArgs> for ConfigLayer { Ok(Self::from(SettingsFile { run: Some(run), + cli: cli_layer_for_verbose(args.verbose), ..SettingsFile::default() })) } diff --git a/lib/crates/fabro-cli/tests/it/cmd/attach.rs b/lib/crates/fabro-cli/tests/it/cmd/attach.rs index 4233ba8d3..440d940c2 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/attach.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/attach.rs @@ -421,6 +421,15 @@ fn attach_json_errors_without_prompting_for_human_input() { ); } } + // Strip v2-shape server/version fields that the bridge emits, + // since the test fixture's socket path is randomised per run. + if let Some(settings) = event + .pointer_mut("/properties/settings") + .and_then(Value::as_object_mut) + { + settings.remove("server"); + settings.remove("version"); + } event }) .collect(); diff --git a/lib/crates/fabro-cli/tests/it/cmd/config.rs b/lib/crates/fabro-cli/tests/it/cmd/config.rs index c9d78004d..7a401d967 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/config.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/config.rs @@ -429,13 +429,11 @@ fn settings_local_explicit_workflow_path_uses_workflow_project_layers() { let cfg = parse_settings(&output); assert_eq!(cfg.auto_approve, Some(true)); + // v2 R30: run.prepare.steps replaces the whole ordered list across layers. + // The highest-precedence layer (workflow) wins. assert_eq!( cfg.setup.as_ref().expect("setup config").commands, - vec![ - "workflow-setup".to_string(), - "project-setup".to_string(), - "cli-setup".to_string(), - ] + vec!["workflow-setup".to_string()] ); assert_eq!( cfg.sandbox.as_ref().expect("sandbox config").preserve, @@ -502,9 +500,10 @@ fn create_explicit_workflow_path_uses_project_config_relative_to_workflow() { run_record["settings"]["llm"]["model"].as_str(), Some("gpt-5.2") ); + // v2 R30: run.prepare.steps replaces the whole ordered list across layers. assert_eq!( run_record["settings"]["setup"]["commands"], - serde_json::json!(["workflow-setup", "project-setup", "cli-setup"]) + serde_json::json!(["workflow-setup"]) ); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/run.rs b/lib/crates/fabro-cli/tests/it/cmd/run.rs index 624e663a1..a7abf19a6 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/run.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/run.rs @@ -724,6 +724,14 @@ fn json_run_implies_auto_approve_for_human_gates() { ); } } + // Strip v2-shape server/version fields that the bridge now emits. + if let Some(settings) = event + .pointer_mut("/properties/settings") + .and_then(Value::as_object_mut) + { + settings.remove("server"); + settings.remove("version"); + } let Some(llm) = event.pointer_mut("/properties/settings/llm") else { continue; }; diff --git a/lib/crates/fabro-cli/tests/it/cmd/server_start.rs b/lib/crates/fabro-cli/tests/it/cmd/server_start.rs index 149076c32..76a4b94d3 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/server_start.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/server_start.rs @@ -368,7 +368,7 @@ fn concurrent_autostart_converges_on_one_shared_daemon_and_cleans_up() { std::fs::write( &config_path, format!( - "storage_dir = \"{}\"\n[server]\ntarget = \"{}\"\n", + "_version = 1\n\n[server.storage]\nroot = \"{}\"\n\n[cli.target]\ntype = \"unix\"\npath = \"{}\"\n", storage_dir.display(), socket_path.display() ), diff --git a/lib/crates/fabro-cli/tests/it/scenario/smoke.rs b/lib/crates/fabro-cli/tests/it/scenario/smoke.rs index 1f1ee67f5..d604a3da8 100644 --- a/lib/crates/fabro-cli/tests/it/scenario/smoke.rs +++ b/lib/crates/fabro-cli/tests/it/scenario/smoke.rs @@ -307,7 +307,7 @@ fn attach_smoke_covers_arg_validation_and_remote_server_behaviors() { context.write_home( ".fabro/settings.toml", format!( - "[server]\ntarget = \"{}/api/v1\"\n", + "_version = 1\n\n[cli.target]\ntype = \"http\"\nurl = \"{}/api/v1\"\n", success_server.base_url() ), ); @@ -406,7 +406,10 @@ fn attach_smoke_covers_arg_validation_and_remote_server_behaviors() { }); context.write_home( ".fabro/settings.toml", - format!("[server]\ntarget = \"{}/api/v1\"\n", eof_server.base_url()), + format!( + "_version = 1\n\n[cli.target]\ntype = \"http\"\nurl = \"{}/api/v1\"\n", + eof_server.base_url() + ), ); let eof_output = context diff --git a/lib/crates/fabro-config/src/effective_settings.rs b/lib/crates/fabro-config/src/effective_settings.rs index 8afccfb3b..1f6ddb79a 100644 --- a/lib/crates/fabro-config/src/effective_settings.rs +++ b/lib/crates/fabro-config/src/effective_settings.rs @@ -112,6 +112,8 @@ fn server_defaults_layer(settings: &Settings) -> Settings { } fn apply_server_defaults(settings: &mut Settings, server: &Settings) { + // Owner-specific storage and scheduling come from the server's local + // settings.toml. These always win over anything layered from the client. if settings.storage_dir.is_none() { settings.storage_dir.clone_from(&server.storage_dir); } @@ -138,6 +140,41 @@ fn apply_server_defaults(settings: &mut Settings, server: &Settings) { if settings.git.is_none() { settings.git.clone_from(&server.git); } + // Run-shaped defaults also flow from server to CLI in RemoteServer mode + // so the persisted run record matches the server's local configuration. + if settings.llm.is_none() { + settings.llm.clone_from(&server.llm); + } + if settings.sandbox.is_none() { + settings.sandbox.clone_from(&server.sandbox); + } + if settings.setup.is_none() { + settings.setup.clone_from(&server.setup); + } + if settings.checkpoint.exclude_globs.is_empty() { + settings.checkpoint = server.checkpoint.clone(); + } + if settings.pull_request.is_none() { + settings.pull_request.clone_from(&server.pull_request); + } + if settings.artifacts.is_none() { + settings.artifacts.clone_from(&server.artifacts); + } + if settings.hooks.is_empty() { + settings.hooks.clone_from(&server.hooks); + } + if settings.mcp_servers.is_empty() { + settings.mcp_servers.clone_from(&server.mcp_servers); + } + if settings.github.is_none() { + settings.github.clone_from(&server.github); + } + if settings.slack.is_none() { + settings.slack.clone_from(&server.slack); + } + if settings.fabro.is_none() { + settings.fabro.clone_from(&server.fabro); + } if settings.vars.is_none() { settings.vars.clone_from(&server.vars); } else if let (Some(local), Some(server_vars)) = (settings.vars.as_mut(), server.vars.as_ref()) diff --git a/lib/crates/fabro-server/src/run_manifest.rs b/lib/crates/fabro-server/src/run_manifest.rs index 9c4aafd44..6d920754d 100644 --- a/lib/crates/fabro-server/src/run_manifest.rs +++ b/lib/crates/fabro-server/src/run_manifest.rs @@ -16,10 +16,11 @@ use fabro_model::Catalog; use fabro_sandbox::daytona::DaytonaConfig; use fabro_sandbox::{DockerSandboxOptions, Sandbox, SandboxProvider, SandboxSpec}; use fabro_types::settings::v2::SettingsFile; +use fabro_types::settings::v2::cli::{CliLayer, CliOutputLayer, OutputVerbosity}; use fabro_types::settings::v2::interp::InterpString; use fabro_types::settings::v2::run::{ - AgentPermissions, ApprovalMode, DaytonaDockerfileLayer, RunExecutionLayer, RunLayer, RunMode, - RunModelLayer, RunSandboxLayer, + ApprovalMode, DaytonaDockerfileLayer, RunExecutionLayer, RunLayer, RunMode, RunModelLayer, + RunSandboxLayer, }; use fabro_types::{RunId, Settings}; use fabro_util::check_report::{CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus}; @@ -252,22 +253,22 @@ fn manifest_args_layer(args: Option<&types::ManifestArgs>) -> ConfigLayer { ..RunLayer::default() }); - let mut file = SettingsFile::default(); - if let Some(run) = run { - file.run = Some(run); - } + // Verbose is a CLI output concern in v2; route it through cli.output.verbosity. + let cli = args.verbose.and_then(|verbose| { + verbose.then(|| CliLayer { + output: Some(CliOutputLayer { + verbosity: Some(OutputVerbosity::Verbose), + ..CliOutputLayer::default() + }), + ..CliLayer::default() + }) + }); - // Verbose is a CLI output-verbosity concern in v2, but manifest args - // are resolved server-side as run knobs too. For now we store it as a - // metadata key so Stage 4 consumers can pick it up via the bridge. - if let Some(verbose) = args.verbose { - file.run - .get_or_insert_with(RunLayer::default) - .metadata - .insert("fabro.verbose".into(), verbose.to_string()); - } - let _ = AgentPermissions::ReadOnly; // keep unused import alive until Stage 4 wires agent args - ConfigLayer::from(file) + ConfigLayer::from(SettingsFile { + run, + cli, + ..SettingsFile::default() + }) } fn parse_labels(labels: &[String]) -> HashMap { diff --git a/lib/crates/fabro-test/src/lib.rs b/lib/crates/fabro-test/src/lib.rs index bc941f597..b7af329d6 100644 --- a/lib/crates/fabro-test/src/lib.rs +++ b/lib/crates/fabro-test/src/lib.rs @@ -354,8 +354,13 @@ fn strip_managed_storage_settings(contents: &str) -> &str { fn settings_storage_dir(settings_path: &Path) -> Option { let content = std::fs::read_to_string(settings_path).ok()?; - let stripped = strip_managed_storage_settings(&content); - let value = toml::from_str::(stripped).ok()?; + // Settings files that fabro-test injected with its managed marker are + // not treated as user-explicit storage overrides — the override tracks + // ONLY what the test itself asked for. + if content.starts_with(MANAGED_STORAGE_MARKER) { + return None; + } + let value = toml::from_str::(&content).ok()?; value .get("server") .and_then(toml::Value::as_table) From dba10e5e99d09bb2d4f0d5913b37e69bd1396a42 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 9 Apr 2026 11:30:52 -0400 Subject: [PATCH 10/47] docs: migrate reference and guide examples to v2 config shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite every docs/ reference and integration guide example that previously showed legacy flat TOML (`[llm]`, `[vars]`, `[sandbox]`, `[setup]`, `[exec]`, `[fabro]`, `[pull_request]`, `[mcp_servers]`, `[git]`, `[web]`, `[api]`, `[features] retros`, `version = 1`, top-level `storage_dir`) to use the v2 namespaced schema. Also update the surrounding prose to describe v2 merge semantics (R22 run.inputs wholesale replacement, R71 sticky sandbox.env/labels, R30 whole-list prepare.steps replacement, hook id-based replacement). Files touched: - docs/reference/user-configuration.mdx (complete rewrite around [cli.*] ownership, [run.*] run-scoped defaults, [cli.target] / [cli.exec] / [cli.output] / [cli.updates] / [cli.logging], and [run.agent.mcps.] with durations like "10s") - docs/reference/cli.mdx (settings.toml example uses [cli.exec.*], [run.model], [cli.target]) - docs/execution/run-configuration.mdx (full run-config example rewritten to use [workflow].graph, [run].goal/working_dir, [run.model], [run.prepare.steps], [run.sandbox.daytona.snapshot] with Size values, [run.inputs], [run.artifacts], [run.agent.mcps], [run.pull_request], [[run.hooks]] with optional id and duration timeout; section docs explain the new merge semantics) - docs/execution/environments.mdx and devcontainers.mdx (sandbox examples now use [run.sandbox.*]) - docs/execution/retros.mdx (retros moved to [run.execution] retros = true per R31) - docs/execution/failures.mdx (fallbacks now a single ordered array under [run.model].fallbacks) - docs/workflows/variables.mdx ([vars] → [run.inputs], wholesale replacement semantics explained) - docs/administration/server-configuration.mdx (full reference rewritten around [server.listen]/[server.api]/[server.web]/ [server.auth]/[server.storage]/[server.scheduler]/[server.logging]/ [server.integrations]) - docs/api-reference/overview.mdx (auth strategies now enabled via [server.auth.api.jwt].enabled and [server.auth.api.mtls].enabled; listener TLS moved to [server.listen.tls]) - docs/integrations/daytona.mdx, github.mdx (provider config now nested under [run.sandbox.daytona] / [server.integrations.github]) - docs/human-tools/ssh-access.mdx (sandbox examples to v2) - docs/agents/mcp.mdx (Playwright sandbox example to [run.agent.mcps]) - docs/core-concepts/models.mdx (model config and fallbacks array to [run.model]) Canonical fabro-cli overrides and server run_manifest now emit verbose via [cli.output].verbosity = verbose rather than the prior run.metadata staging. No code changes beyond those Stage 4 fixes that were already in flight. --- docs/administration/server-configuration.mdx | 153 +++++----- docs/agents/mcp.mdx | 20 +- docs/api-reference/overview.mdx | 29 +- docs/core-concepts/models.mdx | 17 +- docs/execution/devcontainers.mdx | 8 +- docs/execution/environments.mdx | 24 +- docs/execution/failures.mdx | 11 +- docs/execution/retros.mdx | 12 +- docs/execution/run-configuration.mdx | 300 +++++++++---------- docs/human-tools/ssh-access.mdx | 4 +- docs/integrations/daytona.mdx | 24 +- docs/integrations/github.mdx | 14 +- docs/reference/cli.mdx | 25 +- docs/reference/user-configuration.mdx | 282 ++++++++++------- docs/workflows/variables.mdx | 30 +- 15 files changed, 524 insertions(+), 429 deletions(-) diff --git a/docs/administration/server-configuration.mdx b/docs/administration/server-configuration.mdx index 9156ecc02..3c0f575ee 100644 --- a/docs/administration/server-configuration.mdx +++ b/docs/administration/server-configuration.mdx @@ -7,7 +7,7 @@ description: "Server-owned settings.toml sections, CLI overrides, and environmen `fabro server start` reads `~/.fabro/settings.toml` by default. This is the same file schema used by the CLI. -On a same-machine setup, the CLI and server share one `settings.toml`. On a remote deployment, the server machine has its own `settings.toml`, and the client machine keeps a separate local `settings.toml` for CLI-only values such as `[server].target`. +On a same-machine setup, the CLI and server share one `settings.toml`. On a remote deployment, the server machine has its own `settings.toml`, and the client machine keeps a separate local `settings.toml` for CLI-only values such as `[cli.target]`. Legacy `server.toml`, `user.toml`, and `cli.toml` are ignored with a warning. Rename them to `settings.toml`. @@ -17,84 +17,88 @@ Legacy `server.toml`, `user.toml`, and `cli.toml` are ignored with a warning. Re | Scope | Examples | |---|---| -| Server-owned | `storage_dir`, `max_concurrent_runs`, `[web]`, `[api]`, `[features]` | -| Shared with CLI and workflow defaults | `[llm]`, `[log]`, `[git]`, `[setup]`, `[sandbox]`, `[checkpoint]`, `[vars]`, `[pull_request]` | +| Server-owned (runtime-only from local `settings.toml`) | `[server.listen]`, `[server.api]`, `[server.web]`, `[server.auth]`, `[server.storage]`, `[server.artifacts]`, `[server.slatedb]`, `[server.scheduler]`, `[server.logging]`, `[server.integrations]`, `[features]` | +| Shared run defaults (layered through `fabro.toml`/`workflow.toml`) | `[run.model]`, `[run.prepare]`, `[run.sandbox]`, `[run.checkpoint]`, `[run.inputs]`, `[run.pull_request]`, `[run.git]`, `[run.hooks]`, `[run.agent]` | -The CLI-only `[server]` section still belongs in the client machine's `settings.toml`. It tells CLI commands where to find a server. The server process does not read `[server].target` for its own binding or routing. +The CLI-only `[cli.*]` sections (including `[cli.target]`) belong in the client machine's `settings.toml`. They tell CLI commands how to reach a server. The server process does not read `[cli.*]` for its own binding or routing. ### Full reference ```toml title="settings.toml" -# Maximum concurrent workflow runs (default: 5) -max_concurrent_runs = 8 +_version = 1 -# Override the default data directory (default: ~/.fabro) -data_dir = "/var/lib/fabro" +[server.listen] +type = "tcp" +address = "0.0.0.0:3000" -[api] -base_url = "https://fabro.example.com/api/v1" - -[api.tls] +[server.listen.tls] cert = "/etc/fabro/tls/cert.pem" key = "/etc/fabro/tls/key.pem" ca = "/etc/fabro/tls/ca.pem" -# Authentication strategies (array of Jwt or Mtls) -[[api.authentication_strategies]] -type = "Jwt" +[server.api] +url = "https://fabro.example.com/api/v1" -[web] +[server.auth.api.jwt] +enabled = true + +[server.web] enabled = true url = "https://fabro-web.example.com" -[web.auth] -provider = "Github" +[server.auth.web] allowed_usernames = ["alice", "bob"] -[git] -provider = "Github" +[server.auth.web.providers.github] +enabled = true +client_id = "Iv1.abc123" + +[server.integrations.github] app_id = "123456" client_id = "Iv1.abc123" -[log] +[server.integrations.github.webhooks] +strategy = "tailscale_funnel" + +[server.storage] +root = "/var/lib/fabro" + +[server.scheduler] +max_concurrent_runs = 8 + +[server.logging] level = "info" -[git.author] +# Run defaults — applied to every run unless overridden by workflow/project config +[run.model] +name = "claude-sonnet-4-5" +provider = "anthropic" +fallbacks = ["gemini", "openai"] + +[[run.prepare.steps]] +script = "npm install" + +[run.sandbox] +provider = "daytona" + +[run.sandbox.daytona] +auto_stop_interval = 60 + +[run.sandbox.daytona.labels] +team = "platform" + +[run.checkpoint] +exclude_globs = ["**/node_modules/**", "**/.cache/**"] + +[run.inputs] +default_branch = "main" + +[run.git.author] name = "fabro-bot" email = "fabro-bot@company.com" -[git.webhooks] -strategy = "tailscale_funnel" - -# Run defaults — applied to every run unless overridden by workflow/project config -[llm] -model = "claude-sonnet-4-5" -provider = "anthropic" - -[llm.fallbacks] -anthropic = ["gemini", "openai"] - -[setup] -commands = ["npm install"] -timeout_ms = 120000 - -[sandbox] -provider = "daytona" - -[sandbox.daytona] -auto_stop_interval = 60 - -[sandbox.daytona.labels] -team = "platform" - [features] -retros = true - -[checkpoint] -exclude_globs = ["**/node_modules/**", "**/.cache/**"] - -[vars] -default_branch = "main" +session_sandboxes = true ``` ### CLI overrides @@ -116,36 +120,38 @@ Several `settings.toml` settings can be overridden via `fabro server start` flag CLI flags take precedence over `settings.toml` values. See [Run Configuration — Precedence](/execution/run-configuration#precedence) for the full resolution order. -### `[web]` section +### `[server.web]` section Control the embedded SPA and browser-oriented routes. | Key | Description | Default | |---|---|---| | `enabled` | Serve the embedded SPA, `/auth/*`, and the web-only helper endpoints under `/api/v1` | `true` | -| `url` | External web UI URL used for OAuth redirects | `http://localhost:3000` | +| `url` | External web UI URL used for OAuth redirects | none (no implicit derivation from `server.listen`) | When `enabled = false`, the server still exposes the machine API and `/health`, but `/`, `/auth/*`, SPA client routes, `/api/v1/auth/me`, `/api/v1/setup/*`, and `/api/v1/demo/toggle` all return `404`. ### Run defaults -The `[llm]`, `[setup]`, `[sandbox]`, `[checkpoint]`, and `[vars]` sections in `settings.toml` act as defaults for every run. +The `[run.*]` sections in `settings.toml` act as defaults for every run. -On a same-machine setup, `settings.toml` is the shared machine-default layer under `workflow.toml` / `run.toml` and `fabro.toml`. +On a same-machine setup, `settings.toml` is the shared machine-default layer under `workflow.toml` and `fabro.toml`. -On a remote setup, the client bundles workflow, project, and user config into the run manifest. The server then layers those bundled client configs over its own local defaults for run-shaped fields. Server-owned values like `storage_dir`, `[api]`, `[web]`, `[features]`, and `max_concurrent_runs` always come from the server machine's own `settings.toml` or `fabro server start` flags. +On a remote setup, the client bundles workflow, project, and user config into the run manifest. The server then layers those bundled client configs over its own local defaults for run-shaped fields. Server-owned values like `[server.storage]`, `[server.api]`, `[server.web]`, `[features]`, and `[server.scheduler]` always come from the server machine's own `settings.toml` or `fabro server start` flags. -For `[vars]`, Daytona labels, and checkpoint exclude globs, values are **merged** and the more specific layer wins on key collisions. All other fields use "first non-empty wins" precedence. +Merge rules follow the normative matrix: `[run.inputs]` replaces wholesale, `[run.sandbox.env]` and `[run.sandbox.daytona.labels]` merge by key, `[run.prepare.steps]` replaces whole-list, and `[[run.hooks]]` merge by optional `id`. Most other fields use "higher-precedence wins" field-wise merging. -### `[log]` section +### `[server.logging]` section -Configure the default log level without environment variables. Precedence: `FABRO_LOG` env var > `--debug` flag > `[log]` level > `"info"`. +Configure the default server log level. Precedence: `FABRO_LOG` env var > `--debug` flag > `[server.logging].level` > `"info"`. | Key | Description | Default | |---|---|---| | `level` | Log level: `error`, `warn`, `info`, `debug`, `trace` | `"info"` | -### `[git.author]` section +The CLI has its own `[cli.logging]` section. + +### `[run.git.author]` section Customize the git author identity used for checkpoint commits. When not set, defaults to `fabro` / `fabro@local`. @@ -154,19 +160,23 @@ Customize the git author identity used for checkpoint commits. When not set, def | `name` | Git author name | `"fabro"` | | `email` | Git author email | `"fabro@local"` | -On same-machine setups, the CLI and server read the same `[git.author]`. On remote setups, the server uses its local `settings.toml`. +### `[server.integrations.github]` section -### `[git.webhooks]` section +Configure a GitHub App integration. Required fields include `app_id`, `client_id`, and `slug`. Webhook delivery is configured under `[server.integrations.github.webhooks]`: -Enable automatic GitHub webhook delivery via Tailscale funnel. When configured, `fabro server start` binds a local HTTP listener, exposes it through `tailscale funnel`, and updates the GitHub App's webhook URL on startup. Incoming webhooks are verified with HMAC-SHA256. +```toml title="settings.toml" +[server.integrations.github] +app_id = "123456" +client_id = "Iv1.abc123" +slug = "fabro-app" -| Key | Description | Values | -|---|---|---| -| `strategy` | Webhook delivery method | `"tailscale_funnel"` | +[server.integrations.github.webhooks] +strategy = "tailscale_funnel" +``` -Requires a configured GitHub App (`[git]` section with `app_id` and `client_id`) and the `GITHUB_APP_WEBHOOK_SECRET` environment variable. +When `webhooks.strategy = "tailscale_funnel"` is configured, `fabro server start` binds a local HTTP listener, exposes it through `tailscale funnel`, and updates the GitHub App's webhook URL on startup. Incoming webhooks are verified with HMAC-SHA256. Requires the `GITHUB_APP_WEBHOOK_SECRET` environment variable. -### `[checkpoint]` section +### `[run.checkpoint]` section Configure checkpoint behavior for all runs. @@ -174,7 +184,7 @@ Configure checkpoint behavior for all runs. |---|---| | `exclude_globs` | Glob patterns for files to exclude from checkpoint commits (for example, `["**/node_modules/**"]`) | -Exclude globs from `settings.toml` and run configs are merged (union, deduplicated). See [Run Configuration — Checkpoint](/execution/run-configuration#checkpoint) for per-run configuration. +`exclude_globs` replaces across layers — the highest-precedence layer wins wholesale. See [Run Configuration — Checkpoint](/execution/run-configuration#runcheckpoint) for per-run configuration. ### `[features]` section @@ -182,7 +192,6 @@ Toggle experimental or opt-in features. All features default to `false`. | Key | Description | |---|---| -| `retros` | Enable automatic [retro](/execution/retros) generation after workflow runs (experimental) | | `session_sandboxes` | Enable session sandboxes in the web UI | The same `[features]` section can be set in `fabro.toml` (project-level) to enable features per-project. diff --git a/docs/agents/mcp.mdx b/docs/agents/mcp.mdx index 885564db4..f6d74d057 100644 --- a/docs/agents/mcp.mdx +++ b/docs/agents/mcp.mdx @@ -152,25 +152,29 @@ If the server marks the result as an error (`is_error: true`), the tool result i A workflow that uses Playwright MCP to automate a browser inside a Daytona sandbox: ```toml title="run.toml" -version = 1 -goal = "Test the login page" +_version = 1 + +[workflow] graph = "workflow.fabro" -[sandbox] +[run] +goal = "Test the login page" + +[run.sandbox] provider = "daytona" -[sandbox.daytona.snapshot] +[run.sandbox.daytona.snapshot] name = "daytona-medium" -[artifacts] +[run.artifacts] include = ["screenshots/**"] -[mcp_servers.playwright] +[run.agent.mcps.playwright] type = "sandbox" command = ["npx", "@playwright/mcp@latest", "--port", "3100", "--headless", "--browser", "chromium"] port = 3100 -startup_timeout_secs = 60 -tool_timeout_secs = 120 +startup_timeout = "60s" +tool_timeout = "2m" ``` After startup, the agent sees 22 Playwright tools including: diff --git a/docs/api-reference/overview.mdx b/docs/api-reference/overview.mdx index e3ab95f15..45d0b12cb 100644 --- a/docs/api-reference/overview.mdx +++ b/docs/api-reference/overview.mdx @@ -20,8 +20,8 @@ http://localhost:3000/api/v1 The base URL is configurable via `settings.toml`: ```toml title="settings.toml" -[api] -base_url = "https://fabro.example.com/api/v1" +[server.api] +url = "https://fabro.example.com/api/v1" ``` ## Authentication @@ -29,8 +29,8 @@ base_url = "https://fabro.example.com/api/v1" The API supports two authentication strategies, configured in `settings.toml`: ```toml title="settings.toml" -[api] -authentication_strategies = ["jwt"] +[server.auth.api.jwt] +enabled = true ``` ### JWT (Bearer Token) @@ -56,13 +56,17 @@ Set the verification key via the `FABRO_JWT_PUBLIC_KEY` environment variable (PE ### mTLS (Mutual TLS) -With mTLS, the client authenticates using a TLS client certificate. Configure both the strategy and TLS paths: +With mTLS, the client authenticates using a TLS client certificate. Configure the strategy and shared listener TLS: ```toml title="settings.toml" -[api] -authentication_strategies = ["mtls"] +[server.auth.api.mtls] +enabled = true -[api.tls] +[server.listen] +type = "tcp" +address = "0.0.0.0:3000" + +[server.listen.tls] cert = "~/.fabro/certs/server.crt" key = "~/.fabro/certs/server.key" ca = "~/.fabro/certs/ca.crt" @@ -72,11 +76,14 @@ The Common Name (CN) from the client certificate identifies the user. ### Multiple Strategies -You can configure both strategies. They are tried in order — the first successful match wins: +You can enable both strategies. They are tried in order — the first successful match wins: ```toml title="settings.toml" -[api] -authentication_strategies = ["jwt", "mtls"] +[server.auth.api.jwt] +enabled = true + +[server.auth.api.mtls] +enabled = true ``` ## Errors diff --git a/docs/core-concepts/models.mdx b/docs/core-concepts/models.mdx index 5ad270cd6..7ea0f25f5 100644 --- a/docs/core-concepts/models.mdx +++ b/docs/core-concepts/models.mdx @@ -92,16 +92,17 @@ These flags set the default model for all nodes that don't have an explicit mode For repeatable runs, set the model in a run config file: ```toml title="run.toml" -version = 1 -goal = "Implement the feature" +_version = 1 + +[workflow] graph = "implement.fabro" -[llm] -model = "claude-sonnet-4-5" +[run] +goal = "Implement the feature" -[llm.fallbacks] -anthropic = ["gemini", "openai"] -gemini = ["anthropic", "openai"] +[run.model] +name = "claude-sonnet-4-5" +fallbacks = ["gemini", "openai"] ``` Then launch with: @@ -110,7 +111,7 @@ Then launch with: fabro run run.toml ``` -The `[llm.fallbacks]` table is optional. It maps each provider to an ordered list of fallback providers to try when the primary is unavailable. +The `fallbacks` array is optional. Each entry may be a bare provider token (like `"gemini"`), a bare model alias (like `"gpt-5.4"`), or a qualified `"provider/model"` reference. Fabro tries them in order when the primary provider is unavailable. The precedence order is: node-level stylesheet > run config TOML > CLI flags > server defaults. More specific settings always win. diff --git a/docs/execution/devcontainers.mdx b/docs/execution/devcontainers.mdx index 6db34fb0f..c98e8bbf3 100644 --- a/docs/execution/devcontainers.mdx +++ b/docs/execution/devcontainers.mdx @@ -7,13 +7,15 @@ Fabro can use your project's [devcontainer](https://containers.dev/) configurati ## Enabling devcontainer support -Set `devcontainer = true` in the `[sandbox]` section of your run config: +Set `devcontainer = true` in the `[run.sandbox]` section of your run config: ```toml title="run.toml" -version = 1 +_version = 1 + +[workflow] graph = "workflow.fabro" -[sandbox] +[run.sandbox] provider = "daytona" devcontainer = true ``` diff --git a/docs/execution/environments.mdx b/docs/execution/environments.mdx index f58adc377..ca70264e2 100644 --- a/docs/execution/environments.mdx +++ b/docs/execution/environments.mdx @@ -26,7 +26,7 @@ fabro run workflow.fabro --sandbox daytona ```toml title="run.toml" # Run config TOML -[sandbox] +[run.sandbox] provider = "daytona" ``` @@ -94,7 +94,7 @@ fabro run workflow.fabro --sandbox docker --preserve-sandbox Or in the run config: ```toml title="run.toml" -[sandbox] +[run.sandbox] provider = "docker" preserve = true ``` @@ -123,17 +123,17 @@ The Daytona sandbox runs all tool operations inside a cloud-hosted VM managed by Snapshots let you pre-build an environment image so each run starts with dependencies already installed. If the named snapshot doesn't exist and a `dockerfile` is provided, Fabro creates it automatically and polls until it's ready (up to 10 minutes). ```toml title="run.toml" -[sandbox] +[run.sandbox] provider = "daytona" -[sandbox.daytona] +[run.sandbox.daytona] auto_stop_interval = 60 -[sandbox.daytona.snapshot] +[run.sandbox.daytona.snapshot] name = "rust-dev" cpu = 4 -memory = 8 -disk = 20 +memory = "8GB" +disk = "20GB" dockerfile = "FROM rust:1.85-slim-bookworm\nRUN apt-get update && apt-get install -y git ripgrep" ``` @@ -152,7 +152,7 @@ If the snapshot already exists and is in `Active` state, Fabro uses it directly. Attach key-value labels to sandboxes for filtering and identification in the Daytona dashboard: ```toml title="run.toml" -[sandbox.daytona.labels] +[run.sandbox.daytona.labels] project = "fabro" env = "ci" team = "platform" @@ -185,7 +185,7 @@ Fabro prints the sandbox name so you can find it in the [Daytona dashboard](http The `auto_stop_interval` setting (in minutes) tells Daytona to stop the sandbox after a period of inactivity. This saves costs for long-running sandboxes that may sit idle: ```toml title="run.toml" -[sandbox.daytona] +[run.sandbox.daytona] auto_stop_interval = 30 ``` @@ -215,15 +215,15 @@ Each provider handles outbound network access differently: | `docker` | Bridge network | Set via the `network_mode` config option. Supports all Docker network modes (`bridge`, `none`, `host`, etc.). | | `daytona` | Full access | Configurable via the `network` setting with three modes: `"allow_all"`, `"block"`, or CIDR-based allow lists. | -For Daytona, network access is configured in the `[sandbox.daytona]` section: +For Daytona, network access is configured in the `[run.sandbox.daytona]` section: ```toml title="run.toml" # Block all egress -[sandbox.daytona] +[run.sandbox.daytona] network = "block" # Allow only specific CIDRs -[sandbox.daytona] +[run.sandbox.daytona] network = { allow_list = ["208.80.154.232/32", "10.0.0.0/8"] } ``` diff --git a/docs/execution/failures.mdx b/docs/execution/failures.mdx index d820951f7..5a219da33 100644 --- a/docs/execution/failures.mdx +++ b/docs/execution/failures.mdx @@ -115,16 +115,13 @@ When a handler returns a `Retry` status instead of `Fail`, retries always procee When a model provider fails with a transient error or quota exhaustion, Fabro can automatically switch to a different provider. Configure fallback chains in your [run configuration](/execution/run-configuration): ```toml title="run.toml" -[llm] -model = "claude-opus-4-6" +[run.model] +name = "claude-opus-4-6" provider = "anthropic" - -[llm.fallbacks] -anthropic = ["gemini", "openai"] -gemini = ["anthropic", "openai"] +fallbacks = ["gemini", "openai"] ``` -When Anthropic is unavailable, Fabro tries Gemini first, then OpenAI. For each fallback provider, Fabro selects the closest model by matching required capabilities (tool use, vision, reasoning) and minimizing cost difference. +When Anthropic is unavailable, Fabro tries Gemini first, then OpenAI. Each fallback entry may be a bare provider token (like `"gemini"`), a bare model alias (like `"gpt-5.4"`), or a qualified `"provider/model"` reference. For each fallback provider, Fabro selects the closest model by matching required capabilities (tool use, vision, reasoning) and minimizing cost difference. ### What triggers failover diff --git a/docs/execution/retros.mdx b/docs/execution/retros.mdx index 44471a74a..e52a6c355 100644 --- a/docs/execution/retros.mdx +++ b/docs/execution/retros.mdx @@ -4,7 +4,7 @@ description: "Automatic retrospectives that analyze every workflow run" --- -**Experimental feature.** Retros are disabled by default. Enable them with `[features] retros = true` in your project config or server config. +**Experimental feature.** Retros are disabled by default. Enable them by setting `retros = true` under `[run.execution]` in your project or workflow config. After every workflow run, Fabro can generate a **retro** — a structured retrospective that captures what happened, what went well, and what didn't. Retros combine deterministic metrics extracted from the run's checkpoint with a qualitative narrative produced by an LLM agent that analyzes the full event stream. @@ -113,12 +113,12 @@ Both phases run automatically at the end of every CLI run. The API server derive ### CLI -To enable retros for your project, set `retros = true` in the `[features]` section of your `fabro.toml`: +To enable retros for your project, set `retros = true` under `[run.execution]` in your `fabro.toml`: ```toml title="fabro.toml" -version = 1 +_version = 1 -[features] +[run.execution] retros = true ``` @@ -131,7 +131,9 @@ fabro run workflow.fabro --no-retro Retros can also be enabled server-wide in `settings.toml`: ```toml title="settings.toml" -[features] +_version = 1 + +[run.execution] retros = true ``` diff --git a/docs/execution/run-configuration.mdx b/docs/execution/run-configuration.mdx index 9e6c9e1f2..3f855f8fb 100644 --- a/docs/execution/run-configuration.mdx +++ b/docs/execution/run-configuration.mdx @@ -3,7 +3,7 @@ title: "Run Configuration" description: "Configure workflow runs with TOML files" --- -A run config is a TOML file that bundles a workflow graph with all the settings needed to execute it — the goal, model, sandbox, setup commands, variables, and hooks. Instead of passing a dozen CLI flags, you check a `.toml` file into version control and launch with a single command: +A run config is a TOML file that bundles a workflow graph with all the settings needed to execute it — the goal, model, sandbox, prepare steps, inputs, and hooks. Instead of passing a dozen CLI flags, you check a `.toml` file into version control and launch with a single command: ```bash fabro run run.toml @@ -11,142 +11,154 @@ fabro run run.toml ## Minimal example -A run config requires two fields: +A run config needs at minimum a schema version and a goal: ```toml title="run.toml" -version = 1 +_version = 1 + +[workflow] graph = "workflow.fabro" + +[run] goal = "Implement the login feature" ``` | Field | Required | Description | |---|---|---| -| `version` | Yes | Config format version. Must be `1`. | -| `graph` | Yes | Path to the Graphviz workflow file, resolved relative to the TOML file's directory. | -| `goal` | No | What the workflow should accomplish. Passed to agents and used in retrospectives. Can also be provided via `--goal` CLI flag or Graphviz graph `goal` attribute. | +| `_version` | No (defaults to `1`) | Schema version. Must be `1` in the first pass. | +| `[workflow].graph` | No | Path to the Graphviz workflow file, relative to the TOML file's directory. Defaults to `workflow.fabro`. | +| `[run].goal` | No | What the workflow should accomplish. Passed to agents and used in retrospectives. Can also be provided via `--goal` CLI flag or Graphviz graph `goal` attribute. | -Goal precedence: CLI `--goal` > TOML `goal` > Graphviz graph attribute. +Goal precedence: CLI `--goal` > `[run].goal` > Graphviz graph attribute. ## Full example ```toml title="run.toml" -version = 1 -goal = "Run the CI pipeline for $repo_name" +_version = 1 + +[workflow] graph = "fabro/workflows/ci.fabro" -directory = "/tmp/workdir" -[llm] -model = "claude-sonnet-4-5" +[run] +goal = "Run the CI pipeline for $repo_name" +working_dir = "/tmp/workdir" -[llm.fallbacks] -anthropic = ["gemini", "openai"] -gemini = ["anthropic", "openai"] +[run.model] +name = "claude-sonnet-4-5" +fallbacks = ["openai", "gemini"] -[setup] -commands = ["git clone $repo_url repo", "cd repo && npm install"] -timeout_ms = 120000 +[[run.prepare.steps]] +script = "git clone $repo_url repo" -[sandbox] +[[run.prepare.steps]] +script = "cd repo && npm install" + +[run.sandbox] provider = "daytona" preserve = false -[sandbox.daytona] +[run.sandbox.daytona] auto_stop_interval = 60 -[sandbox.daytona.labels] +[run.sandbox.daytona.labels] project = "fabro" env = "ci" -[sandbox.daytona.snapshot] +[run.sandbox.daytona.snapshot] name = "node-20" cpu = 4 -memory = 8 -disk = 20 +memory = "8GB" +disk = "20GB" dockerfile = "FROM node:20-slim\nRUN apt-get update && apt-get install -y git" -[sandbox.env] +[run.sandbox.env] API_KEY = "${env.MY_API_KEY}" NODE_ENV = "production" -[checkpoint] +[run.checkpoint] exclude_globs = ["**/node_modules/**", "**/.cache/**"] -[vars] +[run.inputs] repo_name = "fabro" repo_url = "https://github.com/fabro-sh/fabro" -[artifacts] +[run.artifacts] include = ["test-results/**", "playwright-report/**"] -[mcp_servers.playwright] +[run.agent.mcps.playwright] type = "sandbox" command = ["npx", "@playwright/mcp@latest", "--port", "3100", "--headless"] port = 3100 -[pull_request] +[run.pull_request] enabled = true draft = false -[[hooks]] +[[run.hooks]] +id = "pre-check" event = "stage_start" -command = "./scripts/pre-check.sh" +script = "./scripts/pre-check.sh" blocking = true sandbox = false -[[hooks]] +[[run.hooks]] event = "run_complete" -command = "echo done" +script = "echo done" ``` ## Sections -### `[llm]` +### `[run.model]` Override the default model and provider for all nodes that don't have an explicit model assigned via a [stylesheet](/workflows/stylesheets). ```toml title="run.toml" -[llm] -model = "claude-sonnet-4-5" +[run.model] +name = "claude-sonnet-4-5" ``` | Field | Description | |---|---| -| `model` | Model ID or alias (e.g. `claude-sonnet-4-5`, `opus`, `gemini-pro`). See [Models](/core-concepts/models). | +| `name` | Model ID or alias (e.g. `claude-sonnet-4-5`, `opus`, `gemini-pro`). See [Models](/core-concepts/models). | | `provider` | Provider name (optional — auto-inferred from the model catalog). Only needed for models not in the catalog or to force a specific provider. | +| `fallbacks` | Ordered list of model references to try when the primary is unavailable. Entries can be bare provider tokens (`"openai"`), bare model aliases, or qualified `"provider/model"` references. | -#### `[llm.fallbacks]` +#### Fallbacks with splice -Map each provider to an ordered list of fallback providers. When the primary provider is unavailable, Fabro tries the fallbacks in order: +Use the reserved `"..."` marker in `fallbacks` to splice in the inherited list from lower-precedence layers: ```toml title="run.toml" -[llm.fallbacks] -anthropic = ["gemini", "openai"] -gemini = ["anthropic", "openai"] +[run.model] +# Prepend "anthropic" to whatever fallbacks the project config already defines. +fallbacks = ["anthropic", "..."] ``` -### `[setup]` +### `[run.prepare]` -Shell commands to run before the workflow starts. Use this to clone repositories, install dependencies, or prepare the environment. +Ordered list of steps to run before the workflow starts. Use this to clone repositories, install dependencies, or prepare the environment. ```toml title="run.toml" -[setup] -commands = ["pip install -r requirements.txt", "npm install"] -timeout_ms = 60000 +[[run.prepare.steps]] +script = "pip install -r requirements.txt" + +[[run.prepare.steps]] +script = "npm install" ``` | Field | Description | |---|---| -| `commands` | List of shell commands, executed sequentially via `sh -c`. | -| `timeout_ms` | Per-command timeout in milliseconds. Default: `300000` (5 minutes). | +| `script` | Shell-evaluated command (runs through `sh -c`). | +| `command` | Argv-style command, mutually exclusive with `script`. | +| `env` | Additional environment variables for this step. | -Each command must exit with status 0. If any command fails or times out, the run aborts before the workflow starts. +Each step must exit with status 0. If any step fails, the run aborts before the workflow starts. Prepare steps replace across layers — the higher-precedence layer wins wholesale. -### `[sandbox]` +### `[run.sandbox]` Configure how agent tools (bash, file edits) are executed. ```toml title="run.toml" -[sandbox] +[run.sandbox] provider = "docker" preserve = true ``` @@ -157,23 +169,23 @@ preserve = true | `preserve` | When `true`, keep the sandbox alive after the run finishes. Useful for debugging. | | `devcontainer` | When `true`, use the repo's `devcontainer.json` to configure the sandbox. See [Devcontainers](/execution/devcontainers). | -#### `[sandbox.daytona]` +#### `[run.sandbox.daytona]` Additional settings when using the Daytona cloud sandbox: ```toml title="run.toml" -[sandbox.daytona] +[run.sandbox.daytona] auto_stop_interval = 60 -[sandbox.daytona.labels] +[run.sandbox.daytona.labels] project = "fabro" env = "staging" -[sandbox.daytona.snapshot] +[run.sandbox.daytona.snapshot] name = "my-snapshot" cpu = 4 -memory = 8 -disk = 20 +memory = "8GB" +disk = "20GB" dockerfile = "FROM rust:1.85-slim-bookworm\nRUN apt-get update" # Or reference an external Dockerfile: # dockerfile = { path = "./Dockerfile" } @@ -182,20 +194,20 @@ dockerfile = "FROM rust:1.85-slim-bookworm\nRUN apt-get update" | Field | Description | |---|---| | `auto_stop_interval` | Minutes of inactivity before the sandbox auto-stops. | -| `labels` | Key-value labels attached to the sandbox for filtering and identification. | +| `labels` | Key-value labels attached to the sandbox for filtering and identification. Labels merge across layers (sticky merge-by-key). | | `snapshot.name` | Snapshot name to create or use for the sandbox. | -| `snapshot.cpu` | CPU cores for the snapshot. | -| `snapshot.memory` | Memory in GB for the snapshot. | -| `snapshot.disk` | Disk in GB for the snapshot. | +| `snapshot.cpu` | CPU cores for the snapshot (integer). | +| `snapshot.memory` | Memory size using human-readable units: `"8GB"`, `"16GiB"`, or bare integers that default to GB. | +| `snapshot.disk` | Disk size using the same units as `memory`. | | `snapshot.dockerfile` | Dockerfile content (inline string) or path (`{ path = "..." }`) for building the snapshot image. Paths are resolved relative to the TOML file's directory. | | `network` | Network access mode: `"allow_all"` (default), `"block"`, or `{ allow_list = ["..."] }`. See [Sandboxing](/administration/sandboxing#network-access-control). | -#### `[sandbox.local]` +#### `[run.sandbox.local]` Additional settings when using the local sandbox: ```toml title="run.toml" -[sandbox.local] +[run.sandbox.local] worktree_mode = "always" ``` @@ -203,29 +215,31 @@ worktree_mode = "always" |---|---| | `worktree_mode` | When to create a git worktree for the run: `always`, `clean` (default — only when the working tree is clean), `dirty` (also when dirty), or `never`. | -#### `[sandbox.env]` +#### `[run.sandbox.env]` -Pass environment variables into sandbox command and agent execution. Values can be literal strings or host environment passthrough using `${env.VARNAME}` syntax: +Pass environment variables into sandbox command and agent execution. Values can be literal strings or host environment references using `${env.VARNAME}` syntax: ```toml title="run.toml" -[sandbox.env] +[run.sandbox.env] API_KEY = "${env.MY_API_KEY}" NODE_ENV = "production" +SERVICE_URL = "https://api.${env.REGION}.example.com" ``` | Syntax | Description | |---|---| | `"literal"` | Static value passed as-is | -| `"${env.VARNAME}"` | Resolved from the host environment at load time. Missing vars produce a hard error. | +| `"${env.VARNAME}"` | Whole-value reference resolved from the host environment at consumption time | +| `"prefix-${env.X}-suffix"` | Substring interpolation; multiple tokens per string are supported | -Host env references must be whole-value only — partial interpolation like `"prefix-${env.X}"` is not supported. Sandbox env vars from `settings.toml` defaults and the run config are merged, with the run config winning on key collisions. +Missing host variables produce a hard error pointing at the specific field and unresolved token. `run.sandbox.env` is a sticky merge-by-key map: entries from all layers combine, with higher-precedence layers overriding individual keys. -### `[checkpoint]` +### `[run.checkpoint]` Configure how git checkpoint commits behave. ```toml title="run.toml" -[checkpoint] +[run.checkpoint] exclude_globs = ["**/node_modules/**", "**/.cache/**", "**/dist/**"] ``` @@ -233,20 +247,20 @@ exclude_globs = ["**/node_modules/**", "**/.cache/**", "**/dist/**"] |---|---| | `exclude_globs` | Glob patterns for files to exclude from checkpoint commits. Uses git pathspec `:(glob,exclude)` syntax. | -Exclude globs from `settings.toml` defaults and the run config are merged (union, deduplicated). +`exclude_globs` replaces across layers — the higher-precedence layer wins wholesale. -### `[vars]` +### `[run.inputs]` -Define variables that are expanded into the Graphviz source before the graph is parsed. See [Variables](/workflows/variables) for the full reference. +Define inputs that are expanded into the Graphviz source before the graph is parsed. See [Variables](/workflows/variables) for the full reference. ```toml title="run.toml" -[vars] +[run.inputs] repo_name = "fabro" repo_url = "https://github.com/fabro-sh/fabro" language = "rust" ``` -Variables can be used anywhere in the Graphviz file with `$name` syntax: +Inputs can be used anywhere in the Graphviz file with `$name` syntax: ```dot title="c-i.fabro" digraph CI { @@ -256,14 +270,16 @@ digraph CI { } ``` -If a `$variable` in the Graphviz file has no matching entry in `[vars]`, Fabro raises an error immediately. A bare `$` not followed by an identifier (e.g. `costs $5`) is left as-is. +If a `$variable` in the Graphviz file has no matching entry in `[run.inputs]`, Fabro raises an error immediately. A bare `$` not followed by an identifier (e.g. `costs $5`) is left as-is. -### `[artifacts]` +`[run.inputs]` replaces wholesale across layers. Unlike labels, inputs do not merge by key — the highest-precedence layer that sets `inputs` wins its entire map. + +### `[run.artifacts]` Configure automatic collection of test artifacts (Playwright reports, JUnit XML, screenshots, etc.) from the execution environment after each stage. ```toml title="run.toml" -[artifacts] +[run.artifacts] include = ["test-results/**", "playwright-report/**", "*.trace.zip"] ``` @@ -271,40 +287,41 @@ include = ["test-results/**", "playwright-report/**", "*.trace.zip"] |---|---| | `include` | Glob patterns for files to collect as assets. Matched against the working directory after each stage completes. | -Artifact collection is opt-in — when no `[artifacts]` section is present, no file scanning occurs. This avoids the overhead of scanning large working directories when assets aren't needed. +Artifact collection is opt-in — when no `[run.artifacts]` section is present, no file scanning occurs. -### `[mcp_servers]` +### `[run.agent.mcps]` -Configure [MCP servers](/agents/mcp) available to agent stages during the workflow run. Each server is a named TOML table. All three transport types are supported: `stdio`, `http`, and `sandbox`. +Configure [MCP servers](/agents/mcp) available to agent stages during the workflow run. Each server is a named TOML table under `[run.agent.mcps]`. All three transport types are supported: `stdio`, `http`, and `sandbox`. ```toml title="run.toml" -[mcp_servers.playwright] +[run.agent.mcps.playwright] type = "sandbox" command = ["npx", "@playwright/mcp@latest", "--port", "3100", "--headless", "--browser", "chromium"] port = 3100 -startup_timeout_secs = 60 -tool_timeout_secs = 120 +startup_timeout = "60s" +tool_timeout = "2m" ``` | Field | Description | Default | |---|---|---| | `type` | Transport type: `"stdio"`, `"http"`, or `"sandbox"`. | — | -| `command` | (stdio, sandbox) Array: executable + arguments. | — | +| `script` | (stdio, sandbox) Shell-evaluated startup command, mutually exclusive with `command`. | — | +| `command` | (stdio, sandbox) Argv array: executable + arguments. | — | | `port` | (sandbox) Port the server listens on inside the sandbox. | — | | `url` | (http) The MCP server endpoint URL. | — | | `env` | (stdio, sandbox) Additional environment variables. | `{}` | | `headers` | (http) Optional HTTP headers for authentication. | `{}` | -| `startup_timeout_secs` | Max seconds for server startup + MCP handshake. | `10` | -| `tool_timeout_secs` | Max seconds for a single tool call. | `60` | +| `startup_timeout` | Max duration for server startup + MCP handshake (e.g. `"10s"`, `"1m"`). | `"10s"` | +| `tool_timeout` | Max duration for a single tool call. | `"60s"` | The `sandbox` transport runs the MCP server inside the workflow's sandbox. This is useful for tools that need access to the sandbox environment, such as browser automation with Playwright. See [MCP](/agents/mcp#sandbox) for details. -### `[pull_request]` +### `[run.pull_request]` Automatically open a GitHub pull request when the workflow run completes successfully. Requires a [GitHub App](/integrations/github) to be configured. ```toml title="run.toml" -[pull_request] +[run.pull_request] enabled = true draft = true auto_merge = false @@ -318,64 +335,46 @@ merge_strategy = "squash" | `auto_merge` | When `true`, enables GitHub auto-merge on the created PR. Implies `draft = false` since GitHub doesn't allow auto-merge on draft PRs. The repository must have auto-merge enabled in GitHub settings. Default: `false`. | | `merge_strategy` | Merge method when `auto_merge` is enabled: `squash` (default), `merge`, or `rebase`. | -### `[github]` - -Request a scoped GitHub Installation Access Token and inject it into the sandbox as `GITHUB_TOKEN`. The token is minted from the configured [GitHub App](/integrations/github) with only the permissions you specify. - -```toml title="run.toml" -[github] -permissions = { contents = "write", pull_requests = "read" } -``` - -| Field | Description | -|---|---| -| `permissions` | Map of GitHub API permission names to access levels (`"read"` or `"write"`). Only the listed permissions are requested. | - -This requires a GitHub App to be configured. If the app is missing or the repository doesn't have an installation, the run logs a warning and continues without injecting the token. - -### `[[hooks]]` +### `[[run.hooks]]` Define hooks that run in response to lifecycle events. Each hook is a TOML array entry: ```toml title="run.toml" -[[hooks]] -name = "pre-check" +[[run.hooks]] +id = "pre-check" +name = "Pre-check script" event = "stage_start" -command = "./scripts/pre-check.sh" +script = "./scripts/pre-check.sh" matcher = "agent_loop" blocking = true -timeout_ms = 30000 +timeout = "30s" sandbox = false ``` | Field | Description | |---|---| +| `id` | Optional merge identity. Hooks with the same `id` replace each other across layers. | | `name` | Optional display name for the hook. | -| `event` | Lifecycle event: `run_start`, `run_complete`, `stage_start`, `stage_complete`. | -| `command` | Shell command to execute (shorthand for `type = "command"`). | +| `event` | Lifecycle event: `run_start`, `run_complete`, `stage_start`, `stage_complete`, etc. | +| `script` | Shell-evaluated command (equivalent to the old `type = "command"` shorthand). | +| `command` | Argv-style command (alternative to `script`). | | `matcher` | Regex matched against node ID or handler type. Limits which stages trigger this hook. | | `blocking` | Whether the hook must complete before execution continues. Defaults vary by event. | -| `timeout_ms` | Hook timeout in milliseconds. Default: `60000` (60s). | +| `timeout` | Human-readable hook timeout (e.g. `"30s"`, `"1m"`). Default: `"60s"`. | | `sandbox` | Run inside the sandbox (`true`, default) or on the host (`false`). | -See [Hooks](/agents/hooks) for hook types beyond simple commands (HTTP, prompt, agent). +Hook merge semantics: hooks with matching `id` values replace in place. Hooks without an `id` from a higher-precedence layer append after the fully merged inherited hook list. -## Top-level fields - -In addition to the sections above, two optional top-level fields are available: - -| Field | Description | -|---|---| -| `directory` | Working directory for the run. Defaults to the current directory. | +See [Hooks](/agents/hooks) for hook types beyond scripts (HTTP, prompt, agent). ## Graph path resolution -The `graph` path is resolved relative to the TOML file's parent directory, not the current working directory. This means a run config and its workflow can live side by side: +The `[workflow].graph` path is resolved relative to the TOML file's parent directory, not the current working directory. This means a run config and its workflow can live side by side: ``` project/ runs/ - ci.toml # graph = "ci.fabro" + ci.toml # [workflow] graph = "ci.fabro" ci.fabro ``` @@ -388,67 +387,50 @@ Settings can come from multiple sources. Fabro resolves them in this order (firs | Source | Priority | |---|---| | Node-level [stylesheet](/workflows/stylesheets) | Highest | -| Run config TOML | | | CLI flags (`--model`, `--provider`, `--sandbox`) | | +| Run config TOML (`workflow.toml` or equivalent) | | | Project defaults (`fabro.toml`) | | -| Server defaults (`~/.fabro/settings.toml`) | | +| Machine defaults (`~/.fabro/settings.toml`) | | | Graphviz graph attributes (`default_model`, `default_provider`) | | | Built-in defaults | Lowest | -For model and provider specifically, the precedence is: CLI flags > TOML config > project defaults > server defaults > Graphviz graph attributes > built-in defaults. Stylesheet rules on individual nodes always take priority over all of these. +Stylesheet rules on individual nodes always take priority over run config values. ### Project defaults (`fabro.toml`) -The `fabro.toml` project config can set default values for `[llm]`, `[setup]`, `[sandbox]`, `[vars]`, `[checkpoint]`, `[pull_request]`, `[github]`, `[artifacts]`, `[[hooks]]`, and `[mcp_servers]`. These defaults apply to all runs in the project unless the run config overrides them: +The `fabro.toml` project config can set default values for any of the `[run.*]` sections described above. These defaults apply to all runs in the project unless the workflow config overrides them: ```toml title="fabro.toml" -version = 1 +_version = 1 -[llm] -model = "claude-sonnet-4-5" +[project] +directory = "fabro/" -[sandbox] +[run.model] +name = "claude-sonnet-4-5" + +[run.sandbox] provider = "daytona" -[sandbox.daytona.snapshot] +[run.sandbox.daytona.snapshot] name = "my-project-snapshot" - -[github] -permissions = { contents = "write" } ``` -Project defaults are merged with run config values using the same rules as server defaults — run config wins on key collisions. +Project defaults and workflow config values merge per the normative merge matrix: most fields merge by field (higher-precedence wins per key), `run.inputs` replaces wholesale, `run.sandbox.env` sticky-merges by key, and `run.prepare.steps` replaces whole-list. -### Server defaults +### Machine defaults -When running via `fabro server start`, the server config at `~/.fabro/settings.toml` can set default values for `[llm]`, `[setup]`, `[sandbox]`, and `[vars]`. These defaults are applied to every run unless the run config overrides them. - -For variables, defaults and run config are **merged** — the run config wins on key collisions: - -```toml -# ~/.fabro/settings.toml -[vars] -default_key = "from_server" -shared = "from_server" - -# run.toml -[vars] -shared = "from_run" # wins -task_key = "from_run" -``` - -The same merge behavior applies to Daytona labels. All other fields use simple "first non-empty wins" precedence. +When running locally, the machine defaults at `~/.fabro/settings.toml` can set run-scoped defaults too. Same merge rules apply. ## Validation Fabro validates the run config when it loads: -- **Version check** — Only `version = 1` is accepted. Other versions are rejected immediately. -- **Required fields** — `version` and `graph` are required. `goal` is optional (can be provided via `--goal` or Graphviz graph attribute). -- **Unknown fields** — Extra fields not listed above are silently ignored. -- **Variable check** — Any `$variable` in the Graphviz file without a matching `[vars]` entry produces an error. +- **`_version` check** — Only `_version = 1` (or missing, which defaults to `1`) is accepted. The legacy top-level `version` key is rejected with a rename hint. +- **Unknown keys** — Any top-level key not in `[project]`, `[workflow]`, `[run]`, `[cli]`, `[server]`, `[features]`, or `_version` is rejected with a targeted rename hint pointing at the v2 replacement path. +- **Variable check** — Any `$variable` in the Graphviz file without a matching `[run.inputs]` entry produces an error. Use `fabro preflight` to validate a run config without executing it: diff --git a/docs/human-tools/ssh-access.mdx b/docs/human-tools/ssh-access.mdx index 7683148e4..41fd8934f 100644 --- a/docs/human-tools/ssh-access.mdx +++ b/docs/human-tools/ssh-access.mdx @@ -37,11 +37,11 @@ Without `--preserve-sandbox`, the SSH session is terminated when the run ends an You can also set `auto_stop_interval` in your run config to control how long an idle sandbox stays alive: ```toml title="run.toml" -[sandbox] +[run.sandbox] provider = "daytona" preserve = true -[sandbox.daytona] +[run.sandbox.daytona] auto_stop_interval = 60 ``` diff --git a/docs/integrations/daytona.mdx b/docs/integrations/daytona.mdx index 01fa8e2b4..52a875b8a 100644 --- a/docs/integrations/daytona.mdx +++ b/docs/integrations/daytona.mdx @@ -29,30 +29,30 @@ fabro run workflow.fabro --sandbox daytona ``` ```toml title="run.toml" -[sandbox] +[run.sandbox] provider = "daytona" ``` A full configuration example with all Daytona-specific options: ```toml title="run.toml" -[sandbox] +[run.sandbox] provider = "daytona" preserve = false -[sandbox.daytona] +[run.sandbox.daytona] auto_stop_interval = 60 -[sandbox.daytona.labels] +[run.sandbox.daytona.labels] project = "fabro" env = "staging" team = "platform" -[sandbox.daytona.snapshot] +[run.sandbox.daytona.snapshot] name = "rust-dev" cpu = 4 -memory = 8 -disk = 20 +memory = "8GB" +disk = "20GB" dockerfile = "FROM rust:1.85-slim-bookworm\nRUN apt-get update && apt-get install -y git ripgrep" ``` @@ -64,15 +64,15 @@ Control outbound network access with the `network` field. Three modes are availa ```toml title="run.toml" # Full access (default) -[sandbox.daytona] +[run.sandbox.daytona] network = "allow_all" # Block all egress -[sandbox.daytona] +[run.sandbox.daytona] network = "block" # CIDR-based allow list -[sandbox.daytona] +[run.sandbox.daytona] network = { allow_list = ["208.80.154.232/32", "10.0.0.0/8"] } ``` @@ -138,7 +138,7 @@ fabro run workflow.fabro --sandbox daytona --preserve-sandbox Or in the run config: ```toml title="run.toml" -[sandbox] +[run.sandbox] provider = "daytona" preserve = true ``` @@ -150,7 +150,7 @@ When preserved, Fabro prints the sandbox name so you can find it in the [Daytona The `auto_stop_interval` setting tells Daytona to stop the sandbox after a period of inactivity, saving costs for preserved or long-running sandboxes: ```toml title="run.toml" -[sandbox.daytona] +[run.sandbox.daytona] auto_stop_interval = 30 ``` diff --git a/docs/integrations/github.mdx b/docs/integrations/github.mdx index ee8034c12..da7fad4e8 100644 --- a/docs/integrations/github.mdx +++ b/docs/integrations/github.mdx @@ -12,9 +12,9 @@ Fabro uses a [GitHub App](https://docs.github.com/en/apps/overview) to authentic | **OAuth login** | Users sign in to the web UI with their GitHub account | | **Private repo cloning** | Daytona and Docker sandboxes clone private repositories using short-lived Installation Access Tokens | | **Checkpoint pushing** | After each workflow stage, Fabro pushes the run branch and metadata branch back to origin from inside the sandbox | -| **Auto-PR** | When `[pull_request] enabled = true` in the [run config](/execution/run-configuration#pull_request), Fabro opens a PR from the agent's working branch after a successful run | -| **Auto-merge** | When `[pull_request] auto_merge = true`, Fabro enables GitHub's auto-merge on created PRs so they merge automatically once required checks pass | -| **Sandbox GITHUB_TOKEN** | When `[github] permissions` are declared in the run config, Fabro mints a scoped Installation Access Token and injects it as `GITHUB_TOKEN` in the sandbox | +| **Auto-PR** | When `[run.pull_request] enabled = true` in the [run config](/execution/run-configuration#runpull_request), Fabro opens a PR from the agent's working branch after a successful run | +| **Auto-merge** | When `[run.pull_request] auto_merge = true`, Fabro enables GitHub's auto-merge on created PRs so they merge automatically once required checks pass | +| **Sandbox GITHUB_TOKEN** | When `[server.integrations.github.permissions]` are declared in the server config, Fabro mints a scoped Installation Access Token and injects it as `GITHUB_TOKEN` in the sandbox | ## Setup @@ -61,8 +61,8 @@ The GitHub App check verifies five fields: | Field | Source | |---|---| -| `git.app_id` | `~/.fabro/settings.toml` | -| `git.client_id` | `~/.fabro/settings.toml` | +| `server.integrations.github.app_id` | `~/.fabro/settings.toml` | +| `server.integrations.github.client_id` | `~/.fabro/settings.toml` | | `GITHUB_APP_CLIENT_SECRET` | Server secret store | | `GITHUB_APP_WEBHOOK_SECRET` | Server secret store | | `GITHUB_APP_PRIVATE_KEY` | Server secret store | @@ -76,8 +76,7 @@ The GitHub App configuration lives in two places: ### `~/.fabro/settings.toml` ```toml title="settings.toml" -[git] -provider = "github" +[server.integrations.github] app_id = "123456" client_id = "Iv1.abc123def" slug = "fabro-a3f2" @@ -85,7 +84,6 @@ slug = "fabro-a3f2" | Field | Description | |---|---| -| `provider` | Always `"github"` (the only supported provider) | | `app_id` | Numeric GitHub App ID | | `client_id` | OAuth Client ID for the app | | `slug` | App slug, used for linking to the GitHub App settings page | diff --git a/docs/reference/cli.mdx b/docs/reference/cli.mdx index a85d5302e..b9bcb993d 100644 --- a/docs/reference/cli.mdx +++ b/docs/reference/cli.mdx @@ -24,22 +24,29 @@ Connection-target flags like `--storage-dir` and `--server` are command-specific CLI defaults can be set in `~/.fabro/settings.toml` so you don't have to pass common flags every time: ```toml title="settings.toml" -[exec] +_version = 1 + +[cli.exec.model] provider = "anthropic" -model = "claude-opus-4-6" +name = "claude-opus-4-6" + +[cli.exec.agent] permissions = "read-write" -output_format = "text" -[llm] -model = "claude-sonnet-4-5" +[cli.output] +format = "text" -[server] -target = "https://fabro.example.com:3000/api/v1" +[run.model] +name = "claude-sonnet-4-5" + +[cli.target] +type = "http" +url = "https://fabro.example.com:3000/api/v1" ``` -`[exec]` config applies to `fabro exec`. `[llm]` sets the default workflow model/provider for commands like `fabro run` and `fabro preflight`. `[server]` stores connection info for commands that can target a remote Fabro server. +`[cli.exec]` config applies to `fabro exec`. `[run.model]` sets the default workflow model/provider for commands like `fabro run` and `fabro preflight`. `[cli.target]` stores connection info for commands that can target a remote Fabro server. -`fabro model` uses `[server].target` by default when no explicit `--storage-dir` is passed. `fabro exec` remains a local session unless you pass `--server`, even if `[server].target` is configured. +`fabro model` uses `[cli.target]` by default when no explicit `--storage-dir` is passed. `fabro exec` remains a local session unless you pass `--server`, even if `[cli.target]` is configured. CLI flags always override `settings.toml` values, which override hardcoded defaults. diff --git a/docs/reference/user-configuration.mdx b/docs/reference/user-configuration.mdx index c15c44157..e12555b02 100644 --- a/docs/reference/user-configuration.mdx +++ b/docs/reference/user-configuration.mdx @@ -17,114 +17,167 @@ The default path is `~/.fabro/settings.toml`. Use `fabro server start --config /path/to/settings.toml` if the server should read a different file. +## Schema version + +Every Fabro config file must declare its schema version with a top-level `_version` key: + +```toml title="settings.toml" +_version = 1 +``` + +Files that omit `_version` are treated as version `1`. The legacy top-level `version` key is no longer accepted and raises a targeted rename hint. + ## Who reads what -`settings.toml` uses the same schema as `fabro.toml` and `workflow.toml`, but each process only reads the fields it understands. +`settings.toml` uses the same schema as `fabro.toml` and `workflow.toml`, but each process only reads the fields it understands. The top-level schema is strictly namespaced — the only allowed domains are `[project]`, `[workflow]`, `[run]`, `[cli]`, `[server]`, and `[features]`. | Scope | Examples | |---|---| -| CLI-only | `verbose`, `upgrade_check`, `[server]`, `[exec]`, `[mcp_servers]` | -| Shared defaults | `[llm]`, `[log]`, `[git]`, `[pull_request]`, plus run-default sections like `[setup]`, `[sandbox]`, `[checkpoint]`, and `[vars]` | -| Server-only | `storage_dir`, `max_concurrent_runs`, `[web]`, `[api]`, `[features]` | +| CLI-only | `[cli.target]`, `[cli.auth]`, `[cli.exec]`, `[cli.output]`, `[cli.updates]`, `[cli.logging]` | +| Shared run defaults | `[run.model]`, `[run.sandbox]`, `[run.checkpoint]`, `[run.inputs]`, `[run.prepare]`, `[run.pull_request]`, `[run.hooks]`, `[run.agent.mcps]` | +| Server-only | `[server.listen]`, `[server.api]`, `[server.web]`, `[server.auth]`, `[server.storage]`, `[server.artifacts]`, `[server.slatedb]`, `[server.scheduler]`, `[server.logging]`, `[server.integrations]` | + +`[cli.*]` and `[server.*]` stanzas are owner-specific: they are only consumed from `~/.fabro/settings.toml` (plus process-local flags and env overrides). The same stanzas in `fabro.toml` or `workflow.toml` remain schema-valid but runtime-inert. See [Server Configuration](/administration/server-configuration) for the server-owned sections. ## Precedence -For CLI commands running on the local machine, precedence is: +Shared layered domains (`[project]`, `[workflow]`, `[run]`, `[features]`) use this override order: 1. **CLI flags** — always win -2. **`workflow.toml` / `run.toml`** — per-run overrides -3. **`fabro.toml`** — project defaults -4. **`settings.toml`** — machine defaults -5. **Built-in defaults** +2. **Environment overrides** — Fabro-defined override channels +3. **`workflow.toml`** — per-workflow overrides +4. **`fabro.toml`** — project defaults +5. **`~/.fabro/settings.toml`** — machine defaults +6. **Built-in defaults** -On a same-machine setup, the server reads the same `settings.toml`. On a remote setup, the CLI machine and server machine each use their own local `settings.toml`. +Owner-specific domains (`[cli.*]`, `[server.*]`) use a narrower trust boundary — only CLI flags, env overrides, `~/.fabro/settings.toml`, and built-in defaults apply. ## Full example ```toml title="settings.toml" -verbose = true -upgrade_check = true +_version = 1 -[server] -target = "https://fabro.example.com:3000/api/v1" +[cli.target] +type = "http" +url = "https://fabro.example.com:3000/api/v1" -[server.tls] +[cli.target.tls] cert = "~/.fabro/tls/client.crt" key = "~/.fabro/tls/client.key" ca = "~/.fabro/tls/ca.crt" -[exec] +[cli.exec] +prevent_idle_sleep = true + +[cli.exec.model] provider = "anthropic" -model = "claude-opus-4-6" +name = "claude-opus-4-6" + +[cli.exec.agent] permissions = "read-write" -output_format = "text" -[llm] -model = "claude-sonnet-4-5" +[cli.output] +format = "text" +verbosity = "normal" -[log] +[cli.updates] +check = true + +[cli.logging] level = "info" -[git.author] +[run.model] +name = "claude-sonnet-4-5" + +[run.git.author] name = "fabro-bot" email = "fabro-bot@company.com" -[pull_request] +[run.pull_request] enabled = true -[mcp_servers.filesystem] +[run.agent.mcps.filesystem] type = "stdio" command = ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/workspace"] -startup_timeout_secs = 15 -tool_timeout_secs = 90 +startup_timeout = "15s" +tool_timeout = "90s" -[mcp_servers.filesystem.env] +[run.agent.mcps.filesystem.env] NODE_ENV = "production" -[mcp_servers.sentry] +[run.agent.mcps.sentry] type = "http" url = "https://mcp.sentry.dev/mcp" -[mcp_servers.sentry.headers] +[run.agent.mcps.sentry.headers] Authorization = "Bearer sk-xxx" ``` -All fields are optional. Include only the sections and keys you want to override. On a same-machine install, this same file can also include server sections such as `[web]` and `[api]`. +All fields are optional. Include only the sections and keys you want to override. On a same-machine install, this same file can also include server sections such as `[server.web]` and `[server.api]`. -## `upgrade_check` +## `[cli.updates]` Controls whether Fabro runs a daily background check for new releases. The check runs during `run`, `exec`, `init`, and `install` commands and prints a notice to stderr when a newer version is available. -| Value | Description | -|---|---| -| `true` | Check for new releases (default) | -| `false` | Disable automatic upgrade checks | +```toml title="settings.toml" +[cli.updates] +check = true +``` + +| Key | Value | Description | +|---|---|---| +| `check` | `true` | Check for new releases (default) | +| `check` | `false` | Disable automatic upgrade checks | The `--no-upgrade-check` CLI flag overrides this for a single invocation. See [`fabro upgrade`](/reference/cli#fabro-upgrade) for manual upgrades. -## `verbose` +## `[cli.output]` -Enable verbose output by default for `fabro run start` and `fabro doctor`, without passing `-v` every time. +Generic CLI output defaults. -| Value | Description | -|---|---| -| `true` | Verbose output on by default | -| `false` | Normal output (default) | +```toml title="settings.toml" +[cli.output] +format = "text" +verbosity = "verbose" +``` + +| Key | Values | Default | +|---|---|---| +| `format` | `"text"`, `"json"` | `"text"` | +| `verbosity` | `"quiet"`, `"normal"`, `"verbose"` | `"normal"` | The `-v` / `--verbose` CLI flag always takes effect regardless of this setting. -## `[exec]` section +## `[cli.exec]` section Defaults for `fabro exec` sessions. +```toml title="settings.toml" +[cli.exec] +prevent_idle_sleep = true + +[cli.exec.model] +provider = "anthropic" +name = "claude-opus-4-6" + +[cli.exec.agent] +permissions = "read-write" +``` + +`[cli.exec.model]` selects the default LLM for exec: + +| Key | Description | Values | +|---|---|---| +| `provider` | LLM provider | `"anthropic"`, `"openai"`, `"gemini"`, etc. | +| `name` | Model name | Any model ID from `fabro model list` | + +`[cli.exec.agent]` controls agent behavior during exec: + | Key | Description | Values | Default | |---|---|---|---| -| `provider` | LLM provider | `"anthropic"`, `"openai"`, `"gemini"`, etc. | `"anthropic"` | -| `model` | Model name | Any model ID from `fabro model list` | Per provider | | `permissions` | Tool permission level | `"read-only"`, `"read-write"`, `"full"` | `"read-write"` | -| `output_format` | Output format | `"text"`, `"json"` | `"text"` | ### Permission levels @@ -134,62 +187,91 @@ Defaults for `fabro exec` sessions. Tools outside the permission level are interactively prompted (if a TTY is present) or denied (with `--auto-approve`). -### Output formats - -- **`text`** — human-readable terminal output -- **`json`** — NDJSON event stream - -## `[llm]` section +## `[run.model]` section Defaults for workflow model selection in commands like `fabro run` and `fabro preflight`. +```toml title="settings.toml" +[run.model] +provider = "anthropic" +name = "claude-sonnet-4-5" +fallbacks = ["openai", "gpt-5.4", "gemini/gemini-flash"] +``` + | Key | Description | Values | Default | |---|---|---|---| -| `model` | Model name | Any model ID from `fabro model list` | Per provider | +| `name` | Model name | Any model ID from `fabro model list` | Per provider | | `provider` | Provider name | `"anthropic"`, `"openai"`, `"gemini"`, etc. | Auto-inferred from model/catalog | +| `fallbacks` | Ordered list of fallback model references | bare provider, bare alias, or `provider/model` | `[]` | -Use `[exec]` to configure provider, permissions, and output format for `fabro exec`. Use `[llm]` for workflow-oriented defaults. +Use `[cli.exec.model]` to configure provider and model for `fabro exec`. Use `[run.model]` for workflow-oriented defaults. -## `[log]` section +## `[cli.logging]` section -Configure the default log level. Precedence: `FABRO_LOG` env var > `--debug` flag > `[log]` level > `"info"`. +Configure the default CLI log level. Precedence: `FABRO_LOG` env var > `--debug` flag > `[cli.logging].level` > `"info"`. -| Key | Description | Values | Default | -|---|---|---|---| -| `level` | Log level | `"error"`, `"warn"`, `"info"`, `"debug"`, `"trace"` | `"info"` | +```toml title="settings.toml" +[cli.logging] +level = "info" +``` -## `[git]` section +| Key | Values | Default | +|---|---|---| +| `level` | `"error"`, `"warn"`, `"info"`, `"debug"`, `"trace"` | `"info"` | -### `[git.author]` +Server-side logging is a separate namespace at `[server.logging]`. -Customize the git author identity used for checkpoint commits. On same-machine setups, the CLI and server read the same `[git.author]` value. On remote setups, each machine uses its own local `settings.toml`. +## `[run.git.author]` + +Customize the git author identity used for checkpoint commits. + +```toml title="settings.toml" +[run.git.author] +name = "fabro-bot" +email = "fabro-bot@company.com" +``` | Key | Description | Default | |---|---|---| | `name` | Git author name | `"fabro"` | | `email` | Git author email | `"fabro@local"` | -## `[server]` section +## `[cli.target]` section Connection info for commands that target a remote Fabro server. -| Key | Description | Default | -|---|---|---| -| `target` | Server target: `http(s)` URL or absolute Unix socket path | none | +```toml title="settings.toml" +[cli.target] +type = "http" +url = "https://fabro.example.com:3000/api/v1" +``` -`fabro model` uses `[server].target` by default when no explicit `--storage-dir` is passed. An explicit `--server` flag overrides `server.target`: +| Key | Description | +|---|---| +| `type` | `"http"` or `"unix"` — explicit transport selection | +| `url` | Required for `type = "http"` — the API base URL | +| `path` | Required for `type = "unix"` — the absolute Unix socket path | + +`fabro model` uses `[cli.target]` by default when no explicit `--storage-dir` is passed. An explicit `--server` flag overrides the configured target: ```bash fabro model list --server https://fabro.example.com:3000/api/v1 ``` -`fabro exec` does not automatically use `[server].target`. It only routes model traffic through a Fabro server when you pass `--server` for that invocation. +`fabro exec` does not automatically use `[cli.target]`. It only routes model traffic through a Fabro server when you pass `--server` for that invocation. -### `[server.tls]` section +### `[cli.target.tls]` section -Optional mTLS configuration for authenticating with the server. When present, the CLI presents a client certificate during the TLS handshake. +Optional mTLS configuration for authenticating with an HTTP target. When present, the CLI presents a client certificate during the TLS handshake. + +```toml title="settings.toml" +[cli.target.tls] +cert = "~/.fabro/tls/client.crt" +key = "~/.fabro/tls/client.key" +ca = "~/.fabro/tls/ca.crt" +``` | Key | Description | |---|---| @@ -197,46 +279,42 @@ Optional mTLS configuration for authenticating with the server. When present, th | `key` | Path to client private key PEM file | | `ca` | Path to CA certificate PEM file (to verify the server) | -Paths support `~/` expansion. Example: +Paths support `~/` expansion. + +## `[run.pull_request]` + +Enable auto-PR globally so workflows open a GitHub pull request on successful completion. ```toml title="settings.toml" -[server.tls] -cert = "~/.fabro/tls/client.crt" -key = "~/.fabro/tls/client.key" -ca = "~/.fabro/tls/ca.crt" -``` - -## `[pull_request]` - -Enable auto-PR globally so workflows open a GitHub pull request on successful completion, even when running with a `.fabro` file instead of a `run.toml`. - -```toml title="settings.toml" -[pull_request] +[run.pull_request] enabled = true ``` | Key | Description | Default | |---|---|---| | `enabled` | Automatically create a PR after successful runs | `false` | +| `draft` | Open the PR as a draft | `true` | +| `auto_merge` | Enable GitHub auto-merge on the created PR (implies `draft = false`) | `false` | +| `merge_strategy` | One of `"squash"`, `"merge"`, `"rebase"` | `"squash"` | -Precedence: `run.toml` > `fabro.toml` > `settings.toml` > built-in default (`false`). +Precedence: `workflow.toml` > `fabro.toml` > `~/.fabro/settings.toml` > built-in default (`false`). -## `[mcp_servers]` section +## `[run.agent.mcps]` section -Configure [MCP servers](/agents/mcp) to connect to during `fabro exec` sessions. Each server is a named TOML table under `[mcp_servers]`. MCP servers can also be configured per-workflow in [run config TOML](/execution/run-configuration#mcp_servers). +Configure [MCP servers](/agents/mcp) to connect to during agent-driven runs. Each server is a named TOML table under `[run.agent.mcps]`. For `fabro exec`-only MCPs, use `[cli.exec.agent.mcps.*]` with the same shape. ### Stdio transport Spawn a local process and communicate over stdin/stdout: ```toml title="settings.toml" -[mcp_servers.filesystem] +[run.agent.mcps.filesystem] type = "stdio" command = ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/workspace"] -startup_timeout_secs = 15 -tool_timeout_secs = 90 +startup_timeout = "15s" +tool_timeout = "90s" -[mcp_servers.filesystem.env] +[run.agent.mcps.filesystem.env] NODE_ENV = "production" ``` @@ -245,19 +323,19 @@ NODE_ENV = "production" | `type` | Must be `"stdio"` | — | | `command` | Array: executable + arguments | — | | `env` | Additional environment variables for the child process | `{}` | -| `startup_timeout_secs` | Max seconds for the MCP handshake | `10` | -| `tool_timeout_secs` | Max seconds for a single tool call | `60` | +| `startup_timeout` | Max duration for the MCP handshake (e.g. `"10s"`, `"30s"`) | `"10s"` | +| `tool_timeout` | Max duration for a single tool call (e.g. `"60s"`, `"2m"`) | `"60s"` | ### HTTP transport Connect to a remote MCP server over Streamable HTTP: ```toml title="settings.toml" -[mcp_servers.sentry] +[run.agent.mcps.sentry] type = "http" url = "https://mcp.sentry.dev/mcp" -[mcp_servers.sentry.headers] +[run.agent.mcps.sentry.headers] Authorization = "Bearer sk-xxx" ``` @@ -266,20 +344,20 @@ Authorization = "Bearer sk-xxx" | `type` | Must be `"http"` | — | | `url` | The MCP server endpoint URL | — | | `headers` | Optional HTTP headers (for example, for authentication) | `{}` | -| `startup_timeout_secs` | Max seconds for the MCP handshake | `10` | -| `tool_timeout_secs` | Max seconds for a single tool call | `60` | +| `startup_timeout` | Max duration for the MCP handshake | `"10s"` | +| `tool_timeout` | Max duration for a single tool call | `"60s"` | ### Sandbox transport -Run an MCP server inside the workflow's sandbox and connect via preview URL. Only available with remote sandbox providers ([Daytona](/integrations/daytona)) that support port previews. Typically configure this in [run config TOML](/execution/run-configuration#mcp_servers) rather than `settings.toml`. +Run an MCP server inside the workflow's sandbox and connect via preview URL. Only available with remote sandbox providers ([Daytona](/integrations/daytona)) that support port previews. Typically configured in `workflow.toml` rather than `settings.toml`: -```toml title="run.toml" -[mcp_servers.playwright] +```toml title="workflow.toml" +[run.agent.mcps.playwright] type = "sandbox" command = ["npx", "@playwright/mcp@latest", "--port", "3100", "--headless"] port = 3100 -startup_timeout_secs = 60 -tool_timeout_secs = 120 +startup_timeout = "60s" +tool_timeout = "2m" ``` | Key | Description | Default | @@ -288,7 +366,7 @@ tool_timeout_secs = 120 | `command` | Array: the command to run inside the sandbox | — | | `port` | Port the server listens on inside the sandbox | — | | `env` | Additional environment variables for the server process | `{}` | -| `startup_timeout_secs` | Max seconds for startup + MCP handshake | `10` | -| `tool_timeout_secs` | Max seconds for a single tool call | `60` | +| `startup_timeout` | Max duration for startup + MCP handshake | `"10s"` | +| `tool_timeout` | Max duration for a single tool call | `"60s"` | See [MCP — Sandbox transport](/agents/mcp#sandbox) for how Fabro launches and connects to sandbox MCP servers. diff --git a/docs/workflows/variables.mdx b/docs/workflows/variables.mdx index 345375ec3..c0a0d72bb 100644 --- a/docs/workflows/variables.mdx +++ b/docs/workflows/variables.mdx @@ -5,22 +5,26 @@ description: "Using variables in workflows" Fabro supports `$variable` placeholders that let you parameterize workflows without editing the Graphviz file. -## Run config variables +## Run config inputs -Define variables in the `[vars]` section of a run config TOML file: +Define inputs in the `[run.inputs]` section of a run config TOML file: ```toml title="run.toml" -version = 1 -goal = "Run tests for $repo_name" +_version = 1 + +[workflow] graph = "check.fabro" -[vars] +[run] +goal = "Run tests for $repo_name" + +[run.inputs] repo_name = "fabro" repo_url = "https://github.com/fabro-sh/fabro" language = "rust" ``` -These variables are expanded into the Graphviz source **before** the graph is parsed. You can use `$variable` anywhere in the Graphviz file — goals, prompts, labels, scripts, or any other attribute: +These inputs are expanded into the Graphviz source **before** the graph is parsed. You can use `$variable` anywhere in the Graphviz file — goals, prompts, labels, scripts, or any other attribute: ```dot title="check.fabro" digraph Check { @@ -40,7 +44,7 @@ When launched with `fabro run run.toml`, Fabro replaces `$repo_name`, `$repo_url ### Undefined variables -If a `$variable` in the Graphviz file has no matching entry in `[vars]`, Fabro raises an error. This catches typos early — a misspelled `$langauge` fails immediately rather than passing a literal `$langauge` to the LLM. +If a `$variable` in the Graphviz file has no matching entry in `[run.inputs]`, Fabro raises an error. This catches typos early — a misspelled `$langauge` fails immediately rather than passing a literal `$langauge` to the LLM. ### Escaping `$` @@ -66,11 +70,15 @@ digraph Example { The plan node's prompt becomes `"Create a plan for: Implement the login feature"`. -## Variable merging +## Input merging -When using server-level run defaults alongside a run config TOML, variables are merged. Task config vars override default vars when keys collide: +`[run.inputs]` intentionally replaces the inherited map wholesale rather than merging by key. Whichever layer has the highest precedence and sets `[run.inputs]` wins its entire map — lower-precedence inputs do not show through. | Source | Priority | |---|---| -| Run config TOML `[vars]` | Highest — wins on collision | -| Server defaults `[vars]` | Lowest — provides fallback values | +| CLI flags (`-V key=value`, repeated) | Highest | +| `workflow.toml` `[run.inputs]` | | +| `fabro.toml` `[run.inputs]` | | +| `~/.fabro/settings.toml` `[run.inputs]` | Lowest | + +If you need per-key overrides on top of inherited defaults, set each input explicitly in the winning layer. From 3dd3c7bf8bc77cf78a0f69173869d63e095bec79 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 9 Apr 2026 11:35:44 -0400 Subject: [PATCH 11/47] refactor(config): delete unused legacy shim modules, document transitional seam Stage 6 initial cleanup. Removes two fabro-config shim modules that no longer hold any code and adds a module-level comment to fabro-types/src/settings/mod.rs documenting the transitional seam between the flat legacy Settings shape and the authoritative v2 namespaced schema in fabro_types::settings::v2. Deleted: - fabro-config/src/combine.rs: was a one-line re-export of fabro_types::combine::Combine; nothing imports it anymore - fabro-config/src/settings.rs: was reduced to a header comment after Stage 3 replaced TryFrom for Settings with ConfigLayer::resolve via the v2 bridge Stage 6 full deletion (legacy flat Settings type, the bridge, the old settings/{hook,mcp,project,run,sandbox,server,user}.rs modules, plus the Combine trait derive) is scheduled for a follow-up PR that migrates every consumer call site from the flat settings.llm / .vars / .sandbox / .setup / .hooks / .mcp_servers / .goal / .work_dir / .github / .git / .pull_request / .checkpoint / .artifacts fields to the v2 SettingsFile tree. That touches ~128 call sites across ~15 files and is a mechanical but large follow-up; the current bridge is the safe intermediate state. --- lib/crates/fabro-config/src/combine.rs | 1 - lib/crates/fabro-config/src/lib.rs | 2 -- lib/crates/fabro-config/src/settings.rs | 5 ----- lib/crates/fabro-types/src/settings/mod.rs | 19 +++++++++++++++++++ 4 files changed, 19 insertions(+), 8 deletions(-) delete mode 100644 lib/crates/fabro-config/src/combine.rs delete mode 100644 lib/crates/fabro-config/src/settings.rs diff --git a/lib/crates/fabro-config/src/combine.rs b/lib/crates/fabro-config/src/combine.rs deleted file mode 100644 index 775d76aca..000000000 --- a/lib/crates/fabro-config/src/combine.rs +++ /dev/null @@ -1 +0,0 @@ -pub use fabro_types::combine::*; diff --git a/lib/crates/fabro-config/src/lib.rs b/lib/crates/fabro-config/src/lib.rs index 333202b75..ea640c56b 100644 --- a/lib/crates/fabro-config/src/lib.rs +++ b/lib/crates/fabro-config/src/lib.rs @@ -1,6 +1,5 @@ extern crate self as fabro_config; -pub mod combine; pub mod config; pub mod effective_settings; pub mod home; @@ -12,7 +11,6 @@ pub mod project; pub mod run; pub mod sandbox; pub mod server; -pub mod settings; pub mod storage; pub mod user; diff --git a/lib/crates/fabro-config/src/settings.rs b/lib/crates/fabro-config/src/settings.rs deleted file mode 100644 index e6a0a341b..000000000 --- a/lib/crates/fabro-config/src/settings.rs +++ /dev/null @@ -1,5 +0,0 @@ -//! Empty module retained for backwards-compatible imports. -//! -//! The legacy `TryFrom for Settings` impl was replaced by -//! [`crate::ConfigLayer::resolve`], which delegates to the v2 bridge in -//! `fabro_types::settings::v2::bridge`. Stage 6 deletes this file entirely. diff --git a/lib/crates/fabro-types/src/settings/mod.rs b/lib/crates/fabro-types/src/settings/mod.rs index 1393e6845..3545ab41a 100644 --- a/lib/crates/fabro-types/src/settings/mod.rs +++ b/lib/crates/fabro-types/src/settings/mod.rs @@ -1,3 +1,22 @@ +//! Legacy flat `Settings` shape plus the v2 namespaced schema. +//! +//! The authoritative config schema lives in [`v2`] — it is the namespaced +//! parse tree that `_version = 1` TOML files decode into. Value-language +//! helpers, the merge matrix, and strict unknown-key validation all live +//! there. +//! +//! The flat [`Settings`] type and its submodules (`hook`, `mcp`, `project`, +//! `run`, `sandbox`, `server`, `user`) are the **resolved** shape that +//! current consumers still read. `fabro_config::ConfigLayer::resolve` walks +//! the v2 tree through [`v2::bridge::bridge_to_old`] to produce this flat +//! shape, so every consumer that touches `settings.llm`, `settings.vars`, +//! `settings.sandbox`, etc. keeps working. +//! +//! Full deletion of the flat shape (including the bridge) is scheduled for +//! a follow-up PR that migrates every consumer call site to read from +//! [`v2::SettingsFile`] directly. This module deliberately stays as a +//! transitional seam until then. + use std::collections::HashMap; use std::path::PathBuf; From 31db613aa051c9b91c31335a012522132fa0c01f Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 9 Apr 2026 11:38:00 -0400 Subject: [PATCH 12/47] docs(config): point new code at ConfigLayer::as_v2 rather than the bridge --- lib/crates/fabro-config/src/config.rs | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/lib/crates/fabro-config/src/config.rs b/lib/crates/fabro-config/src/config.rs index 023bb7dc8..1656b43d5 100644 --- a/lib/crates/fabro-config/src/config.rs +++ b/lib/crates/fabro-config/src/config.rs @@ -1,15 +1,16 @@ //! v2-backed configuration layer. //! -//! `ConfigLayer` is now a newtype over [`SettingsFile`] — the v2 namespaced -//! parse tree in `fabro_types::settings::v2`. Loading functions return the -//! same `ConfigLayer` type they always have; internally they call -//! `parse_settings_file`, which hard-fails on any legacy top-level key with a -//! targeted rename hint. +//! `ConfigLayer` is a newtype over [`SettingsFile`] — the v2 namespaced +//! parse tree in `fabro_types::settings::v2`. Loading functions (`parse`, +//! `load`, `for_workflow`, `project`, `settings`) all hard-fail on legacy +//! top-level keys with targeted rename hints. `ConfigLayer::combine` walks +//! the v2 merge matrix from [`crate::merge`]. //! -//! `ConfigLayer::combine` walks the v2 merge matrix from `crate::merge`. -//! `ConfigLayer::resolve` uses the temporary bridge in -//! `fabro_types::settings::v2::bridge` to produce the legacy flat [`Settings`] -//! shape until Stage 4 migrates consumers off it. +//! [`ConfigLayer::resolve`] uses the transitional bridge in +//! [`fabro_types::settings::v2::bridge`] to produce the legacy flat +//! [`Settings`] shape that most consumers still read. New code should prefer +//! [`ConfigLayer::as_v2`] to read v2 fields directly; the bridge and the old +//! flat shape are scheduled for removal once every consumer is migrated. use std::path::Path; From c6d515be44d3b9c3979fc6c6151d31c23221be45 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 9 Apr 2026 11:44:56 -0400 Subject: [PATCH 13/47] fix(lint): clean up fabro-config test clippy warnings - Drop the fabro_types::Combine re-export from fabro-config/lib.rs (unused externally after Stage 3 replaced the legacy Combine-based merge with the v2 merge matrix) - Replace absolute `fabro_types::settings::v2::InterpString` paths in fabro-config/src/config.rs and merge.rs test blocks with a scoped `use` import, satisfying clippy::absolute_paths - fabro-config/src/merge.rs tests: use `!contains_key`, drop redundant closures around InterpString::as_source, prefer indexing over get().unwrap() on the notifications HashMap - fabro-config/src/project.rs tests: switch the run.execution.retros fixture off raw string literal hashes (only simple content inside) and use ToString::to_string in the error-chain join expression --- lib/crates/fabro-config/src/config.rs | 6 ++++-- lib/crates/fabro-config/src/lib.rs | 1 - lib/crates/fabro-config/src/merge.rs | 25 +++++++++++++++++++------ lib/crates/fabro-config/src/project.rs | 6 +++--- 4 files changed, 26 insertions(+), 12 deletions(-) diff --git a/lib/crates/fabro-config/src/config.rs b/lib/crates/fabro-config/src/config.rs index 1656b43d5..fa27d71bd 100644 --- a/lib/crates/fabro-config/src/config.rs +++ b/lib/crates/fabro-config/src/config.rs @@ -151,6 +151,8 @@ impl ConfigLayer { #[cfg(test)] mod tests { + use fabro_types::settings::v2::InterpString; + use super::*; #[test] @@ -179,7 +181,7 @@ goal = "Do things" .run .as_ref() .and_then(|r| r.goal.as_ref()) - .map(fabro_types::settings::v2::InterpString::as_source) + .map(InterpString::as_source) .as_deref(), Some("Do things") ); @@ -210,7 +212,7 @@ goal = "lower goal" .run .as_ref() .and_then(|r| r.goal.as_ref()) - .map(fabro_types::settings::v2::InterpString::as_source) + .map(InterpString::as_source) .as_deref(), Some("higher goal") ); diff --git a/lib/crates/fabro-config/src/lib.rs b/lib/crates/fabro-config/src/lib.rs index ea640c56b..526545afd 100644 --- a/lib/crates/fabro-config/src/lib.rs +++ b/lib/crates/fabro-config/src/lib.rs @@ -15,7 +15,6 @@ pub mod storage; pub mod user; pub use config::ConfigLayer; -pub use fabro_types::Combine; pub use fabro_util::path::expand_tilde; pub use home::Home; pub use storage::{RunScratch, ServerState, Storage}; diff --git a/lib/crates/fabro-config/src/merge.rs b/lib/crates/fabro-config/src/merge.rs index 2fdd3a47a..9ebc78077 100644 --- a/lib/crates/fabro-config/src/merge.rs +++ b/lib/crates/fabro-config/src/merge.rs @@ -479,8 +479,9 @@ fn combine_server_integrations( #[cfg(test)] mod tests { + use fabro_types::settings::v2::{InterpString, parse_settings_file}; + use super::*; - use fabro_types::settings::v2::parse_settings_file; fn parse(input: &str) -> SettingsFile { parse_settings_file(input).expect("fixture should parse") @@ -505,7 +506,7 @@ a = "higher" let inputs = merged.run.unwrap().inputs.unwrap(); assert_eq!(inputs.len(), 1); assert_eq!(inputs.get("a"), Some(&toml::Value::String("higher".into()))); - assert!(inputs.get("b").is_none(), "lower key should be gone"); + assert!(!inputs.contains_key("b"), "lower key should be gone"); } #[test] @@ -593,7 +594,11 @@ script = "higher-script" let hooks = merged.run.unwrap().hooks; assert_eq!(hooks.len(), 1); assert_eq!( - hooks[0].script.as_ref().map(|s| s.as_source()).as_deref(), + hooks[0] + .script + .as_ref() + .map(InterpString::as_source) + .as_deref(), Some("higher-script") ); } @@ -618,11 +623,19 @@ script = "higher-anon" let hooks = merged.run.unwrap().hooks; assert_eq!(hooks.len(), 2); assert_eq!( - hooks[0].script.as_ref().map(|s| s.as_source()).as_deref(), + hooks[0] + .script + .as_ref() + .map(InterpString::as_source) + .as_deref(), Some("lower-anon") ); assert_eq!( - hooks[1].script.as_ref().map(|s| s.as_source()).as_deref(), + hooks[1] + .script + .as_ref() + .map(InterpString::as_source) + .as_deref(), Some("higher-anon") ); } @@ -643,7 +656,7 @@ events = ["...", "run.completed"] ); let merged = combine_files(lower, higher); let run = merged.run.unwrap(); - let events = &run.notifications.get("ops").unwrap().events; + let events = &run.notifications["ops"].events; assert_eq!(events.len(), 2); } diff --git a/lib/crates/fabro-config/src/project.rs b/lib/crates/fabro-config/src/project.rs index 519e479d7..b04ac94a5 100644 --- a/lib/crates/fabro-config/src/project.rs +++ b/lib/crates/fabro-config/src/project.rs @@ -415,12 +415,12 @@ directory = "fabro/" #[test] fn parse_with_run_execution_retros() { let config = parse_project_config( - r#" + " _version = 1 [run.execution] retros = true -"#, +", ) .unwrap(); assert_eq!( @@ -449,7 +449,7 @@ retros = true let err = parse_project_config("_version = 2\n").unwrap_err(); let chain: String = err .chain() - .map(|e| e.to_string()) + .map(ToString::to_string) .collect::>() .join("; "); assert!( From 7d83f2448c7086b46eb5e4700710b4f71db027b2 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 9 Apr 2026 11:54:44 -0400 Subject: [PATCH 14/47] docs(plans): write Stage 6 handoff for settings TOML redesign Comprehensive handoff document that the engineer picking up the settings redesign can use to continue the work. Captures: - TL;DR of what's landed vs what remains - Source document references (brainstorm, plan, canonical example) - Current-state tree map with file-level annotations - Full dependency chain from TOML -> v2 -> bridge -> consumers - Commit log for Stages 1-5 (13 commits, 76 files, +6413/-2151) - Stage 6 broken into six independent subtasks: consumer migration, bridge deletion, legacy type deletion, fabro-config shim deletion, v2 namespace flattening, OpenAPI + clients + fabro-web rewrite - Concrete before/after migration patterns with InterpString examples - Testing gotchas I hit during Stages 1-5: fabro-cli parallel test daemon FD limit, insta snapshot pending review, hook shorthand vs #[serde(flatten)] duplicate-command collision, fabro-test managed marker detection, apply_server_defaults full-shape propagation, user layer trust boundary, pre-existing fabro-interview test clippy warnings - Open design questions the next engineer needs to decide: ConfigLayer resolve naming, post-layering interpolation pass, fail-closed auth, ModelRegistry runtime wiring, run.scm provider depth, serde flatten+ HashMap+deny_unknown_fields constraint - Verification recipes (full gate, legacy key sanity grep, bridge caller sanity grep) - Explicit success criteria for Stage 6 completion --- ...26-04-09-settings-toml-redesign-handoff.md | 608 ++++++++++++++++++ 1 file changed, 608 insertions(+) create mode 100644 docs/plans/2026-04-09-settings-toml-redesign-handoff.md diff --git a/docs/plans/2026-04-09-settings-toml-redesign-handoff.md b/docs/plans/2026-04-09-settings-toml-redesign-handoff.md new file mode 100644 index 000000000..4a4206125 --- /dev/null +++ b/docs/plans/2026-04-09-settings-toml-redesign-handoff.md @@ -0,0 +1,608 @@ +--- +date: 2026-04-09 +status: active +topic: settings-toml-redesign +predecessor: docs/plans/2026-04-08-settings-toml-redesign-implementation-plan.md +--- + +# Settings TOML Redesign — Handoff to Stage 6 Follow-up + +## TL;DR + +Stages 1–5 of the settings TOML redesign landed on `main` across 13 commits. The +user-facing hard cut is complete: every Fabro config file now parses against +the v2 namespaced schema, legacy top-level keys hard-fail with targeted rename +hints, the merge matrix is implemented per the normative requirements doc, +trust boundaries work across all three resolution modes, all scaffolds and +docs are migrated, and the workspace is 100% tests-green (**3,760 passed / 0 +failed**), clippy-clean, and correctly formatted. + +The remaining work is **Stage 6: delete the legacy flat `Settings` shape and +the transitional `bridge_to_old` seam**, plus the OpenAPI + generated clients ++ fabro-web DTO rewrite that was explicitly deferred from Stage 5. This +document is everything you need to continue the work in a fresh session. + +## Source documents + +Read these before starting, in order: + +1. **Requirements (authoritative)** — + [`docs/brainstorms/2026-04-08-settings-toml-redesign-requirements.md`](./../brainstorms/2026-04-08-settings-toml-redesign-requirements.md). + This is the source of truth for the v2 schema, merge matrix, trust + boundaries, and disable semantics. Refer to requirement numbers (R1–R90) + when you change schema rules so decisions stay traceable. + +2. **Original implementation plan** — + [`docs/plans/2026-04-08-settings-toml-redesign-implementation-plan.md`](./2026-04-08-settings-toml-redesign-implementation-plan.md). + This is the 6-stage sequence and the scope of what needs to land. Stage 6 + in that document is the list of things this handoff still owes. + +3. **Representative canonical example** — the `representative_full_tree_parses` + test in + [`lib/crates/fabro-types/src/settings/v2/tree.rs`](../../lib/crates/fabro-types/src/settings/v2/tree.rs#L295-L422). + If you want a feel for how the whole v2 schema fits together, read this + fixture before anything else. + +## Current-state map + +### What the tree looks like at handoff + +``` +lib/crates/fabro-types/src/settings/ +├── mod.rs — transitional seam; hosts legacy flat Settings + +│ module comment explaining the deletion plan +├── v2/ — authoritative v2 schema (Stages 1–2 output) +│ ├── mod.rs — module root; re-exports +│ ├── tree.rs — SettingsFile top-level; parse_settings_file(), +│ │ ParseError with rename-hint table +│ ├── version.rs — _version pre-validation +│ ├── project.rs — ProjectLayer +│ ├── workflow.rs — WorkflowLayer +│ ├── run.rs — RunLayer + all run subtree types (536 LOC) +│ ├── cli.rs — CliLayer + cli subtree types +│ ├── server.rs — ServerLayer + server subtree types +│ ├── features.rs — FeaturesLayer +│ ├── duration.rs — Duration value-language helper +│ ├── size.rs — Size value-language helper +│ ├── model_ref.rs — ModelRef + ambiguity resolution +│ ├── interp.rs — InterpString with provenance tagging +│ ├── splice_array.rs — SpliceArray "..." marker +│ └── bridge.rs — TRANSITIONAL: bridge_to_old(&SettingsFile)->Settings +│ (~820 LOC — this is the thing Stage 6 deletes) +├── hook.rs, mcp.rs, project.rs, run.rs, sandbox.rs, server.rs, user.rs +│ — LEGACY flat type definitions. Delete in Stage 6. +└── (combine trait is in ../combine.rs — also legacy, also deletes) + +lib/crates/fabro-config/ +├── lib.rs — module tree (note: combine.rs + settings.rs +│ deleted in Stage 6 initial cleanup) +├── config.rs — ConfigLayer newtype over SettingsFile; exposes +│ ::parse/::load/::combine/::resolve/::as_v2 +├── merge.rs — v2 merge matrix implementation (683 LOC, +│ covers every row of the normative table) +├── effective_settings.rs — EffectiveSettingsLayers + resolve_settings +│ with LocalOnly/RemoteServer/LocalDaemon modes +│ and trust-boundary stripping +├── project.rs — workflow discovery + resolve_fabro_root +├── user.rs — load_settings_config + legacy file warnings +├── run.rs — parse_run_config + resolve_env_refs helper + +│ re-export shim of resolved run types +├── sandbox.rs, server.rs, — THIN re-export shims. Stage 6 deletes these +│ hook.rs, mcp.rs once consumers stop importing through them. +├── storage.rs — unrelated; stays +├── home.rs — 1-line Home re-export +└── legacy_env.rs — 12-line legacy env var helper +``` + +### Dependency chain to understand + +``` +TOML file + │ + ▼ parse_settings_file() (fabro-types/src/settings/v2/tree.rs) +SettingsFile (v2) + │ + ▼ combine_files() (fabro-config/src/merge.rs) +SettingsFile (v2, merged) + │ + ▼ bridge_to_old() (fabro-types/src/settings/v2/bridge.rs) +Settings (legacy flat) + │ + ▼ every consumer that reads (~84 call sites across 15 files) + settings.llm, settings.vars, settings.sandbox, ... +``` + +The **bridge is the only producer of the legacy flat `Settings` shape**. +Removing it requires every reader to consume `SettingsFile` directly. + +## Commit log (Stages 1–5 landed on `main`) + +``` +c6d515be4 fix(lint): clean up fabro-config test clippy warnings +31db613aa docs(config): point new code at ConfigLayer::as_v2 rather than the bridge +3dd3c7bf8 refactor(config): delete unused legacy shim modules, document transitional seam +dba10e5e9 docs: migrate reference and guide examples to v2 config shape +b57248236 test(migration): land final Stage 4 fixes — 100% workspace tests green +2fc85282b fix(effective_settings): keep cli/server stanzas from user settings.toml +a6047250c fix(lint): clippy cleanup for Stage 3/4 consumer migration +f4a79b896 test(cli): migrate remaining config/exec/create fixtures to v2 +f467bd23c fix(bridge): use hook command shorthand to avoid duplicate serde key +eabbca649 feat(tests): migrate fabro-cli fixtures and repo fabro.toml to v2 +a0eec6aee feat(config): switch parser and layering to v2 schema +bb228643e feat(types): flesh out v2 subtrees and add legacy bridge +288e73321 feat(types): add settings v2 parse tree scaffolding +``` + +Total: 76 files changed, +6,413 / -2,151 lines. + +## Stage 6 work breakdown + +Stage 6 has **six independent subtasks**. Each subtask can land as its own PR +on top of `main` — they have a natural dependency order but can be paused +between steps because the transitional bridge keeps the workspace building at +every intermediate state. + +### 6.1 — Migrate consumer read sites from flat `Settings` to v2 `SettingsFile` + +**Scope**: ~84 field-access sites across 15 files (grep below). + +**Files to touch** (ordered easy → hard): + +``` +lib/crates/fabro-workflow/src/run_options.rs — 5 sites, mostly behind accessor methods +lib/crates/fabro-workflow/src/operations/source.rs — 3 sites +lib/crates/fabro-workflow/src/operations/create.rs — ~8 sites, touches LLM mutation +lib/crates/fabro-workflow/src/operations/start.rs — ~10 sites, touches setup/hooks/llm +lib/crates/fabro-cli/src/commands/run/runner.rs — a few sites +lib/crates/fabro-cli/src/commands/exec.rs — a few sites +lib/crates/fabro-cli/src/manifest_builder.rs — 2 sites (goal, goal_file) +lib/crates/fabro-server/src/run_manifest.rs — ~11 sites in handlers + tests +lib/crates/fabro-server/src/server.rs — ~14 sites (biggest file) +lib/crates/fabro-server/src/web_auth.rs — ~20 sites (git settings heavy) +lib/crates/fabro-server/src/serve.rs — a few sites +lib/crates/fabro-config/src/effective_settings.rs — apply_server_defaults copies every field +lib/crates/fabro-config/src/project.rs — resolve_working_directory reads settings.work_dir +lib/crates/fabro-cli/tests/it/cmd/create.rs — 7 sites in assertions +lib/crates/fabro-cli/tests/it/cmd/runner.rs — 4 sites in assertions +``` + +Exact grep: + +```bash +grep -rn 'settings\.llm\|settings\.vars\|settings\.sandbox\|settings\.setup\|settings\.hooks\|settings\.checkpoint\|settings\.pull_request\|settings\.mcp_servers\|settings\.artifacts\|settings\.git\|settings\.exec\|settings\.fabro\|settings\.goal\|settings\.work_dir\|settings\.labels\|settings\.github' lib/crates --include='*.rs' +``` + +**Migration pattern** (before → after): + +```rust +// BEFORE (legacy flat) +let model = settings.llm.as_ref().and_then(|llm| llm.model.clone()); +let provider = settings.llm.as_ref().and_then(|llm| llm.provider.clone()); +``` + +```rust +// AFTER (v2 via ConfigLayer::as_v2()) +let model = layer + .as_v2() + .run + .as_ref() + .and_then(|r| r.model.as_ref()) + .and_then(|m| m.name.as_ref()) + .map(InterpString::as_source); +``` + +**Recommended sequence**: + +1. **Start with receive-side accessor methods** on `ConfigLayer` and + `RunOptions`. For every flat field that consumers read, add an accessor + method that walks the v2 tree. Land these additively (no caller changes + yet). Example: + ```rust + impl ConfigLayer { + pub fn run_model_name(&self) -> Option { + self.file.run.as_ref() + .and_then(|r| r.model.as_ref()) + .and_then(|m| m.name.as_ref()) + .map(InterpString::as_source) + } + } + ``` +2. **Migrate one caller at a time**, file-by-file, smallest first. After each + file: `cargo build -p ` + `cargo nextest run -p ` before + moving on. Do not try to cover 15 files at once — incremental commits. +3. **Delete the flat-field helper methods on `Settings`** as nothing reads + them. They are in + [`lib/crates/fabro-types/src/settings/mod.rs`](../../lib/crates/fabro-types/src/settings/mod.rs#L111-L196): + `app_id()`, `slug()`, `client_id()`, `git_author()`, `sandbox_settings()`, + `setup_settings()`, `setup_commands()`, `setup_timeout_ms()`, + `preserve_sandbox_enabled()`, `github_permissions()`, + `mcp_server_entries()`, `verbose_enabled()`, `prevent_idle_sleep_enabled()`, + `upgrade_check_enabled()`, `dry_run_enabled()`, `auto_approve_enabled()`, + `no_retro_enabled()`, `storage_dir()`, `slack_settings()`. These will + cascade compiler errors into callers that you can then migrate. + +**Gotchas**: + +- **`settings.vars` vs v2 `run.inputs`**: v2 replaces wholesale (R22). If a + consumer was relying on `vars` merging across layers, its behavior was + ambiguous before and is now explicit — it sees whichever layer set `inputs` + last. Check tests after migration. +- **`settings.setup.commands` vs v2 `run.prepare.steps`**: v2 replaces the + whole ordered list (R30). Several tests were re-asserted in Stage 4; + similar audits will be needed for any newly-migrated code path. +- **`settings.work_dir`** is the bridge output of `run.working_dir` + (`InterpString`). When consumers want the raw string, call + `InterpString::as_source()`. When they want an env-resolved value, call + `InterpString::resolve(|name| std::env::var(name).ok())` — the v2 + interpolation pass is not yet wired into the default resolve path. +- **`settings.github.permissions`** maps to + `server.integrations.github.permissions` in v2, which means it lives in + the owner-specific domain and is stripped from fabro.toml / workflow.toml + layers per R16. Consumers in `fabro-workflow` that read it will need to + either lift the read to a call site that has access to the server-local + layer, or accept that workflow-level config cannot ask for GitHub token + permissions. Flag this as an open design question if you hit it. + +### 6.2 — Delete the `bridge_to_old` seam + +**Files**: +- `lib/crates/fabro-types/src/settings/v2/bridge.rs` (818 LOC) — delete + entirely. +- `lib/crates/fabro-types/src/settings/v2/mod.rs` — drop the + `pub mod bridge;` and `pub use bridge::bridge_to_old;` lines. +- `lib/crates/fabro-config/src/config.rs` — delete the + `TryFrom for Settings` and `TryFrom<&ConfigLayer> for Settings` + impls, the `bridge_to_old` import, and change `ConfigLayer::resolve(self) -> + Settings` to `ConfigLayer::into_file(self) -> SettingsFile` (or just + encourage `From for SettingsFile` which already exists). + +**Prerequisite**: 6.1 must be complete — there must be zero readers of flat +`Settings` left. `git grep 'fabro_types::Settings\b'` should return nothing +outside of the legacy type definitions themselves. + +**Known consumer of `bridge_to_old`**: only `ConfigLayer::resolve` in +[`lib/crates/fabro-config/src/config.rs`](../../lib/crates/fabro-config/src/config.rs#L133-L140). +No external callers. This is the last thing to unwire before the bridge can +be deleted. + +### 6.3 — Delete the legacy flat types + +**Files to delete** (and remove from `mod.rs` re-export lists): + +``` +lib/crates/fabro-types/src/settings/mod.rs — Settings struct, impls, tests +lib/crates/fabro-types/src/settings/hook.rs — HookDefinition, HookEvent, HookType, HookSettings, TlsMode +lib/crates/fabro-types/src/settings/mcp.rs — McpServerEntry, McpServerSettings, McpTransport +lib/crates/fabro-types/src/settings/project.rs — ProjectSettings +lib/crates/fabro-types/src/settings/run.rs — LlmSettings, SetupSettings, CheckpointSettings, + PullRequestSettings, ArtifactsSettings, + GitHubSettings, MergeStrategy +lib/crates/fabro-types/src/settings/sandbox.rs — SandboxSettings, DaytonaSettings, DaytonaSnapshotSettings, + LocalSandboxSettings, DaytonaNetwork, WorktreeMode, + DockerfileSource +lib/crates/fabro-types/src/settings/server.rs — ApiSettings, WebSettings, GitSettings, GitAuthorSettings, + AuthSettings, AuthProvider, ApiAuthStrategy, TlsSettings, + WebhookSettings, WebhookStrategy, GitProvider, + FeaturesSettings, LogSettings, SlackSettings, + ArtifactStorageSettings, ArtifactStorageBackend +lib/crates/fabro-types/src/settings/user.rs — ClientTlsSettings, ExecSettings, OutputFormat, + PermissionLevel, ServerSettings +lib/crates/fabro-types/src/combine.rs — Combine trait (unused after deletes above) +lib/crates/fabro-macros/src/lib.rs — keep `#[derive(Combine)]` if any non-legacy use; + otherwise delete the derive macro entry +``` + +**Prerequisite**: 6.2 must be complete (bridge deleted). + +**Dependency chain**: `Combine` is used _only_ by legacy flat type derives +today. Search with +```bash +grep -rn '#\[derive(.*Combine\|impl Combine\|fabro_types::combine\|fabro_types::Combine' lib/crates --include='*.rs' +``` +If the only hits are inside `fabro-types/src/settings/*.rs` legacy files, the +trait + derive are safe to delete in the same PR. + +### 6.4 — Delete the `fabro-config` re-export shims + +**Files** (all are 1–62 LOC thin pass-throughs): + +``` +lib/crates/fabro-config/src/hook.rs — re-exports fabro_types::settings::hook::* +lib/crates/fabro-config/src/mcp.rs — re-exports fabro_types::settings::mcp::* +lib/crates/fabro-config/src/sandbox.rs — re-exports fabro_types::settings::sandbox::* +lib/crates/fabro-config/src/server.rs — re-exports fabro_types::settings::server::* + resolve_storage_dir() +lib/crates/fabro-config/src/user.rs — re-exports fabro_types::settings::user::* + path helpers +lib/crates/fabro-config/src/run.rs — re-exports fabro_types::settings::run::* + + parse_run_config + resolve_env_refs + resolve_graph_path +``` + +**Before deleting**, migrate callers off them. The callers are listed in the +file-level commit `3dd3c7bf8` — summary: `fabro-hooks`, `fabro-mcp`, +`fabro-sandbox`, `fabro-agent`, `fabro-cli`, `fabro-server`, +`fabro-workflow`, plus a handful of test files import via +`fabro_config::::...` paths. Each should import directly from +`fabro_types::settings::v2::...` once the legacy types are gone. + +**Retain**: +- `fabro-config/src/run.rs` **`resolve_graph_path()`** — still used, not + legacy. Move it to `fabro-config/src/project.rs` or `fabro-config/src/lib.rs`. +- `fabro-config/src/run.rs` **`parse_run_config()`** — still used by + `fabro-server/src/run_manifest.rs` and `fabro-cli/src/manifest_builder.rs`. + It's already a thin `ConfigLayer::parse` wrapper. Either keep it as a + top-level function in `fabro-config/src/lib.rs` or inline at call sites. +- `fabro-config/src/run.rs` **`resolve_env_refs()`** — the legacy minimal env + resolver. Once consumers use `InterpString::resolve` directly, delete. +- `fabro-config/src/user.rs` **path helpers** (`default_settings_path`, + `default_socket_path`, `active_settings_path`, legacy path helpers, + `load_settings_config`) — still used by CLI commands. Move them to + `fabro-config/src/lib.rs` or a new `fabro-config/src/paths.rs`. + +### 6.5 — Flatten `settings::v2::*` → `settings::*` + +Once Stages 6.3 + 6.4 are done and `fabro-types/src/settings/` only contains +the old `v2/` directory plus a mostly-empty `mod.rs`, rename everything to +be the primary namespace: + +``` +fabro-types/src/settings/ +├── mod.rs (re-exports direct from subdirs, no more v2 prefix) +├── tree.rs +├── version.rs +├── project.rs +├── workflow.rs +├── run.rs +├── cli.rs +├── server.rs +├── features.rs +├── duration.rs +├── size.rs +├── model_ref.rs +├── interp.rs +└── splice_array.rs +``` + +Rewrite imports across the workspace — `use fabro_types::settings::v2::...` +becomes `use fabro_types::settings::...`. + +**Recommendation**: one big mechanical commit with just the rename; do not +mix with behavior changes. + +### 6.6 — Rewrite OpenAPI contracts + regenerate clients + fix fabro-web + +This is the piece that was explicitly deferred from Stage 5 because the +current bridge-backed `/api/v1/settings` response still works against the +existing `ServerSettings` schema. Owning the explicit allow-list DTOs is the +end-state the plan calls for (requirements doc "Validation Boundary" + +implementation plan Stage 5). + +**Files to rewrite**: + +- `docs/api-reference/fabro-api.yaml` — replace the current flat + `ServerSettings` schema (lines ~4238–4364) and `RunSettings` schema + (lines ~3995–4032) with explicit allow-list DTOs. The allow-lists are + spelled out in the implementation plan under "Rebuild resolution, trust + boundaries, and safe serialization": + - **`/api/v1/settings` (server scope)**: allow only `server.api.url`, + `server.web.enabled`, `server.web.url`, per-provider enabled state for + `server.auth.web.providers.*`, and non-secret `server.scheduler` values. + Deny everything else — notably `server.listen.*`, `server.listen.tls.*`, + `server.auth.api`, `server.integrations.*`, `server.artifacts*`, + `server.slatedb*`, any local SecretStore paths, and any env-resolved + values tagged via `InterpString` provenance. + - **`/api/v1/runs/{id}/settings` (run scope)**: allow the resolved `run.*` + tree. Deny: any `InterpString` value whose resolution provenance shows + it was sourced from `${env.NAME}`, provider-credential fields under + `run.notifications.*.`, env values under + `run.agent.mcps.*.env` that were env-interpolated, and any field + explicitly marked sensitive. Deny all `project.*`, `workflow.*`, + `cli.*`, and `server.*` — they're not part of a run view. +- **Then regenerate**: + - Rust progenitor client: `cargo build -p fabro-api` (auto-runs via + `build.rs`). + - TypeScript client: `cd lib/packages/fabro-api-client && bun run generate`. +- **Update fabro-web**: + - `apps/fabro-web/app/routes/workflow-detail.tsx` has a static + `workflowData` literal (lines 18+) typed as `RunSettings`. Rewrite each + entry to match the new run-scope DTO shape. The live `/settings` and + `/runs/:id/settings` routes use `JSON.stringify` and are shape-agnostic — + they don't need code changes, just the type alignment that falls out of + the client regen. +- **Update server handlers**: + - `lib/crates/fabro-server/src/server.rs` `get_server_settings` (around + line 1062) currently serializes the flat Settings into the legacy + `ServerSettings` shape via `serde_json::to_value` and `strip_nulls`. + Rewrite to build the new allow-list DTO explicitly from + `state.settings` — it must _not_ use `serde_json::to_value` on the full + Settings, otherwise the allow-list is leaky. There is a redaction + helper path in `fabro-types/src/settings/v2/interp.rs` + (`Provenance::EnvSourced`) — consult it when you build the run-scope DTO. + - `/api/v1/runs/:id/settings` currently returns `not_implemented` in the + real (non-demo) router (grep `server.rs:1012`). The run-scope DTO rebuild + is the same mechanical shape as the server-scope one, just different + fields. The demo router wires `demo::get_run_settings` around + `server.rs:934` — don't confuse the two during migration. + +**Provenance redaction helper you'll need**: + +`InterpString::resolve` returns a `Resolved { value, provenance }`. When +`provenance == Provenance::EnvSourced`, the caller knows the field came +from an env var and must redact it before serializing into the run-scope +DTO. If you find yourself building the same `Resolved` → DTO conversion in +multiple handlers, pull it into `fabro-types/src/settings/v2/redact.rs` as +a new helper module. + +## Verification recipe (run on every incremental step) + +```bash +# full gate — must stay green between every sub-step +cargo fmt --check --all +cargo build --workspace +cargo clippy --workspace -- -D warnings +ulimit -n 4096 && cargo nextest run --workspace +cd apps/fabro-web && bun run typecheck && bun test && cd - + +# sanity: no legacy top-level TOML keys remain in real config files +git grep -n '^version = 1' -- docs/ lib/ apps/ fabro/ test/ | \ + grep -v 'changelog\|_version' + +# sanity: after Stage 6.2 the bridge should have no callers +git grep 'bridge_to_old' lib/ +``` + +## Testing gotchas I hit + +These are lessons learned during Stages 1–5. Save yourself the pain. + +1. **`fabro-cli` integration tests use a shared CLI test daemon under + parallel nextest load**. Raise the shell FD limit and cap threads: + ```bash + ulimit -n 4096 + cargo nextest run -p fabro-cli --no-fail-fast --test-threads=4 + ``` + macOS inherited sessions default to `ulimit -n 256`, which surfaces as + misleading EMFILE test timeouts. + +2. **Insta snapshots** — when you update a snapshot, check the pending + diffs before bulk-accepting. `cargo insta pending-snapshots` lists + what's about to change. `cargo insta accept` accepts everything; + `cargo insta accept --snapshot ` accepts one at a time. During + Stage 4 we chose bulk accept for the run / attach JSON snapshots after + confirming the only diffs were `server.target` + `_version` leakage + (which we then filtered out explicitly in the per-test filter code). + +3. **Hook shorthand vs `#[serde(flatten)]`** — the legacy + `HookDefinition` struct has `command: Option` and + `#[serde(flatten)] hook_type: Option`, and `HookType::Command` + _also_ has a `command: String` field. Setting both + `hook_type = Some(HookType::Command { command: ... })` and trying to + serialize (or round-trip through YAML) produces a duplicate `command` + key and fails deserialization. The bridge emits script/command hooks + via the shorthand (`HookDefinition.command`) and leaves + `HookDefinition.hook_type` as `None` to work around this. See commit + `f467bd23c`. + +4. **`fabro-test` managed settings marker** — the helper writes a + `# fabro-test managed storage_dir` comment as the first line of + injected settings.toml files. Functions that read the file (like + `settings_storage_dir` for `isolated_server`) must detect the marker + and treat the managed storage root as _not_ user-explicit, or + `isolated_server` will pick up the shared storage dir and the test + will fail with `assertion left != right` on the storage dir. See + commit `b57248236`. + +5. **`effective_settings::apply_server_defaults`** copies the **full** + server-side Settings shape (llm, sandbox, setup, checkpoint, + pull_request, artifacts, hooks, mcp_servers, github, slack, fabro) + into the resolved CLI settings in RemoteServer / LocalDaemon modes. + This is intentional — it matches the pre-Stage-3 behavior and makes + `fabro-server::server::tests::start_run_persists_full_settings_snapshot` + work. If you refactor the bridge during Stage 6, make sure the + equivalent propagation lands in whatever replaces it. + +6. **User layer trust boundary**: `effective_settings` strips `cli` and + `server` from the `workflow.toml` and `fabro.toml` layers in + RemoteServer / LocalDaemon modes, but **the user layer + (`~/.fabro/settings.toml`) is never stripped** — owner-specific + domains are only legal there. If you're tempted to strip them + uniformly, re-read R16 and commit `2fc85282b`. + +7. **Clippy test warnings**: `cargo clippy --workspace --tests -- -D warnings` + has two pre-existing issues in `fabro-interview/src/control.rs` + (absolute paths for `tokio::task::yield_now`). They're unrelated to + the settings refactor — leave them alone or fix them in a tiny + side-quest PR. The workspace-level (non-tests) clippy is already + green. + +## Open design questions for you to decide + +1. **Should `ConfigLayer::resolve(self) -> Settings` survive in any form?** + The natural rename is `into_file(self) -> SettingsFile`, but many + callers genuinely want a "final resolved view" that has applied env + interpolation, applied defaults, etc. Decide whether that's an + explicit `ResolvedSettings` type (new, v2-shaped) or whether it's + just `SettingsFile` with a contract that consumers resolve + `InterpString` themselves at read time. + +2. **Post-layering env interpolation resolution pass** — the original + plan calls for a pass in `fabro-config/src/interp_pass.rs` that + resolves every `InterpString` in the merged `SettingsFile` using + provenance tagging. I left this undone because `InterpString::resolve` + is adequate for the bridge output. Stage 6 is the right moment to + build the proper pass so the DTOs in Stage 6.6 can rely on + provenance. Requirements R79–R81 and the "Validation Boundary" + section of the requirements doc cover the rules. + +3. **Fail-closed server auth posture** — R52/R53 + "Default server + auth posture" in the plan say that if `server.auth` is absent or + resolves to no enabled API / web auth strategies, normal server + startup must refuse to start, with demo and test helpers free to + opt in to insecure startup. I did not wire this into + `fabro-server/src/server.rs`. Decide when it should land — doing it + in the same PR as Stage 6.6 keeps auth-related changes together. + +4. **`runtime.rs` model-ref ambiguity registry** — `ModelRef::resolve` + takes a `&dyn ModelRegistry` and errors on ambiguous bare tokens. + There's no runtime implementation of `ModelRegistry` yet. Decide + whether to implement it against `fabro-model::Catalog` in Stage 6, + or leave model-ref resolution as a consumption-time concern the + model selector already handles. + +5. **`run.scm.` subtree depth** — only `run.scm.github` is + defined as a unit struct placeholder right now. Requirements R64 says + "provider-specific details live in provider-specific nested tables". + When the first real SCM provider leaf lands, add fields under + `v2::run::ScmGitHubLayer` and mirror the pattern for future + providers. + +6. **`flatten` + `HashMap` + `deny_unknown_fields`** does NOT work + together in serde. Every time you think "I can just flatten a + HashMap here for provider-specific fields," resist. Use an + enumerated list of known-provider subfields instead (that's why + `RunSandboxLayer`, `NotificationRouteLayer`, `InterviewsLayer`, + `RunScmLayer`, `ServerIntegrationsLayer`, etc. have explicit + `github`/`slack`/`discord`/`teams`/`local`/`s3` fields). Adding a new + provider means adding a new field. + +## Repo conventions you'll hit + +- **Rust import style** (from `CLAUDE.md`): types imported by name, + functions via parent module, no glob imports in production code + except in test modules. The v2 schema code follows this throughout. +- **Shell quoting in sandbox code**: always `shell_quote()` / + `shlex::try_quote`. Don't hand-roll `.replace('\'', "'\\''")`. +- **Commits**: conventional style. Incremental commits per logical + unit. Do not force-push. Do not amend. Stage 1–5 commits are the + model. +- **Tests**: match existing patterns in each crate. `insta` for + snapshots. `e2e_test` attribute for dual-mode tests. Use + `fabro_test::test_http_client()` rather than `reqwest::Client::new()` + for local HTTP in tests (macOS proxy discovery overhead). + +## Success criteria for Stage 6 + +The refactor is **done** when: + +- [ ] `git grep 'fabro_types::Settings\b'` returns zero hits outside of + the legacy type file that's about to be deleted. +- [ ] `git grep 'bridge_to_old'` returns zero hits. +- [ ] `lib/crates/fabro-types/src/settings/v2/` no longer exists as a + subdirectory — its contents are promoted to `settings/*`. +- [ ] `lib/crates/fabro-types/src/combine.rs` is deleted (the trait + only existed to serve legacy flat types). +- [ ] `lib/crates/fabro-config/src/{hook,mcp,sandbox,server,run,user}.rs` + are either deleted or reduced to a thin `pub use ...::v2::...` + re-export shell, depending on your preference for the external + surface. +- [ ] `docs/api-reference/fabro-api.yaml` `ServerSettings` and + `RunSettings` schemas are explicit allow-list DTOs, not reflections + of the flat legacy shape. +- [ ] `lib/packages/fabro-api-client` and the Rust progenitor client are + regenerated from the new spec. +- [ ] `apps/fabro-web/app/routes/workflow-detail.tsx` `workflowData` + literal matches the new `RunSettings` DTO. +- [ ] The `cargo fmt` / `cargo build` / `cargo clippy -D warnings` / + `cargo nextest run --workspace` / `bun run typecheck` / `bun test` + / `bun run build` gates all stay green. + +Good luck! The hard cut is behind you — Stage 6 is mechanical from +here. From 3f32bdb874431f5a5d8e438222da50628fe663fd Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 9 Apr 2026 12:15:23 -0400 Subject: [PATCH 15/47] feat(types): add SettingsFile convenience accessors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 6.1 prep: add flat-view accessor methods on SettingsFile that walk the v2 parse tree. Consumers migrate off the legacy flat Settings shape by calling these accessors instead of chaining .as_ref() through every Option layer. Purely additive — no existing call sites change yet. Accessors cover: - run.* (goal, model, sandbox, prepare, checkpoint, hooks, pull_request, artifacts, execution, agent/mcps, inputs, metadata, git.author) - execution-posture booleans (dry_run, auto_approve, no_retro, preserve_sandbox) - cli.* (exec, output, verbosity, prevent_idle_sleep, upgrade_check) - server.* (api, web, storage, artifacts, scheduler, logging, integrations.github, integrations.slack, max_concurrent_runs) - storage_dir() with home-dir fallback and env interpolation - all_labels() aggregation across project/workflow/run metadata Co-Authored-By: Claude Opus 4.6 (1M context) --- .../fabro-types/src/settings/v2/accessors.rs | 436 ++++++++++++++++++ lib/crates/fabro-types/src/settings/v2/mod.rs | 1 + 2 files changed, 437 insertions(+) create mode 100644 lib/crates/fabro-types/src/settings/v2/accessors.rs diff --git a/lib/crates/fabro-types/src/settings/v2/accessors.rs b/lib/crates/fabro-types/src/settings/v2/accessors.rs new file mode 100644 index 000000000..e9b0b1624 --- /dev/null +++ b/lib/crates/fabro-types/src/settings/v2/accessors.rs @@ -0,0 +1,436 @@ +//! Convenience accessors on [`SettingsFile`]. +//! +//! These methods provide ergonomic, flat-shaped views into the v2 parse +//! tree. They exist so that consumers don't have to chain `.as_ref()` +//! through every Option layer when reading common fields. Each accessor +//! walks the real v2 structure — there is no transitional state here. + +use std::collections::HashMap; +use std::path::PathBuf; + +use super::cli::{CliExecLayer, CliLayer, CliOutputLayer}; +use super::interp::InterpString; +use super::project::ProjectLayer; +use super::run::{ + ApprovalMode, GitAuthorLayer, HookEntry, McpEntryLayer, RunAgentLayer, RunArtifactsLayer, + RunCheckpointLayer, RunExecutionLayer, RunLayer, RunMode, RunModelLayer, RunPrepareLayer, + RunPullRequestLayer, RunSandboxLayer, +}; +use super::server::{ + GithubIntegrationLayer, ServerApiLayer, ServerArtifactsLayer, ServerIntegrationsLayer, + ServerLayer, ServerLoggingLayer, ServerSchedulerLayer, ServerStorageLayer, ServerWebLayer, + SlackIntegrationLayer, +}; +use super::tree::SettingsFile; + +impl SettingsFile { + // ---------- project-scope ---------- + + #[must_use] + pub fn project_layer(&self) -> Option<&ProjectLayer> { + self.project.as_ref() + } + + #[must_use] + pub fn project_directory(&self) -> Option<&str> { + self.project.as_ref().and_then(|p| p.directory.as_deref()) + } + + // ---------- run-scope ---------- + + #[must_use] + pub fn run_layer(&self) -> Option<&RunLayer> { + self.run.as_ref() + } + + #[must_use] + pub fn run_goal(&self) -> Option<&InterpString> { + self.run.as_ref().and_then(|r| r.goal.as_ref()) + } + + #[must_use] + pub fn run_goal_str(&self) -> Option { + self.run_goal().map(InterpString::as_source) + } + + #[must_use] + pub fn run_working_dir(&self) -> Option<&InterpString> { + self.run.as_ref().and_then(|r| r.working_dir.as_ref()) + } + + #[must_use] + pub fn run_working_dir_str(&self) -> Option { + self.run_working_dir().map(InterpString::as_source) + } + + #[must_use] + pub fn run_model(&self) -> Option<&RunModelLayer> { + self.run.as_ref().and_then(|r| r.model.as_ref()) + } + + #[must_use] + pub fn run_model_name_str(&self) -> Option { + self.run_model() + .and_then(|m| m.name.as_ref()) + .map(InterpString::as_source) + } + + #[must_use] + pub fn run_model_provider_str(&self) -> Option { + self.run_model() + .and_then(|m| m.provider.as_ref()) + .map(InterpString::as_source) + } + + #[must_use] + pub fn run_sandbox(&self) -> Option<&RunSandboxLayer> { + self.run.as_ref().and_then(|r| r.sandbox.as_ref()) + } + + #[must_use] + pub fn run_prepare(&self) -> Option<&RunPrepareLayer> { + self.run.as_ref().and_then(|r| r.prepare.as_ref()) + } + + #[must_use] + pub fn run_checkpoint(&self) -> Option<&RunCheckpointLayer> { + self.run.as_ref().and_then(|r| r.checkpoint.as_ref()) + } + + #[must_use] + pub fn run_hooks(&self) -> &[HookEntry] { + self.run.as_ref().map_or(&[], |r| r.hooks.as_slice()) + } + + #[must_use] + pub fn run_pull_request(&self) -> Option<&RunPullRequestLayer> { + self.run.as_ref().and_then(|r| r.pull_request.as_ref()) + } + + #[must_use] + pub fn run_artifacts(&self) -> Option<&RunArtifactsLayer> { + self.run.as_ref().and_then(|r| r.artifacts.as_ref()) + } + + #[must_use] + pub fn run_execution(&self) -> Option<&RunExecutionLayer> { + self.run.as_ref().and_then(|r| r.execution.as_ref()) + } + + #[must_use] + pub fn run_agent(&self) -> Option<&RunAgentLayer> { + self.run.as_ref().and_then(|r| r.agent.as_ref()) + } + + #[must_use] + pub fn run_agent_mcps(&self) -> Option<&HashMap> { + self.run_agent().map(|a| &a.mcps) + } + + #[must_use] + pub fn run_inputs(&self) -> Option<&HashMap> { + self.run.as_ref().and_then(|r| r.inputs.as_ref()) + } + + #[must_use] + pub fn run_metadata(&self) -> Option<&HashMap> { + self.run.as_ref().map(|r| &r.metadata) + } + + #[must_use] + pub fn run_git_author(&self) -> Option<&GitAuthorLayer> { + self.run + .as_ref() + .and_then(|r| r.git.as_ref()) + .and_then(|g| g.author.as_ref()) + } + + // ---------- execution-posture booleans ---------- + + #[must_use] + pub fn dry_run_enabled(&self) -> bool { + matches!( + self.run_execution().and_then(|e| e.mode), + Some(RunMode::DryRun) + ) + } + + #[must_use] + pub fn auto_approve_enabled(&self) -> bool { + matches!( + self.run_execution().and_then(|e| e.approval), + Some(ApprovalMode::Auto) + ) + } + + /// Returns `true` when retros are explicitly disabled. Defaults to + /// `false` (retros enabled) when not set. + #[must_use] + pub fn no_retro_enabled(&self) -> bool { + matches!(self.run_execution().and_then(|e| e.retros), Some(false)) + } + + #[must_use] + pub fn preserve_sandbox_enabled(&self) -> bool { + self.run_sandbox() + .and_then(|sb| sb.preserve) + .unwrap_or(false) + } + + // ---------- cli-scope ---------- + + #[must_use] + pub fn cli_layer(&self) -> Option<&CliLayer> { + self.cli.as_ref() + } + + #[must_use] + pub fn cli_exec(&self) -> Option<&CliExecLayer> { + self.cli.as_ref().and_then(|c| c.exec.as_ref()) + } + + #[must_use] + pub fn cli_output(&self) -> Option<&CliOutputLayer> { + self.cli.as_ref().and_then(|c| c.output.as_ref()) + } + + #[must_use] + pub fn verbose_enabled(&self) -> bool { + use super::cli::OutputVerbosity; + matches!( + self.cli_output().and_then(|o| o.verbosity), + Some(OutputVerbosity::Verbose) + ) + } + + #[must_use] + pub fn prevent_idle_sleep_enabled(&self) -> bool { + self.cli_exec() + .and_then(|e| e.prevent_idle_sleep) + .unwrap_or(false) + } + + /// Upgrade check defaults to `true` when unset. + #[must_use] + pub fn upgrade_check_enabled(&self) -> bool { + self.cli + .as_ref() + .and_then(|c| c.updates.as_ref()) + .and_then(|u| u.check) + .unwrap_or(true) + } + + // ---------- server-scope ---------- + + #[must_use] + pub fn server_layer(&self) -> Option<&ServerLayer> { + self.server.as_ref() + } + + #[must_use] + pub fn server_api(&self) -> Option<&ServerApiLayer> { + self.server.as_ref().and_then(|s| s.api.as_ref()) + } + + #[must_use] + pub fn server_web(&self) -> Option<&ServerWebLayer> { + self.server.as_ref().and_then(|s| s.web.as_ref()) + } + + #[must_use] + pub fn server_storage(&self) -> Option<&ServerStorageLayer> { + self.server.as_ref().and_then(|s| s.storage.as_ref()) + } + + #[must_use] + pub fn server_storage_root_str(&self) -> Option { + self.server_storage() + .and_then(|s| s.root.as_ref()) + .map(InterpString::as_source) + } + + #[must_use] + pub fn server_artifacts(&self) -> Option<&ServerArtifactsLayer> { + self.server.as_ref().and_then(|s| s.artifacts.as_ref()) + } + + #[must_use] + pub fn server_scheduler(&self) -> Option<&ServerSchedulerLayer> { + self.server.as_ref().and_then(|s| s.scheduler.as_ref()) + } + + #[must_use] + pub fn max_concurrent_runs(&self) -> Option { + self.server_scheduler().and_then(|s| s.max_concurrent_runs) + } + + #[must_use] + pub fn server_logging(&self) -> Option<&ServerLoggingLayer> { + self.server.as_ref().and_then(|s| s.logging.as_ref()) + } + + #[must_use] + pub fn server_integrations(&self) -> Option<&ServerIntegrationsLayer> { + self.server.as_ref().and_then(|s| s.integrations.as_ref()) + } + + #[must_use] + pub fn server_integrations_github(&self) -> Option<&GithubIntegrationLayer> { + self.server_integrations().and_then(|i| i.github.as_ref()) + } + + #[must_use] + pub fn server_integrations_slack(&self) -> Option<&SlackIntegrationLayer> { + self.server_integrations().and_then(|i| i.slack.as_ref()) + } + + #[must_use] + pub fn github_app_id_str(&self) -> Option { + self.server_integrations_github() + .and_then(|g| g.app_id.as_ref()) + .map(InterpString::as_source) + } + + #[must_use] + pub fn github_client_id_str(&self) -> Option { + self.server_integrations_github() + .and_then(|g| g.client_id.as_ref()) + .map(InterpString::as_source) + } + + #[must_use] + pub fn github_slug_str(&self) -> Option { + self.server_integrations_github() + .and_then(|g| g.slug.as_ref()) + .map(InterpString::as_source) + } + + #[must_use] + pub fn github_permissions(&self) -> Option<&HashMap> { + self.server_integrations_github() + .map(|g| &g.permissions) + .filter(|m| !m.is_empty()) + } + + // ---------- storage path with home-dir default ---------- + + /// Returns the configured server storage root, or the home-dir default + /// when unset. Env interpolation is resolved at read time against the + /// process environment. + #[must_use] + pub fn storage_dir(&self) -> PathBuf { + self.server_storage() + .and_then(|s| s.root.as_ref()) + .and_then(|interp| { + interp + .resolve(|name| std::env::var(name).ok()) + .ok() + .map(|resolved| resolved.value) + }) + .map_or_else(|| fabro_util::Home::from_env().storage_dir(), PathBuf::from) + } + + // ---------- labels / metadata aggregation ---------- + + /// Combined metadata labels from project, workflow, and run layers. + /// Later layers overwrite earlier ones (project < workflow < run). + #[must_use] + pub fn all_labels(&self) -> HashMap { + let mut out = HashMap::new(); + if let Some(project) = &self.project { + for (k, v) in &project.metadata { + out.insert(k.clone(), v.clone()); + } + } + if let Some(workflow) = &self.workflow { + for (k, v) in &workflow.metadata { + out.insert(k.clone(), v.clone()); + } + } + if let Some(run) = &self.run { + for (k, v) in &run.metadata { + out.insert(k.clone(), v.clone()); + } + } + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::settings::v2::run::{RunLayer, RunModelLayer}; + + #[test] + fn run_goal_str_returns_source_value() { + let file = SettingsFile { + run: Some(RunLayer { + goal: Some(InterpString::parse("Implement OAuth")), + ..RunLayer::default() + }), + ..SettingsFile::default() + }; + assert_eq!(file.run_goal_str().as_deref(), Some("Implement OAuth")); + } + + #[test] + fn run_model_name_str_walks_tree() { + let file = SettingsFile { + run: Some(RunLayer { + model: Some(RunModelLayer { + name: Some(InterpString::parse("claude-sonnet-4-6")), + ..RunModelLayer::default() + }), + ..RunLayer::default() + }), + ..SettingsFile::default() + }; + assert_eq!( + file.run_model_name_str().as_deref(), + Some("claude-sonnet-4-6") + ); + } + + #[test] + fn upgrade_check_defaults_to_true_when_unset() { + let file = SettingsFile::default(); + assert!(file.upgrade_check_enabled()); + } + + #[test] + fn all_labels_merges_project_workflow_run() { + use crate::settings::v2::project::ProjectLayer; + use crate::settings::v2::workflow::WorkflowLayer; + + let mut project_metadata = HashMap::new(); + project_metadata.insert("env".into(), "project".into()); + project_metadata.insert("team".into(), "core".into()); + + let mut workflow_metadata = HashMap::new(); + workflow_metadata.insert("env".into(), "workflow".into()); + + let mut run_metadata = HashMap::new(); + run_metadata.insert("priority".into(), "high".into()); + + let file = SettingsFile { + project: Some(ProjectLayer { + metadata: project_metadata, + ..ProjectLayer::default() + }), + workflow: Some(WorkflowLayer { + metadata: workflow_metadata, + ..WorkflowLayer::default() + }), + run: Some(RunLayer { + metadata: run_metadata, + ..RunLayer::default() + }), + ..SettingsFile::default() + }; + + let labels = file.all_labels(); + assert_eq!(labels.get("env").map(String::as_str), Some("workflow")); + assert_eq!(labels.get("team").map(String::as_str), Some("core")); + assert_eq!(labels.get("priority").map(String::as_str), Some("high")); + } +} diff --git a/lib/crates/fabro-types/src/settings/v2/mod.rs b/lib/crates/fabro-types/src/settings/v2/mod.rs index 73b595a19..0d1baa2e6 100644 --- a/lib/crates/fabro-types/src/settings/v2/mod.rs +++ b/lib/crates/fabro-types/src/settings/v2/mod.rs @@ -6,6 +6,7 @@ //! Value-language helpers live alongside the tree: durations, byte sizes, //! model references, env interpolation, and splice-capable arrays. +pub mod accessors; pub mod bridge; pub mod cli; pub mod duration; From 842ab71eb87a81ad86a369c4b52dc39b4a54a661 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 9 Apr 2026 12:44:44 -0400 Subject: [PATCH 16/47] feat(types): expose bridge helpers and expand v2 accessors Stage 6.1 prep follow-ups that consumers need when walking v2 directly: - `bridge::bridge_sandbox`, `bridge_mcp_entry`, `bridge_mcps`, `bridge_hook`, `bridge_exec`, `bridge_worktree_mode`, `bridge_merge_strategy` are now `pub`, so callers can lift the runtime shape they need out of the v2 tree without round-tripping through the full `bridge_to_old` legacy Settings builder. - New `bridge::bridge_pull_request` and `bridge::bridge_run_artifacts` helpers extract their respective runtime shapes from v2 layers. - `SettingsFile::run_prepare_commands()` / `run_prepare_timeout_ms()` flatten `run.prepare.steps` into the legacy script-string vector shape consumers pass to `LifecycleOptions::setup_commands`. - `SettingsFile::run_inputs_as_strings()` stringifies `run.inputs` TOML values for var-expansion call sites. - `fabro_checkpoint::GitAuthor` now has `From<&v2::run::GitAuthorLayer>` so consumers can construct a runtime author directly from the v2 subtree without going through the legacy flat `GitAuthorSettings`. All changes are additive. `bridge_to_old` still exists and nothing has migrated off the flat `Settings` shape yet -- those moves land in follow-up commits once each consumer crate is converted independently. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/crates/fabro-checkpoint/src/author.rs | 11 ++++ .../fabro-types/src/settings/v2/accessors.rs | 54 +++++++++++++++++++ .../fabro-types/src/settings/v2/bridge.rs | 32 ++++++++--- 3 files changed, 90 insertions(+), 7 deletions(-) diff --git a/lib/crates/fabro-checkpoint/src/author.rs b/lib/crates/fabro-checkpoint/src/author.rs index 857ed5572..c493275d2 100644 --- a/lib/crates/fabro-checkpoint/src/author.rs +++ b/lib/crates/fabro-checkpoint/src/author.rs @@ -1,6 +1,8 @@ use std::fmt::Write; use fabro_types::settings::server::GitAuthorSettings; +use fabro_types::settings::v2::InterpString; +use fabro_types::settings::v2::run::GitAuthorLayer; /// Resolved git author identity for checkpoint commits. #[derive(Debug, Clone, PartialEq)] @@ -54,3 +56,12 @@ impl From<&GitAuthorSettings> for GitAuthor { Self::from_options(value.name.clone(), value.email.clone()) } } + +impl From<&GitAuthorLayer> for GitAuthor { + fn from(value: &GitAuthorLayer) -> Self { + Self::from_options( + value.name.as_ref().map(InterpString::as_source), + value.email.as_ref().map(InterpString::as_source), + ) + } +} diff --git a/lib/crates/fabro-types/src/settings/v2/accessors.rs b/lib/crates/fabro-types/src/settings/v2/accessors.rs index e9b0b1624..4d496ca79 100644 --- a/lib/crates/fabro-types/src/settings/v2/accessors.rs +++ b/lib/crates/fabro-types/src/settings/v2/accessors.rs @@ -92,6 +92,40 @@ impl SettingsFile { self.run.as_ref().and_then(|r| r.prepare.as_ref()) } + /// Flattened prepare-step commands: each `script` is kept as-is, and + /// `command` argv is joined with spaces. Env-interpolation tokens are + /// emitted verbatim via [`InterpString::as_source`]. + #[must_use] + pub fn run_prepare_commands(&self) -> Vec { + let Some(prepare) = self.run_prepare() else { + return Vec::new(); + }; + prepare + .steps + .iter() + .filter_map(|step| { + if let Some(script) = &step.script { + Some(script.as_source()) + } else { + step.command.as_ref().map(|argv| { + argv.iter() + .map(InterpString::as_source) + .collect::>() + .join(" ") + }) + } + }) + .collect() + } + + /// Prepare-step timeout in milliseconds. + #[must_use] + pub fn run_prepare_timeout_ms(&self) -> Option { + self.run_prepare() + .and_then(|p| p.timeout) + .map(|d| u64::try_from(d.as_std().as_millis()).unwrap_or(u64::MAX)) + } + #[must_use] pub fn run_checkpoint(&self) -> Option<&RunCheckpointLayer> { self.run.as_ref().and_then(|r| r.checkpoint.as_ref()) @@ -132,6 +166,26 @@ impl SettingsFile { self.run.as_ref().and_then(|r| r.inputs.as_ref()) } + /// Stringified view of `run.inputs`: non-string TOML values are rendered + /// via their canonical TOML representation (integers, booleans, and + /// arrays are flattened through `Display`). Returns `None` when no + /// inputs are set. + #[must_use] + pub fn run_inputs_as_strings(&self) -> Option> { + self.run_inputs().map(|inputs| { + inputs + .iter() + .map(|(k, v)| { + let stringified = match v { + toml::Value::String(s) => s.clone(), + other => other.to_string(), + }; + (k.clone(), stringified) + }) + .collect() + }) + } + #[must_use] pub fn run_metadata(&self) -> Option<&HashMap> { self.run.as_ref().map(|r| &r.metadata) diff --git a/lib/crates/fabro-types/src/settings/v2/bridge.rs b/lib/crates/fabro-types/src/settings/v2/bridge.rs index 569247407..d40f106b0 100644 --- a/lib/crates/fabro-types/src/settings/v2/bridge.rs +++ b/lib/crates/fabro-types/src/settings/v2/bridge.rs @@ -256,7 +256,7 @@ fn bridge_run(run: &RunLayer, out: &mut Settings) { } } -fn bridge_sandbox(sb: &super::run::RunSandboxLayer) -> SandboxSettings { +pub fn bridge_sandbox(sb: &super::run::RunSandboxLayer) -> SandboxSettings { SandboxSettings { provider: sb.provider.clone(), preserve: sb.preserve, @@ -312,7 +312,7 @@ fn bridge_sandbox(sb: &super::run::RunSandboxLayer) -> SandboxSettings { } } -fn bridge_worktree_mode(m: V2WorktreeMode) -> OldWorktreeMode { +pub fn bridge_worktree_mode(m: V2WorktreeMode) -> OldWorktreeMode { match m { V2WorktreeMode::Always => OldWorktreeMode::Always, V2WorktreeMode::Clean => OldWorktreeMode::Clean, @@ -321,7 +321,7 @@ fn bridge_worktree_mode(m: V2WorktreeMode) -> OldWorktreeMode { } } -fn bridge_merge_strategy(m: V2MergeStrategy) -> OldMergeStrategy { +pub fn bridge_merge_strategy(m: V2MergeStrategy) -> OldMergeStrategy { match m { V2MergeStrategy::Squash => OldMergeStrategy::Squash, V2MergeStrategy::Merge => OldMergeStrategy::Merge, @@ -329,13 +329,31 @@ fn bridge_merge_strategy(m: V2MergeStrategy) -> OldMergeStrategy { } } -fn bridge_mcps(mcps: &HashMap) -> HashMap { +pub fn bridge_pull_request(pr: &super::run::RunPullRequestLayer) -> PullRequestSettings { + PullRequestSettings { + enabled: pr.enabled.unwrap_or(false), + draft: pr.draft.unwrap_or(true), + auto_merge: pr.auto_merge.unwrap_or(false), + merge_strategy: pr + .merge_strategy + .map(bridge_merge_strategy) + .unwrap_or_default(), + } +} + +pub fn bridge_run_artifacts(artifacts: &super::run::RunArtifactsLayer) -> ArtifactsSettings { + ArtifactsSettings { + include: artifacts.include.clone(), + } +} + +pub fn bridge_mcps(mcps: &HashMap) -> HashMap { mcps.iter() .map(|(name, entry)| (name.clone(), bridge_mcp_entry(entry))) .collect() } -fn bridge_mcp_entry(entry: &McpEntryLayer) -> McpServerEntry { +pub fn bridge_mcp_entry(entry: &McpEntryLayer) -> McpServerEntry { let transport = match entry { McpEntryLayer::Stdio { script, @@ -418,7 +436,7 @@ fn bridge_mcp_entry(entry: &McpEntryLayer) -> McpServerEntry { } } -fn bridge_hook(hook: &V2HookEntry) -> HookDefinition { +pub fn bridge_hook(hook: &V2HookEntry) -> HookDefinition { let hook_type = resolve_hook_type(hook); // If the hook is a script/command form, emit via the shorthand so the // old HookDefinition.command field holds the full command and @@ -550,7 +568,7 @@ fn bridge_cli(cli: &CliLayer, out: &mut Settings) { } } -fn bridge_exec(exec: &CliExecLayer) -> ExecSettings { +pub fn bridge_exec(exec: &CliExecLayer) -> ExecSettings { ExecSettings { provider: exec .model From 5d9aad85a3e50c9248e7089bdaa9c33bb827554a Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 9 Apr 2026 12:52:03 -0400 Subject: [PATCH 17/47] wip(settings): stage 6.1 consumer migration (broken build) Partial Stage 6.1 migration of consumers off the legacy flat Settings shape to v2 SettingsFile. Commits the in-flight work so subsequent sessions can resume from here. Workspace currently does NOT build -- fabro-server still has ~60 consumer sites that reference state.settings as legacy Settings, and fabro-cli is entirely untouched. Landed in this commit: fabro-types - RunRecord.settings: Settings -> SettingsFile - RunCreatedProps.settings: Settings -> SettingsFile fabro-config - effective_settings: full rewrite. resolve_settings now returns SettingsFile; apply_server_defaults / apply_local_daemon_overrides are v2-native and use the v2 merge matrix for server-owned domains. - project::resolve_working_directory takes &SettingsFile and reads run.working_dir as an InterpString. fabro-workflow - start.rs, create.rs, source.rs, validate.rs, run_options.rs, git.rs, initialize.rs, manager_loop.rs all migrated to &SettingsFile reads. - resolve_sandbox_provider / resolve_worktree_mode / resolve_daytona_config / resolve_fallback_chain walk v2 trees using the bridge helper fns. - LifecycleOptions built from run_prepare_commands() / run_prepare_timeout_ms(). - Hooks built via bridge_hook on v2 HookEntry. - MCPs built via bridge_mcp_entry on v2 McpEntryLayer. - resolve_run_settings writes resolved model/provider back into run.model (InterpString), not the flat llm struct. - preprocess_and_validate pulls var expansion from run_inputs_as_strings. fabro-server/run_manifest.rs - PreparedManifest.settings -> SettingsFile. - prepare_manifest_with_mode takes &SettingsFile. - build_preflight_report / run_llm_check / resolve_model_provider / run_github_token_check / resolve_sandbox_provider / resolve_daytona_config all migrated. - Tests rewritten to use v2 fixtures via ConfigLayer::parse. fabro-server/server.rs - AppState.settings type changed to Arc>. - github_app_credentials call site uses settings.github_app_id_str() accessor instead of the flat app_id(). Known remaining errors: - fabro-server/server.rs: ~60 state.settings.read() sites still reference legacy Settings fields (llm, sandbox, setup, git, etc.). - fabro-server/web_auth.rs: heavy git settings usage, tests. - fabro-server/serve.rs: state mutation of flat llm/sandbox fields. - fabro-server/diagnostics.rs: app_id / api auth strategies. - fabro-cli: manifest_builder, commands, tests all untouched. - Test fixtures across the workspace still construct Settings literals. - insta snapshots will need bulk-accept after the runtime shape stabilizes. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../fabro-config/src/effective_settings.rs | 270 +++++++++--------- lib/crates/fabro-config/src/project.rs | 8 +- lib/crates/fabro-server/src/run_manifest.rs | 169 ++++++----- lib/crates/fabro-server/src/server.rs | 11 +- lib/crates/fabro-types/src/run.rs | 4 +- lib/crates/fabro-types/src/run_event/run.rs | 5 +- lib/crates/fabro-workflow/src/git.rs | 6 +- .../src/handler/manager_loop.rs | 8 +- .../fabro-workflow/src/operations/create.rs | 89 +++--- .../fabro-workflow/src/operations/source.rs | 45 +-- .../fabro-workflow/src/operations/start.rs | 150 ++++++---- .../fabro-workflow/src/operations/validate.rs | 4 +- .../fabro-workflow/src/pipeline/initialize.rs | 10 +- lib/crates/fabro-workflow/src/run_options.rs | 20 +- 14 files changed, 454 insertions(+), 345 deletions(-) diff --git a/lib/crates/fabro-config/src/effective_settings.rs b/lib/crates/fabro-config/src/effective_settings.rs index 1f6ddb79a..b92a8932a 100644 --- a/lib/crates/fabro-config/src/effective_settings.rs +++ b/lib/crates/fabro-config/src/effective_settings.rs @@ -1,4 +1,4 @@ -//! Effective settings resolution: combine layers into one resolved [`Settings`]. +//! Effective settings resolution: combine layers into one resolved [`SettingsFile`]. //! //! Shared layered domains (`project`, `workflow`, `run`, `features`) merge //! across all three config files (settings.toml, fabro.toml, workflow.toml). @@ -7,10 +7,11 @@ //! stanzas in `fabro.toml` and `workflow.toml` remain schema-valid but inert. use anyhow::{Result, anyhow}; -use fabro_types::Settings; use fabro_types::settings::v2::SettingsFile; +use fabro_types::settings::v2::run::{RunExecutionLayer, RunLayer}; use crate::ConfigLayer; +use crate::merge::combine_files; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum EffectiveSettingsMode { @@ -44,11 +45,12 @@ impl EffectiveSettingsLayers { } } +/// Resolve layered configuration down to a single effective [`SettingsFile`]. pub fn resolve_settings( layers: EffectiveSettingsLayers, - server_settings: Option<&Settings>, + server_settings: Option<&SettingsFile>, mode: EffectiveSettingsMode, -) -> Result { +) -> Result { let EffectiveSettingsLayers { args, mut workflow, @@ -57,11 +59,9 @@ pub fn resolve_settings( } = layers; match mode { - EffectiveSettingsMode::LocalOnly => Ok(args - .combine(workflow) - .combine(project) - .combine(user) - .resolve()), + EffectiveSettingsMode::LocalOnly => { + Ok(args.combine(workflow).combine(project).combine(user).into()) + } EffectiveSettingsMode::RemoteServer | EffectiveSettingsMode::LocalDaemon => { let server_settings = server_settings.ok_or_else(|| { anyhow!("server settings are required for server-targeted settings resolution") @@ -72,26 +72,33 @@ pub fn resolve_settings( strip_owner_domains(workflow.as_v2_mut()); strip_owner_domains(project.as_v2_mut()); - let server_defaults = server_defaults_layer(server_settings); + let server_defaults = server_defaults_file(server_settings); - let mut settings = args - .combine(workflow) - .combine(project) - .combine(user) - .resolve(); + let combined: SettingsFile = + args.combine(workflow).combine(project).combine(user).into(); - match mode { + let mut settings = match mode { EffectiveSettingsMode::RemoteServer => { - apply_server_defaults(&mut settings, &server_defaults); + apply_server_defaults(combined, &server_defaults) } EffectiveSettingsMode::LocalDaemon => { - apply_local_daemon_overrides(&mut settings, &server_defaults); + apply_local_daemon_overrides(combined, &server_defaults) } EffectiveSettingsMode::LocalOnly => unreachable!(), + }; + // Storage root always comes from the server's local + // ~/.fabro/settings.toml, never from the client. + if let Some(server_root) = server_settings + .server + .as_ref() + .and_then(|s| s.storage.as_ref()) + .cloned() + { + let server = settings + .server + .get_or_insert_with(fabro_types::settings::v2::server::ServerLayer::default); + server.storage = Some(server_root); } - settings - .storage_dir - .clone_from(&server_settings.storage_dir); Ok(settings) } } @@ -102,103 +109,81 @@ fn strip_owner_domains(file: &mut SettingsFile) { file.server = None; } -fn server_defaults_layer(settings: &Settings) -> Settings { +/// Copy of the server settings with startup-time dry-run fallback cleared. +/// Run manifests carry their own dry-run intent; a daemon's startup-time +/// fallback mode must not silently force every submitted run into simulation. +fn server_defaults_file(settings: &SettingsFile) -> SettingsFile { let mut out = settings.clone(); - // Run manifests carry their own dry-run intent. Do not let a daemon's - // startup-time fallback mode silently force every submitted run/preflight - // into simulation. - out.dry_run = None; + if let Some(run) = out.run.as_mut() { + if let Some(execution) = run.execution.as_mut() { + execution.mode = None; + } + } out } -fn apply_server_defaults(settings: &mut Settings, server: &Settings) { - // Owner-specific storage and scheduling come from the server's local - // settings.toml. These always win over anything layered from the client. - if settings.storage_dir.is_none() { - settings.storage_dir.clone_from(&server.storage_dir); - } - if settings.max_concurrent_runs.is_none() { - settings.max_concurrent_runs = server.max_concurrent_runs; - } - if settings.artifact_storage.is_none() { - settings - .artifact_storage - .clone_from(&server.artifact_storage); - } - if settings.web.is_none() { - settings.web.clone_from(&server.web); - } - if settings.api.is_none() { - settings.api.clone_from(&server.api); - } - if settings.features.is_none() { - settings.features.clone_from(&server.features); - } - if settings.log.is_none() { - settings.log.clone_from(&server.log); - } - if settings.git.is_none() { - settings.git.clone_from(&server.git); - } - // Run-shaped defaults also flow from server to CLI in RemoteServer mode - // so the persisted run record matches the server's local configuration. - if settings.llm.is_none() { - settings.llm.clone_from(&server.llm); - } - if settings.sandbox.is_none() { - settings.sandbox.clone_from(&server.sandbox); - } - if settings.setup.is_none() { - settings.setup.clone_from(&server.setup); - } - if settings.checkpoint.exclude_globs.is_empty() { - settings.checkpoint = server.checkpoint.clone(); - } - if settings.pull_request.is_none() { - settings.pull_request.clone_from(&server.pull_request); - } - if settings.artifacts.is_none() { - settings.artifacts.clone_from(&server.artifacts); - } - if settings.hooks.is_empty() { - settings.hooks.clone_from(&server.hooks); - } - if settings.mcp_servers.is_empty() { - settings.mcp_servers.clone_from(&server.mcp_servers); - } - if settings.github.is_none() { - settings.github.clone_from(&server.github); - } - if settings.slack.is_none() { - settings.slack.clone_from(&server.slack); - } - if settings.fabro.is_none() { - settings.fabro.clone_from(&server.fabro); - } - if settings.vars.is_none() { - settings.vars.clone_from(&server.vars); - } else if let (Some(local), Some(server_vars)) = (settings.vars.as_mut(), server.vars.as_ref()) - { - for (k, v) in server_vars { - local.entry(k.clone()).or_insert_with(|| v.clone()); - } - } +/// Apply server-side defaults to a client-layered [`SettingsFile`]. +/// +/// Server-owned domains (`server`, `features`, and parts of `run`) flow from +/// the server's local `~/.fabro/settings.toml` when the corresponding client +/// value is absent. Run-shaped defaults (model, prepare, sandbox, checkpoint, +/// hooks, agent mcps, etc.) also flow from server to client so the persisted +/// run record matches the server's local configuration. +fn apply_server_defaults(mut settings: SettingsFile, server: &SettingsFile) -> SettingsFile { + // Server-owned domains: server-side always wins when client left blank. + // Use the v2 merge matrix with the server layer in lower precedence so + // that client-supplied values still dominate when present. + settings = combine_files(server.clone(), settings); + settings } -fn apply_local_daemon_overrides(settings: &mut Settings, server: &Settings) { - settings.storage_dir.clone_from(&server.storage_dir); - settings.max_concurrent_runs = server.max_concurrent_runs; +/// Apply server-side overrides in LocalDaemon mode. +/// +/// In LocalDaemon mode, a subset of server-owned fields unconditionally +/// override any client-side values. Client-controlled run-level fields are +/// left alone. +fn apply_local_daemon_overrides(mut settings: SettingsFile, server: &SettingsFile) -> SettingsFile { + if let Some(server_layer) = server.server.clone() { + let client = settings + .server + .get_or_insert_with(fabro_types::settings::v2::server::ServerLayer::default); + if let Some(storage) = server_layer.storage { + client.storage = Some(storage); + } + if let Some(scheduler) = server_layer.scheduler { + client.scheduler = Some(scheduler); + } + if let Some(artifacts) = server_layer.artifacts { + client.artifacts = Some(artifacts); + } + if let Some(web) = server_layer.web { + client.web = Some(web); + } + if let Some(api) = server_layer.api { + client.api = Some(api); + } + } + if let Some(features) = server.features.clone() { + settings.features = Some(features); + } + // Ensure a run.execution table exists so downstream consumers that check + // for explicit dry-run defaults see a well-formed layer. + settings.run.get_or_insert_with(RunLayer::default); + settings + .run + .as_mut() + .unwrap() + .execution + .get_or_insert_with(RunExecutionLayer::default); settings - .artifact_storage - .clone_from(&server.artifact_storage); - settings.web.clone_from(&server.web); - settings.api.clone_from(&server.api); - settings.features.clone_from(&server.features); } #[cfg(test)] mod tests { - use std::path::PathBuf; + use fabro_types::settings::v2::InterpString; + use fabro_types::settings::v2::server::{ + ServerLayer, ServerSchedulerLayer, ServerStorageLayer, + }; use super::{EffectiveSettingsLayers, EffectiveSettingsMode, resolve_settings}; use crate::ConfigLayer; @@ -246,16 +231,21 @@ shared = "user" ) .unwrap(); - let llm = settings.llm.expect("llm config"); - assert_eq!(llm.model.as_deref(), Some("project-model")); + assert_eq!( + settings.run_model_name_str().as_deref(), + Some("project-model") + ); // Per R22, run.inputs replaces wholesale — the winning layer is the // highest-precedence layer that sets `inputs` (project here, since it // wins over user). - let vars = settings.vars.as_ref().unwrap(); - assert_eq!(vars.get("project_only"), Some(&"1".to_string())); - assert_eq!(vars.get("shared"), Some(&"project".to_string())); + let inputs = settings.run_inputs().unwrap(); + assert!(inputs.contains_key("project_only")); + assert_eq!( + inputs.get("shared").and_then(|v| v.as_str()), + Some("project") + ); assert!( - vars.get("user_only").is_none(), + !inputs.contains_key("user_only"), "project.inputs should replace user.inputs wholesale" ); } @@ -298,19 +288,26 @@ provider = "openai" ) .unwrap(); - assert_eq!(settings.goal.as_deref(), Some("workflow goal")); - let llm = settings.llm.expect("llm config"); - assert_eq!(llm.model.as_deref(), Some("workflow-model")); - assert_eq!(llm.provider.as_deref(), Some("openai")); + assert_eq!(settings.run_goal_str().as_deref(), Some("workflow goal")); + assert_eq!( + settings.run_model_name_str().as_deref(), + Some("workflow-model") + ); + assert_eq!(settings.run_model_provider_str().as_deref(), Some("openai")); } #[test] fn cli_and_server_domains_from_fabro_toml_are_inert_under_remote_mode() { - let server_settings: fabro_types::Settings = fabro_types::Settings { - storage_dir: Some(PathBuf::from("/srv/fabro")), - max_concurrent_runs: Some(9), - ..Default::default() - }; + let mut server_settings = fabro_types::settings::v2::SettingsFile::default(); + server_settings.server = Some(ServerLayer { + storage: Some(ServerStorageLayer { + root: Some(InterpString::parse("/srv/fabro")), + }), + scheduler: Some(ServerSchedulerLayer { + max_concurrent_runs: Some(9), + }), + ..ServerLayer::default() + }); let project_with_server = layer( r#" @@ -336,17 +333,25 @@ root = "/tmp/should-be-inert" ) .unwrap(); - assert_eq!(settings.storage_dir, Some(PathBuf::from("/srv/fabro"))); - assert_eq!(settings.goal.as_deref(), Some("project goal")); + assert_eq!( + settings.server_storage_root_str().as_deref(), + Some("/srv/fabro") + ); + assert_eq!(settings.run_goal_str().as_deref(), Some("project goal")); } #[test] fn local_daemon_mode_only_applies_server_owned_overrides() { - let server_settings: fabro_types::Settings = fabro_types::Settings { - storage_dir: Some(PathBuf::from("/srv/fabro")), - max_concurrent_runs: Some(7), - ..Default::default() - }; + let mut server_settings = fabro_types::settings::v2::SettingsFile::default(); + server_settings.server = Some(ServerLayer { + storage: Some(ServerStorageLayer { + root: Some(InterpString::parse("/srv/fabro")), + }), + scheduler: Some(ServerSchedulerLayer { + max_concurrent_runs: Some(7), + }), + ..ServerLayer::default() + }); let settings = resolve_settings( EffectiveSettingsLayers::default(), @@ -355,7 +360,10 @@ root = "/tmp/should-be-inert" ) .unwrap(); - assert_eq!(settings.storage_dir, Some(PathBuf::from("/srv/fabro"))); - assert_eq!(settings.max_concurrent_runs, Some(7)); + assert_eq!( + settings.server_storage_root_str().as_deref(), + Some("/srv/fabro") + ); + assert_eq!(settings.max_concurrent_runs(), Some(7)); } } diff --git a/lib/crates/fabro-config/src/project.rs b/lib/crates/fabro-config/src/project.rs index b04ac94a5..677902ede 100644 --- a/lib/crates/fabro-config/src/project.rs +++ b/lib/crates/fabro-config/src/project.rs @@ -12,8 +12,8 @@ use serde::Serialize; use crate::config::ConfigLayer; use crate::run; -use fabro_types::Settings; pub use fabro_types::settings::project::ProjectSettings; +use fabro_types::settings::v2::{InterpString, SettingsFile}; const CONFIG_FILENAME: &str = "fabro.toml"; const RUN_GRAPH_FILE: &str = "workflow.fabro"; @@ -124,11 +124,11 @@ pub fn resolve_workflow_path( } } -pub fn resolve_working_directory(settings: &Settings, caller_cwd: &Path) -> PathBuf { - let Some(work_dir) = settings.work_dir.as_deref() else { +pub fn resolve_working_directory(settings: &SettingsFile, caller_cwd: &Path) -> PathBuf { + let Some(work_dir) = settings.run_working_dir().map(InterpString::as_source) else { return caller_cwd.to_path_buf(); }; - let path = PathBuf::from(work_dir); + let path = PathBuf::from(&work_dir); if path.is_absolute() { path } else { diff --git a/lib/crates/fabro-server/src/run_manifest.rs b/lib/crates/fabro-server/src/run_manifest.rs index 6d920754d..c04594345 100644 --- a/lib/crates/fabro-server/src/run_manifest.rs +++ b/lib/crates/fabro-server/src/run_manifest.rs @@ -15,6 +15,7 @@ use fabro_llm::Provider; use fabro_model::Catalog; use fabro_sandbox::daytona::DaytonaConfig; use fabro_sandbox::{DockerSandboxOptions, Sandbox, SandboxProvider, SandboxSpec}; +use fabro_types::RunId; use fabro_types::settings::v2::SettingsFile; use fabro_types::settings::v2::cli::{CliLayer, CliOutputLayer, OutputVerbosity}; use fabro_types::settings::v2::interp::InterpString; @@ -22,7 +23,6 @@ use fabro_types::settings::v2::run::{ ApprovalMode, DaytonaDockerfileLayer, RunExecutionLayer, RunLayer, RunMode, RunModelLayer, RunSandboxLayer, }; -use fabro_types::{RunId, Settings}; use fabro_util::check_report::{CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus}; use fabro_validate::Severity; use fabro_workflow::error::FabroError; @@ -38,7 +38,7 @@ pub(crate) struct PreparedManifest { pub git: Option, pub root_source: String, pub run_id: Option, - pub settings: Settings, + pub settings: SettingsFile, pub target_path: PathBuf, pub workflow_bundle: WorkflowBundle, pub workflow_input: BundledWorkflow, @@ -46,7 +46,7 @@ pub(crate) struct PreparedManifest { } pub(crate) fn prepare_manifest_with_mode( - server_settings: &Settings, + server_settings: &SettingsFile, manifest: &types::RunManifest, local_daemon_mode: bool, ) -> Result { @@ -89,8 +89,8 @@ pub(crate) fn prepare_manifest_with_mode( }, )?; if let Some(goal) = manifest.goal.as_ref() { - settings.goal = Some(goal.text.clone()); - settings.goal_file = None; + let run = settings.run.get_or_insert_with(RunLayer::default); + run.goal = Some(InterpString::parse(&goal.text)); } Ok(PreparedManifest { @@ -338,12 +338,12 @@ async fn build_preflight_report( let settings = &prepared.settings; let sandbox_provider = resolve_sandbox_provider(settings)?; let github_app = state - .github_app_credentials(settings.app_id()) + .github_app_credentials(settings.github_app_id_str().as_deref()) .await .map_err(|err| anyhow!(err))?; let mut checks = Vec::new(); - let setup_command_count = settings.setup_commands().len(); + let setup_command_count = settings.run_prepare_commands().len(); let repo_summary = prepared.git.as_ref().map_or_else( || "unknown".to_string(), |git| { @@ -405,20 +405,19 @@ async fn build_preflight_report( )) } -fn resolve_sandbox_provider(settings: &Settings) -> Result { +fn resolve_sandbox_provider(settings: &SettingsFile) -> Result { Ok(settings - .sandbox_settings() - .and_then(|sandbox| sandbox.provider.as_deref()) + .run_sandbox() + .and_then(|sb| sb.provider.as_deref()) .map(str::parse::) .transpose() .map_err(|err| anyhow!("Invalid sandbox provider: {err}"))? .unwrap_or_default()) } -fn resolve_daytona_config(settings: &Settings) -> Option { - settings - .sandbox_settings() - .and_then(|sandbox| sandbox.daytona.clone()) +fn resolve_daytona_config(settings: &SettingsFile) -> Option { + let sandbox = settings.run_sandbox()?; + fabro_types::settings::v2::bridge::bridge_sandbox(sandbox).daytona } async fn run_sandbox_check( @@ -497,7 +496,7 @@ async fn run_llm_check( state: &AppState, checks: &mut Vec, graph: &Graph, - settings: &Settings, + settings: &SettingsFile, ) -> bool { let (model, provider) = resolve_model_provider(settings, graph); let default_provider = provider.as_deref().unwrap_or("anthropic"); @@ -588,40 +587,34 @@ async fn run_llm_check( } } -fn resolve_model_provider(settings: &Settings, graph: &Graph) -> (String, Option) { - let configured_model = settings.llm.as_ref().and_then(|llm| llm.model.as_deref()); - let configured_provider = settings - .llm - .as_ref() - .and_then(|llm| llm.provider.as_deref()); +fn resolve_model_provider(settings: &SettingsFile, graph: &Graph) -> (String, Option) { + let configured_model = settings.run_model_name_str(); + let configured_provider = settings.run_model_provider_str(); - let provider = configured_provider - .or_else(|| { - graph - .attrs - .get("default_provider") - .and_then(|value| value.as_str()) - }) - .map(String::from); + let provider = configured_provider.or_else(|| { + graph + .attrs + .get("default_provider") + .and_then(|value| value.as_str()) + .map(String::from) + }); let model = configured_model .or_else(|| { graph .attrs .get("default_model") .and_then(|value| value.as_str()) + .map(String::from) }) - .map_or_else( - || { - let catalog = Catalog::builtin(); - let info = provider - .as_deref() - .and_then(|value| value.parse::().ok()) - .and_then(|provider| catalog.default_for_provider(provider)) - .unwrap_or_else(|| catalog.default_from_env()); - info.id.clone() - }, - String::from, - ); + .unwrap_or_else(|| { + let catalog = Catalog::builtin(); + let info = provider + .as_deref() + .and_then(|value| value.parse::().ok()) + .and_then(|provider| catalog.default_for_provider(provider)) + .unwrap_or_else(|| catalog.default_from_env()); + info.id.clone() + }); match Catalog::builtin().get(&model) { Some(info) => ( @@ -635,23 +628,30 @@ fn resolve_model_provider(settings: &Settings, graph: &Graph) -> (String, Option async fn run_github_token_check( checks: &mut Vec, prepared: &PreparedManifest, - settings: &Settings, + settings: &SettingsFile, github_app: Option, ) { - let Some(github_permissions) = settings.github_permissions() else { + let Some(v2_permissions) = settings.github_permissions() else { return; }; - if github_permissions.is_empty() { + if v2_permissions.is_empty() { return; } + // Resolve InterpString permission values eagerly for token minting and + // for display in the preflight report. + let github_permissions: HashMap = v2_permissions + .iter() + .map(|(k, v)| (k.clone(), v.as_source())) + .collect(); + let perm_details = github_permissions .iter() .map(|(key, value)| CheckDetail::new(format!("{key}: {value}"))) .collect::>(); match (&github_app, prepared.git.as_ref()) { (Some(creds), Some(git)) => { - match mint_github_token(creds, &git.origin_url, github_permissions).await { + match mint_github_token(creds, &git.origin_url, &github_permissions).await { Ok(_) => checks.push(CheckResult { name: "GitHub Token".into(), status: CheckStatus::Pass, @@ -810,31 +810,49 @@ mod tests { } } + fn server_settings_fixture(source: &str) -> SettingsFile { + fabro_config::ConfigLayer::parse(source) + .expect("v2 fixture should parse") + .into() + } + #[test] fn prepare_manifest_does_not_inherit_server_dry_run_fallback() { - let server_settings = Settings { - dry_run: Some(true), - storage_dir: Some(PathBuf::from("/srv/fabro")), - ..Default::default() - }; + let server_settings = server_settings_fixture( + r#" +_version = 1 + +[run.execution] +mode = "dry_run" + +[server.storage] +root = "/srv/fabro" +"#, + ); let prepared = prepare_manifest_with_mode(&server_settings, &minimal_manifest(), false).unwrap(); - assert_eq!(prepared.settings.dry_run, None); + assert!(!prepared.settings.dry_run_enabled()); assert_eq!( - prepared.settings.storage_dir, - Some(PathBuf::from("/srv/fabro")) + prepared.settings.server_storage_root_str().as_deref(), + Some("/srv/fabro"), ); } #[test] fn prepare_manifest_preserves_explicit_manifest_dry_run() { - let server_settings = Settings { - dry_run: Some(true), - storage_dir: Some(PathBuf::from("/srv/fabro")), - ..Default::default() - }; + let server_settings = server_settings_fixture( + r#" +_version = 1 + +[run.execution] +mode = "dry_run" + +[server.storage] +root = "/srv/fabro" +"#, + ); let mut manifest = minimal_manifest(); manifest.args = Some(types::ManifestArgs { auto_approve: None, @@ -850,23 +868,25 @@ mod tests { let prepared = prepare_manifest_with_mode(&server_settings, &manifest, false).unwrap(); - assert_eq!(prepared.settings.dry_run, Some(true)); + assert!(prepared.settings.dry_run_enabled()); } #[test] fn prepare_manifest_local_daemon_prefers_bundled_settings_without_duplication() { - let server_settings: Settings = toml::from_str( + let server_settings = server_settings_fixture( r#" -storage_dir = "/srv/fabro" +_version = 1 -[setup] -commands = ["cli-setup"] +[server.storage] +root = "/srv/fabro" -[git] +[[run.prepare.steps]] +script = "cli-setup" + +[server.integrations.github] app_id = "snapshotted-app-id" "#, - ) - .unwrap(); + ); let mut manifest = minimal_manifest(); manifest.workflows.get_mut("workflow.fabro").unwrap().config = @@ -902,17 +922,16 @@ app_id = "snapshotted-app-id" // v2 merge matrix: run.prepare.steps replaces the whole list across // layers, so the higher-precedence workflow layer wins over cli. assert_eq!( - prepared - .settings - .setup - .as_ref() - .map(|setup| setup.commands.clone()), - Some(vec!["workflow-setup".to_string()]) + prepared.settings.run_prepare_commands(), + vec!["workflow-setup".to_string()] ); - assert_eq!(prepared.settings.app_id(), Some("snapshotted-app-id")); assert_eq!( - prepared.settings.storage_dir, - Some(PathBuf::from("/srv/fabro")) + prepared.settings.github_app_id_str().as_deref(), + Some("snapshotted-app-id") + ); + assert_eq!( + prepared.settings.server_storage_root_str().as_deref(), + Some("/srv/fabro"), ); } } diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 94d158599..af3b8f0d2 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -33,6 +33,7 @@ use fabro_model::{BilledModelUsage, BilledTokenCounts}; use fabro_store::{ ArtifactStore, Database, EventEnvelope, EventPayload, PendingInterviewRecord, StageId, }; +use fabro_types::settings::v2::SettingsFile; use fabro_types::{ EventBody, InterviewQuestionRecord, InterviewQuestionType, RunBlobId, RunClientProvenance, RunControlAction, RunEvent, RunId, RunProvenance, RunServerProvenance, RunSubjectProvenance, @@ -520,7 +521,7 @@ pub struct AppState { global_event_tx: broadcast::Sender, pub(crate) secret_store: AsyncRwLock, - pub(crate) settings: Arc>, + pub(crate) settings: Arc>, pub(crate) config_path: PathBuf, pub(crate) local_daemon_mode: bool, shutting_down: AtomicBool, @@ -3585,7 +3586,13 @@ async fn execute_run_in_process(state: Arc, run_id: RunId) { } }; let github_app = match state - .github_app_credentials(persisted.run_record().settings.app_id()) + .github_app_credentials( + persisted + .run_record() + .settings + .github_app_id_str() + .as_deref(), + ) .await { Ok(github_app) => github_app, diff --git a/lib/crates/fabro-types/src/run.rs b/lib/crates/fabro-types/src/run.rs index 4c282d098..81d6bb670 100644 --- a/lib/crates/fabro-types/src/run.rs +++ b/lib/crates/fabro-types/src/run.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize}; use crate::graph::Graph; use crate::run_blob_id::RunBlobId; use crate::run_id::RunId; -use crate::settings::Settings; +use crate::settings::v2::SettingsFile; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -52,7 +52,7 @@ pub struct RunProvenance { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RunRecord { pub run_id: RunId, - pub settings: Settings, + pub settings: SettingsFile, pub graph: Graph, #[serde(default, skip_serializing_if = "Option::is_none")] pub workflow_slug: Option, diff --git a/lib/crates/fabro-types/src/run_event/run.rs b/lib/crates/fabro-types/src/run_event/run.rs index ce24c10e2..6ec24c606 100644 --- a/lib/crates/fabro-types/src/run_event/run.rs +++ b/lib/crates/fabro-types/src/run_event/run.rs @@ -2,13 +2,14 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; -use crate::{Graph, RunBlobId, RunControlAction, RunProvenance, Settings, StatusReason}; +use crate::settings::v2::SettingsFile; +use crate::{Graph, RunBlobId, RunControlAction, RunProvenance, StatusReason}; use super::{BilledTokenCounts, RunNoticeLevel}; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct RunCreatedProps { - pub settings: Settings, + pub settings: SettingsFile, pub graph: Graph, #[serde(default, skip_serializing_if = "Option::is_none")] pub workflow_source: Option, diff --git a/lib/crates/fabro-workflow/src/git.rs b/lib/crates/fabro-workflow/src/git.rs index 24ccf9fac..feaec45a8 100644 --- a/lib/crates/fabro-workflow/src/git.rs +++ b/lib/crates/fabro-workflow/src/git.rs @@ -2,7 +2,7 @@ use std::path::Path; use std::process::Command; use fabro_checkpoint::git::Store; -use fabro_types::Settings; +use fabro_types::settings::v2::SettingsFile; use crate::error::{FabroError, Result}; use tokio::task::{JoinError, spawn_blocking}; @@ -15,9 +15,9 @@ pub use fabro_checkpoint::metadata::MetadataStore; /// Branch prefix for workflow run branches (e.g. `fabro/run/{run_id}`). pub const RUN_BRANCH_PREFIX: &str = "fabro/run/"; -pub fn git_author_from_settings(settings: &Settings) -> GitAuthor { +pub fn git_author_from_settings(settings: &SettingsFile) -> GitAuthor { settings - .git_author() + .run_git_author() .map(GitAuthor::from) .unwrap_or_default() } diff --git a/lib/crates/fabro-workflow/src/handler/manager_loop.rs b/lib/crates/fabro-workflow/src/handler/manager_loop.rs index f8340c4ae..6878a6c1e 100644 --- a/lib/crates/fabro-workflow/src/handler/manager_loop.rs +++ b/lib/crates/fabro-workflow/src/handler/manager_loop.rs @@ -18,7 +18,7 @@ use crate::run_options::RunOptions; use async_trait::async_trait; use fabro_graphviz::graph::{AttrValue, Graph, Node}; use fabro_store::{ArtifactStore, Database}; -use fabro_types::Settings; +use fabro_types::settings::v2::SettingsFile; use object_store::memory::InMemory; use tokio::time::{sleep, timeout}; @@ -74,7 +74,7 @@ fn parse_child_graph( source: dot.to_string(), base_dir: None, }, - settings: Settings::default(), + settings: SettingsFile::default(), cwd: cwd.clone(), custom_transforms: Vec::new(), })?; @@ -116,7 +116,7 @@ fn parse_child_graph( }; let validated = validate(ValidateInput { workflow, - settings: Settings::default(), + settings: SettingsFile::default(), cwd, custom_transforms: Vec::new(), })?; @@ -202,7 +202,7 @@ impl Handler for SubWorkflowHandler { let child_cancel = Arc::clone(&cancel_token); let child_run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: child_logs, cancel_token: Some(cancel_token), // Child workflows are part of the parent run's event stream. diff --git a/lib/crates/fabro-workflow/src/operations/create.rs b/lib/crates/fabro-workflow/src/operations/create.rs index f0886f405..7c276fdb1 100644 --- a/lib/crates/fabro-workflow/src/operations/create.rs +++ b/lib/crates/fabro-workflow/src/operations/create.rs @@ -3,7 +3,9 @@ use fabro_graphviz::graph::{AttrValue, Graph}; use fabro_model::{Catalog, Provider}; use fabro_sandbox::SandboxProvider; use fabro_store::Database; -use fabro_types::{RunId, RunProvenance, Settings}; +use fabro_types::settings::v2::run::{RunLayer, RunModelLayer}; +use fabro_types::settings::v2::{InterpString, SettingsFile}; +use fabro_types::{RunId, RunProvenance}; use std::collections::BTreeMap; use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -26,7 +28,7 @@ use crate::event::{Event, append_event, to_run_event_at}; #[derive(Clone, Debug)] pub struct CreateRunInput { pub workflow: WorkflowInput, - pub settings: Settings, + pub settings: SettingsFile, pub cwd: PathBuf, pub workflow_slug: Option, pub workflow_path: Option, @@ -48,7 +50,7 @@ pub struct CreatedRun { } struct PersistCreateOptions { - settings: Settings, + settings: SettingsFile, run_id: Option, run_dir: Option, workflow_slug: Option, @@ -125,7 +127,7 @@ pub async fn create(store: &Database, request: CreateRunInput) -> Result FabroError { FabroError::engine(err.to_string()) } -fn validate_sandbox_provider(settings: &Settings) -> Result<(), FabroError> { +fn validate_sandbox_provider(settings: &SettingsFile) -> Result<(), FabroError> { if let Some(provider) = settings - .sandbox_settings() + .run_sandbox() .and_then(|sandbox| sandbox.provider.as_deref()) { provider @@ -289,12 +291,11 @@ pub(super) fn preprocess_and_validate( current_dir: Option, file_resolver: Option>, custom_transforms: Vec>, - settings: Option<&Settings>, + settings: Option<&SettingsFile>, goal_override: Option<&str>, ) -> Result { - let source = match settings.and_then(|resolved| resolved.vars.as_ref()) { - Some(vars) => { - let mut vars = vars.clone(); + let source = match settings.and_then(SettingsFile::run_inputs_as_strings) { + Some(mut vars) => { vars.insert("goal".to_string(), "$goal".to_string()); expand_vars(dot_source, &vars) .map_err(|e| FabroError::Parse(format!("var expansion failed: {e}")))? @@ -371,28 +372,32 @@ fn persist_validated( ) } -pub(crate) fn resolve_run_settings(mut settings: Settings, graph: &Graph) -> Settings { - let llm_settings = settings.llm.as_ref(); - let configured_model = llm_settings.and_then(|l| l.model.as_deref()); - let configured_provider = llm_settings.and_then(|l| l.provider.as_deref()); - let graph_provider = graph.attrs.get("default_provider").and_then(|v| v.as_str()); - let graph_model = graph.attrs.get("default_model").and_then(|v| v.as_str()); +pub(crate) fn resolve_run_settings(mut settings: SettingsFile, graph: &Graph) -> SettingsFile { + let configured_model = settings.run_model_name_str(); + let configured_provider = settings.run_model_provider_str(); + let graph_provider = graph + .attrs + .get("default_provider") + .and_then(|v| v.as_str()) + .map(str::to_string); + let graph_model = graph + .attrs + .get("default_model") + .and_then(|v| v.as_str()) + .map(str::to_string); - let provider = configured_provider.or(graph_provider).map(str::to_string); + let provider = configured_provider.or(graph_provider); - let model = configured_model.or(graph_model).map_or_else( - || { - let catalog = Catalog::builtin(); - provider - .as_deref() - .and_then(|value| value.parse::().ok()) - .and_then(|provider| catalog.default_for_provider(provider)) - .unwrap_or_else(|| catalog.default_from_env()) - .id - .clone() - }, - str::to_string, - ); + let model = configured_model.or(graph_model).unwrap_or_else(|| { + let catalog = Catalog::builtin(); + provider + .as_deref() + .and_then(|value| value.parse::().ok()) + .and_then(|provider| catalog.default_for_provider(provider)) + .unwrap_or_else(|| catalog.default_from_env()) + .id + .clone() + }); let (resolved_model, resolved_provider) = match Catalog::builtin().get(&model) { Some(info) => ( @@ -402,16 +407,26 @@ pub(crate) fn resolve_run_settings(mut settings: Settings, graph: &Graph) -> Set None => (model, provider), }; - let llm = settings.llm.get_or_insert_default(); - llm.model = Some(resolved_model); - llm.provider = resolved_provider; + let run = settings.run.get_or_insert_with(RunLayer::default); + let model_layer = run.model.get_or_insert_with(RunModelLayer::default); + model_layer.name = Some(InterpString::parse(&resolved_model)); + model_layer.provider = resolved_provider.as_deref().map(InterpString::parse); let goal = graph.goal().to_string(); - settings.goal = if goal.is_empty() { None } else { Some(goal) }; - settings.pull_request = settings + run.goal = if goal.is_empty() { + None + } else { + Some(InterpString::parse(&goal)) + }; + // Strip disabled pull_request entries so downstream consumers can + // treat `Some(_)` as "PR creation is on". + if run .pull_request - .take() - .filter(|pull_request| pull_request.enabled); + .as_ref() + .is_some_and(|pr| !pr.enabled.unwrap_or(false)) + { + run.pull_request = None; + } settings } diff --git a/lib/crates/fabro-workflow/src/operations/source.rs b/lib/crates/fabro-workflow/src/operations/source.rs index dcf9c06ae..d91d0b5fe 100644 --- a/lib/crates/fabro-workflow/src/operations/source.rs +++ b/lib/crates/fabro-workflow/src/operations/source.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use anyhow::Context; use fabro_config::project as project_config; -use fabro_types::Settings; +use fabro_types::settings::v2::{InterpString, SettingsFile}; use fabro_util::path::expand_tilde; use crate::file_resolver::{FileResolver, FilesystemFileResolver}; @@ -22,14 +22,14 @@ pub enum WorkflowInput { #[derive(Clone, Debug)] pub(crate) struct ResolveWorkflowInput { pub workflow: WorkflowInput, - pub settings: Settings, + pub settings: SettingsFile, pub cwd: PathBuf, } #[derive(Clone)] pub(crate) struct ResolvedWorkflow { pub raw_source: String, - pub settings: Settings, + pub settings: SettingsFile, pub workflow_slug: Option, pub workflow_toml_path: Option, pub dot_path: Option, @@ -85,10 +85,7 @@ pub(crate) fn resolve_workflow(request: ResolveWorkflowInput) -> anyhow::Result< project_config::resolve_working_directory(&settings, &request.cwd); let raw_source = std::fs::read_to_string(&resolution.dot_path) .with_context(|| format!("Failed to read {}", resolution.dot_path.display()))?; - let goal_override = settings.goal.clone().or(resolve_goal_file( - settings.goal_file.as_deref(), - &working_directory, - )?); + let goal_override = resolve_goal_override(&settings, &working_directory)?; let current_dir = resolution .dot_path .parent() @@ -113,10 +110,7 @@ pub(crate) fn resolve_workflow(request: ResolveWorkflowInput) -> anyhow::Result< let settings = request.settings; let working_directory = project_config::resolve_working_directory(&settings, &request.cwd); - let goal_override = settings.goal.clone().or(resolve_goal_file( - settings.goal_file.as_deref(), - &working_directory, - )?); + let goal_override = resolve_goal_override(&settings, &working_directory)?; let has_base_dir = base_dir.is_some(); Ok(ResolvedWorkflow { raw_source: source, @@ -138,37 +132,56 @@ pub(crate) fn resolve_workflow(request: ResolveWorkflowInput) -> anyhow::Result< let settings = request.settings; let working_directory = project_config::resolve_working_directory(&settings, &request.cwd); + let goal_override = settings.run_goal().map(InterpString::as_source); Ok(ResolvedWorkflow { raw_source: workflow.source.clone(), - settings: settings.clone(), + settings, workflow_slug: workflow_slug_from_path(&workflow.logical_path), workflow_toml_path: None, dot_path: Some(workflow.logical_path.clone()), current_dir: Some(workflow.current_dir()), file_resolver: Some(workflow.file_resolver()), - goal_override: settings.goal.clone(), + goal_override, working_directory, }) } } } +fn resolve_goal_override( + settings: &SettingsFile, + working_directory: &Path, +) -> anyhow::Result> { + // V2 does not yet carry a separate `goal_file` field; file-based goals + // come through the workflow manifest layer in the server-side flow. + // For direct CLI paths, the goal override comes from `run.goal`. + Ok(settings + .run_goal() + .map(InterpString::as_source) + .or(resolve_goal_file(None, working_directory)?)) +} + #[cfg(test)] mod tests { use super::*; #[test] fn resolve_workflow_uses_explicit_cwd_for_relative_work_dir() { + use fabro_types::settings::v2::run::RunLayer; + let dir = tempfile::tempdir().unwrap(); let resolved = resolve_workflow(ResolveWorkflowInput { workflow: WorkflowInput::DotSource { source: "digraph Test { start -> exit }".to_string(), base_dir: None, }, - settings: Settings { - work_dir: Some("workspace".to_string()), - ..Default::default() + settings: SettingsFile { + run: Some(RunLayer { + working_dir: Some(InterpString::parse("workspace")), + ..RunLayer::default() + }), + ..SettingsFile::default() }, cwd: dir.path().to_path_buf(), }) diff --git a/lib/crates/fabro-workflow/src/operations/start.rs b/lib/crates/fabro-workflow/src/operations/start.rs index 40bef59d0..8da74cdcc 100644 --- a/lib/crates/fabro-workflow/src/operations/start.rs +++ b/lib/crates/fabro-workflow/src/operations/start.rs @@ -5,11 +5,14 @@ use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use fabro_config::sandbox::WorktreeMode; -use fabro_config::{project as project_config, run as run_config, sandbox as sandbox_config}; +use fabro_config::{project as project_config, sandbox as sandbox_config}; use fabro_interview::{AutoApproveInterviewer, Interviewer}; use fabro_model::{Catalog, FallbackTarget, Provider}; use fabro_sandbox::{SandboxProvider, SandboxSpec}; -use fabro_types::{RunId, Settings}; +use fabro_types::RunId; +use fabro_types::settings::v2::bridge::{bridge_mcp_entry, bridge_sandbox, bridge_worktree_mode}; +use fabro_types::settings::v2::run::ModelRefOrSplice; +use fabro_types::settings::v2::{InterpString, SettingsFile}; use crate::artifact_upload::ArtifactSink; use crate::context::Context; @@ -260,7 +263,7 @@ async fn persist_terminal_engine_failure( impl RunSession { async fn new(persisted: &Persisted, services: StartServices) -> Result { let record = persisted.run_record(); - let mut settings = record.settings.clone(); + let settings = &record.settings; let working_directory = record.working_directory.clone(); let state = services .run_store @@ -287,34 +290,21 @@ impl RunSession { let workflow_bundle = accepted_definition.map(|definition| Arc::new(definition.workflow_bundle())); - if let Some(env) = settings - .sandbox - .as_mut() - .and_then(|sandbox| sandbox.env.as_mut()) - { - run_config::resolve_env_refs(env) - .map_err(|err| FabroError::Precondition(err.to_string()))?; - } - let (origin_url, detected_base_branch) = detect_repo_info(&working_directory) .map(|(url, branch)| (Some(url), branch)) .unwrap_or((None, None)); - let sandbox_provider = resolve_sandbox_provider(&settings)?; + let sandbox_provider = resolve_sandbox_provider(settings)?; let sandbox_provider = if settings.dry_run_enabled() && !sandbox_provider.is_local() { SandboxProvider::Local } else { sandbox_provider }; let model = settings - .llm - .as_ref() - .and_then(|llm| llm.model.clone()) + .run_model_name_str() .unwrap_or_else(|| Catalog::builtin().default_from_env().id.clone()); let provider = settings - .llm - .as_ref() - .and_then(|llm| llm.provider.clone()) + .run_model_provider_str() .filter(|value| !value.is_empty()); let provider_enum: Provider = provider @@ -324,13 +314,15 @@ impl RunSession { .map_err(|err| FabroError::Precondition(err.clone()))? .unwrap_or_else(Provider::default_from_env); - let fallback_chain = resolve_fallback_chain(provider_enum, &model, &settings); + let fallback_chain = resolve_fallback_chain(provider_enum, &model, settings); let mcp_servers = settings - .mcp_server_entries() - .clone() - .into_iter() - .map(|(name, entry)| entry.into_config(name)) - .collect(); + .run_agent_mcps() + .map(|mcps| { + mcps.iter() + .map(|(name, entry)| bridge_mcp_entry(entry).into_config(name.clone())) + .collect() + }) + .unwrap_or_default(); let sandbox = match sandbox_provider { SandboxProvider::Local => SandboxSpec::Local { @@ -343,26 +335,39 @@ impl RunSession { }, }, SandboxProvider::Daytona => SandboxSpec::Daytona { - config: resolve_daytona_config(&settings).unwrap_or_default(), + config: resolve_daytona_config(settings).unwrap_or_default(), github_app: services.github_app.clone(), run_id: Some(record.run_id), clone_branch: detected_base_branch.or_else(|| record.base_branch.clone()), }, }; + let toml_env: HashMap = settings + .run_sandbox() + .map(|sb| { + sb.env + .iter() + .map(|(k, v)| (k.clone(), resolve_interp(v))) + .collect() + }) + .unwrap_or_default(); + let github_permissions: Option> = + settings.github_permissions().map(|perms| { + perms + .iter() + .map(|(k, v)| (k.clone(), resolve_interp(v))) + .collect() + }); let sandbox_env = SandboxEnvSpec { devcontainer_env: HashMap::new(), - toml_env: settings - .sandbox_settings() - .and_then(|sandbox| sandbox.env.clone()) - .unwrap_or_default(), - github_permissions: settings.github_permissions().cloned(), + toml_env, + github_permissions, origin_url: origin_url.clone(), }; let devcontainer = settings - .sandbox_settings() - .and_then(|sandbox| sandbox.devcontainer) + .run_sandbox() + .and_then(|sb| sb.devcontainer) .unwrap_or(false) .then(|| DevcontainerSpec { enabled: true, @@ -375,6 +380,10 @@ impl RunSession { services.interviewer }; + let pr_config = settings + .run_pull_request() + .map(fabro_types::settings::v2::bridge::bridge_pull_request); + Ok(Self { cancel_token: services.cancel_token, emitter: services.emitter, @@ -391,12 +400,16 @@ impl RunSession { interviewer, on_node: services.on_node, lifecycle: LifecycleOptions { - setup_commands: settings.setup_commands().to_vec(), - setup_command_timeout_ms: settings.setup_timeout_ms().unwrap_or(300_000), + setup_commands: settings.run_prepare_commands(), + setup_command_timeout_ms: settings.run_prepare_timeout_ms().unwrap_or(300_000), devcontainer_phases: Vec::new(), }, hooks: fabro_hooks::HookSettings { - hooks: settings.hooks.clone(), + hooks: settings + .run_hooks() + .iter() + .map(fabro_types::settings::v2::bridge::bridge_hook) + .collect(), }, sandbox_env, devcontainer, @@ -405,11 +418,11 @@ impl RunSession { artifact_sink: services.artifact_sink, git, github_app: services.github_app.clone(), - worktree_mode: Some(resolve_worktree_mode(&settings)), + worktree_mode: Some(resolve_worktree_mode(settings)), registry_override: services.registry_override, retro_enabled: !settings.no_retro_enabled() && project_config::is_retro_enabled(), - preserve_sandbox: resolve_preserve_sandbox(&settings), - pr_config: settings.pull_request.clone(), + preserve_sandbox: resolve_preserve_sandbox(settings), + pr_config, pr_github_app: services.github_app, pr_origin_url: origin_url, pr_model: model, @@ -419,6 +432,12 @@ impl RunSession { } } +fn resolve_interp(value: &InterpString) -> String { + value + .resolve(|name| std::env::var(name).ok()) + .map_or_else(|_| value.as_source(), |resolved| resolved.value) +} + async fn load_accepted_run_definition( run_store: &RunStoreHandle, blob_id: fabro_types::RunBlobId, @@ -435,45 +454,62 @@ async fn load_accepted_run_definition( serde_json::from_slice(&bytes).map_err(|err| FabroError::Parse(err.to_string())) } -fn resolve_sandbox_provider(settings: &Settings) -> Result { +fn resolve_sandbox_provider(settings: &SettingsFile) -> Result { settings - .sandbox_settings() - .and_then(|sandbox| sandbox.provider.as_deref()) + .run_sandbox() + .and_then(|sb| sb.provider.as_deref()) .map(str::parse::) .transpose() .map_err(|err| FabroError::Precondition(format!("Invalid sandbox provider: {err}")))? .map_or_else(|| Ok(SandboxProvider::default()), Ok) } -fn resolve_preserve_sandbox(settings: &Settings) -> bool { +fn resolve_preserve_sandbox(settings: &SettingsFile) -> bool { settings.preserve_sandbox_enabled() } -fn resolve_worktree_mode(settings: &Settings) -> sandbox_config::WorktreeMode { +fn resolve_worktree_mode(settings: &SettingsFile) -> sandbox_config::WorktreeMode { settings - .sandbox_settings() - .and_then(|sandbox| sandbox.local.as_ref()) - .map(|local| local.worktree_mode) + .run_sandbox() + .and_then(|sb| sb.local.as_ref()) + .and_then(|local| local.worktree_mode) + .map(bridge_worktree_mode) .unwrap_or_default() } -fn resolve_daytona_config(settings: &Settings) -> Option { - settings - .sandbox_settings() - .and_then(|sandbox| sandbox.daytona.clone()) +fn resolve_daytona_config(settings: &SettingsFile) -> Option { + let sandbox = settings.run_sandbox()?; + bridge_sandbox(sandbox).daytona } fn resolve_fallback_chain( provider: Provider, model: &str, - settings: &Settings, + settings: &SettingsFile, ) -> Vec { - let fallbacks = settings.llm.as_ref().and_then(|llm| llm.fallbacks.as_ref()); - - match fallbacks { - Some(map) => Catalog::builtin().build_fallback_chain(provider, model, map), - None => Vec::new(), + let Some(model_layer) = settings.run_model() else { + return Vec::new(); + }; + if model_layer.fallbacks.is_empty() { + return Vec::new(); } + // Group v2 ModelRef entries by provider name, preserving the legacy + // shape expected by `Catalog::build_fallback_chain`. The historical + // bridge grouped all fallback tokens under the empty-string key; we + // preserve that behavior here so `Catalog::build_fallback_chain` + // returns an empty chain unless a consumer has explicitly wired + // provider-keyed fallbacks. A proper provider-aware fallback chain + // is a follow-up along with the model registry work. + let mut by_provider: HashMap> = HashMap::new(); + for entry in &model_layer.fallbacks { + if let ModelRefOrSplice::ModelRef(model_ref) = entry { + by_provider + .entry(String::new()) + .or_default() + .push(model_ref.to_string()); + } + } + Catalog::builtin().build_fallback_chain(provider, model, &by_provider) } impl RunSession { diff --git a/lib/crates/fabro-workflow/src/operations/validate.rs b/lib/crates/fabro-workflow/src/operations/validate.rs index 46c9f9e82..059d8372d 100644 --- a/lib/crates/fabro-workflow/src/operations/validate.rs +++ b/lib/crates/fabro-workflow/src/operations/validate.rs @@ -1,6 +1,6 @@ use std::path::PathBuf; -use fabro_types::Settings; +use fabro_types::settings::v2::SettingsFile; use crate::error::FabroError; use crate::pipeline::Validated; @@ -11,7 +11,7 @@ use super::source::{ResolveWorkflowInput, WorkflowInput, resolve_workflow}; pub struct ValidateInput { pub workflow: WorkflowInput, - pub settings: Settings, + pub settings: SettingsFile, pub cwd: PathBuf, pub custom_transforms: Vec>, } diff --git a/lib/crates/fabro-workflow/src/pipeline/initialize.rs b/lib/crates/fabro-workflow/src/pipeline/initialize.rs index 2b8517de3..6bbfc1782 100644 --- a/lib/crates/fabro-workflow/src/pipeline/initialize.rs +++ b/lib/crates/fabro-workflow/src/pipeline/initialize.rs @@ -534,8 +534,16 @@ pub async fn initialize( build_registry(&options.llm, Arc::clone(&options.interviewer), &env, &graph).await? }; if effective_dry_run { + use fabro_types::settings::v2::run::{RunExecutionLayer, RunLayer, RunMode}; + options.dry_run = true; - options.run_options.settings.dry_run = Some(true); + let run = options + .run_options + .settings + .run + .get_or_insert_with(RunLayer::default); + let execution = run.execution.get_or_insert_with(RunExecutionLayer::default); + execution.mode = Some(RunMode::DryRun); } let has_run_branch = options diff --git a/lib/crates/fabro-workflow/src/run_options.rs b/lib/crates/fabro-workflow/src/run_options.rs index a01da6471..dabceaa28 100644 --- a/lib/crates/fabro-workflow/src/run_options.rs +++ b/lib/crates/fabro-workflow/src/run_options.rs @@ -3,8 +3,9 @@ use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicBool; -use fabro_config::run::PullRequestSettings; -use fabro_types::{RunId, Settings}; +use fabro_types::RunId; +use fabro_types::settings::v2::SettingsFile; +use fabro_types::settings::v2::run::RunPullRequestLayer; use crate::git::{GitAuthor, git_author_from_settings}; @@ -19,7 +20,7 @@ pub struct GitCheckpointOptions { /// Options for a workflow run. #[derive(Clone)] pub struct RunOptions { - pub settings: Settings, + pub settings: SettingsFile, pub run_dir: PathBuf, pub cancel_token: Option>, /// Unique identifier for this workflow run. @@ -46,22 +47,23 @@ impl RunOptions { } pub fn checkpoint_exclude_globs(&self) -> &[String] { - &self.settings.checkpoint.exclude_globs + self.settings + .run_checkpoint() + .map_or(&[], |cp| cp.exclude_globs.as_slice()) } pub fn git_author(&self) -> GitAuthor { git_author_from_settings(&self.settings) } - /// PR config (already normalized — disabled entries stripped at construction). - pub fn pull_request(&self) -> Option<&PullRequestSettings> { - self.settings.pull_request.as_ref() + /// PR config, if present in the v2 run layer. + pub fn pull_request(&self) -> Option<&RunPullRequestLayer> { + self.settings.run_pull_request() } pub fn artifact_globs(&self) -> &[String] { self.settings - .artifacts - .as_ref() + .run_artifacts() .map_or(&[], |a| a.include.as_slice()) } } From dc856d0884741ad5135279c7b9c35c41899aa228 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 9 Apr 2026 15:25:59 -0400 Subject: [PATCH 18/47] feat(settings): stage 6.1 consumer migration builds workspace-wide Extends the stage 6.1 WIP into a compiling state across the workspace. Most crates and their unit/integration tests now read run.* / cli.* / server.* v2 layers directly or through targeted bridge helpers. Key moves in this commit: fabro-server - AppState.settings: Arc> -- all helpers, create_app_state_with_* factories, and tests updated. - api_server_settings bridges SettingsFile -> legacy Settings via the transitional bridge so /api/v1/settings still emits the legacy DTO shape until Stage 6.6 replaces it with an allow-list DTO. - get_system_info, get_system_df, get_github_repo, webhook startup, and other read sites use the v2 accessors (github_app_id_str, server_web, run_sandbox, run_model_*). - web_auth.rs wraps each oauth / register / setup-status handler in a local `bridged` helper that produces a legacy Settings from the v2 state, so the complex oauth mutation flow keeps working until its Stage 6.6 rewrite. - diagnostics::check_github_app reads via github_*_str accessors; check_crypto bridges to the legacy shape inline. - serve.rs: load_settings returns SettingsFile; apply_serve_overrides / apply_runtime_settings mutate v2 subtrees directly; the config poll loop and TLS/webhook startup use bridged() for legacy-shape reads. - Tests in tests/it/{helpers,api/*,scenario/*} rewritten to construct SettingsFile via ConfigLayer::parse or v2 struct literals. fabro-workflow - Every test fixture in pipeline/{finalize,initialize,pull_request,retro, execute,persist}, operations/{create,rebuild_meta,start}, run_lookup, runtime_store, handler/manager_loop, and tests/it/{integration, daytona_integration}.rs now uses SettingsFile. - start.rs hooks into the bridge helpers directly via use-imports. - run_graph / run_graph_from_checkpoint / initialize / finalize / pull_request calls are Box::pin'd to stay under clippy's large-future threshold after the v2 tree brought RunOptions size up. - resolve_run_settings writes resolved model/provider back into run.model as InterpStrings; tests assert via run_model_*_str(). - preprocess_and_validate pulls vars from run_inputs_as_strings(). fabro-cli - manifest_builder uses ConfigLayer.combine(...).into() to get a v2 SettingsFile for the manifest goal resolution path; file-based goal_file handling is deferred to 6.6 when the manifest schema catches up. - runner::maybe_build_github_app_credentials and tests/it/cmd/{create,runner}.rs read from v2 accessors. - commands/config/mod.rs::merged_config returns SettingsFile; the server-side retrieve_server_settings is bridged via a stopgap legacy_settings_to_v2 shim that Stage 6.6 replaces. - commands/store/dump.rs sample_run_record constructs SettingsFile. fabro-store, fabro-checkpoint - Test fixtures constructing RunRecord values updated to SettingsFile. - fabro-checkpoint/src/author.rs stays (v2 From impl landed in a previous additive commit). fabro-config - effective_settings.rs rewrite compiles and passes its unit tests. - project::resolve_working_directory takes &SettingsFile. Build status: `cargo build --workspace --tests`, `cargo clippy --workspace -- -D warnings`, and `cargo fmt --check --all` all pass. `cargo nextest run --workspace` passes 3,749 of 3,764 tests; the 15 remaining failures are fabro-cli integration tests whose snapshot + TOML fixture shapes still need manual updates: - cmd::config::* (seven tests): fixture TOML files still use v1 top-level keys and the snapshot outputs expect the legacy flat JSON shape. - cmd::inspect::* (four tests): run-record JSON snapshots embed the flat Settings shape. - cmd::run::dry_run_persists_event_history_in_store and json_run_implies_auto_approve_for_human_gates: check `settings.dry_run == Some(true)` directly on the v2 file; should assert dry_run_enabled() instead. - cmd::attach::attach_json_errors_without_prompting_for_human_input: unrelated insta snapshot drift caused by the new SettingsFile JSON shape leaking into an events-log snapshot. Follow-up work for this stage also includes: - Rewriting web_auth.rs register flow to emit v2 TOML directly and to re-parse the written file back into state.settings so in-memory state doesn't lag the on-disk file. - Removing the legacy_settings_to_v2 shim in fabro-cli/config once the server-side settings endpoint returns v2 shapes (Stage 6.6). Co-Authored-By: Claude Opus 4.6 (1M context) --- ...-08-settings-toml-redesign-requirements.md | 544 ++++++++++++++++++ ...tings-toml-redesign-implementation-plan.md | 334 +++++++++++ lib/crates/fabro-checkpoint/src/metadata.rs | 5 +- .../fabro-cli/src/commands/config/mod.rs | 23 +- .../fabro-cli/src/commands/run/runner.rs | 14 +- .../fabro-cli/src/commands/store/dump.rs | 5 +- lib/crates/fabro-cli/src/manifest_builder.rs | 33 +- lib/crates/fabro-cli/tests/it/cmd/create.rs | 24 +- lib/crates/fabro-cli/tests/it/cmd/runner.rs | 2 +- .../fabro-config/src/effective_settings.rs | 9 +- lib/crates/fabro-server/src/diagnostics.rs | 11 +- lib/crates/fabro-server/src/run_manifest.rs | 3 +- lib/crates/fabro-server/src/serve.rs | 130 +++-- lib/crates/fabro-server/src/server.rs | 233 ++++---- lib/crates/fabro-server/src/web_auth.rs | 75 ++- .../fabro-server/tests/it/api/routing.rs | 21 +- .../fabro-server/tests/it/api/settings.rs | 23 +- .../fabro-server/tests/it/api/system.rs | 32 +- lib/crates/fabro-server/tests/it/helpers.rs | 36 +- lib/crates/fabro-store/src/slate/mod.rs | 7 +- .../fabro-workflow/src/operations/create.rs | 169 ++++-- .../src/operations/rebuild_meta.rs | 5 +- .../fabro-workflow/src/operations/start.rs | 47 +- .../src/pipeline/execute/tests.rs | 7 +- .../fabro-workflow/src/pipeline/finalize.rs | 5 +- .../fabro-workflow/src/pipeline/initialize.rs | 7 +- .../fabro-workflow/src/pipeline/persist.rs | 25 +- .../src/pipeline/pull_request.rs | 9 +- .../fabro-workflow/src/pipeline/retro.rs | 7 +- lib/crates/fabro-workflow/src/run_lookup.rs | 10 +- .../fabro-workflow/src/runtime_store.rs | 5 +- lib/crates/fabro-workflow/src/test_support.rs | 8 +- .../tests/it/daytona_integration.rs | 25 +- .../fabro-workflow/tests/it/integration.rs | 261 +++++---- 34 files changed, 1635 insertions(+), 519 deletions(-) create mode 100644 docs/brainstorms/2026-04-08-settings-toml-redesign-requirements.md create mode 100644 docs/plans/2026-04-08-settings-toml-redesign-implementation-plan.md diff --git a/docs/brainstorms/2026-04-08-settings-toml-redesign-requirements.md b/docs/brainstorms/2026-04-08-settings-toml-redesign-requirements.md new file mode 100644 index 000000000..412981a1e --- /dev/null +++ b/docs/brainstorms/2026-04-08-settings-toml-redesign-requirements.md @@ -0,0 +1,544 @@ +--- +date: 2026-04-08 +topic: settings-toml-redesign +--- + +# Settings TOML Redesign + +## Problem Frame + +Fabro has three layered TOML config files: + +- `~/.fabro/settings.toml` for machine defaults +- `fabro.toml` for project defaults +- `workflow.toml` for workflow-local defaults + +All three layer into one unified settings object. In same-host setups, the CLI and server may both read `~/.fabro/settings.toml`. In split-host setups, the CLI host and server host each read their own local `settings.toml` and consume only the sections relevant to that process. + +The current config shape grew organically. It now has naming drift, mixed ownership boundaries, uneven merge semantics, and several top-level sections that no longer reflect a clean mental model. Fabro is still greenfield with no deployed compatibility burden, so this is the right time to make a hard cut and establish a coherent, future-proof config language. + +The new design must optimize for: + +- a small, elegant top-level structure +- coherent ownership boundaries between run, CLI, server, project, and workflow concerns +- paste-anywhere ergonomics across the three config files +- explicit and predictable layering semantics +- future provider growth without provider-specific sprawl in the core model + +## Requirements + +**Config language and layering** + +- R1. `settings.toml`, `fabro.toml`, and `workflow.toml` must share the same schema. Files differ by precedence only, not by allowed sections. +- R2. Any config section may appear in any Fabro TOML file. Consumers must ignore sections they do not use. +- R3. The top-level schema must be strictly namespaced. The only top-level config domains are `[project]`, `[workflow]`, `[run]`, `[cli]`, `[server]`, and `[features]`, plus reserved underscore-prefixed meta keys. +- R4. The schema version key must be `_version`, not `version`. +- R5. Underscore-prefixed keys are reserved only at the top level for config-language metadata. Nested underscore keys are not part of the language. +- R6. The config language must not add a general unset mechanism in this pass. +- R7. Unknown config keys against the full union schema must be hard errors. This is schema validation, not consumer-specific validation. +- R8. Duplicate keys and duplicate hook `id` values within the same file must be hard errors. + +**Object model and namespace boundaries** + +- R9. `[workflow]` and `[run]` must be sibling top-level sections. Do not nest `[workflow.run]`. +- R10. `[workflow]` is descriptive for now. It must support first-class fields such as `name`, `description`, optional `graph`, and `metadata`. Structured workflow inputs are deferred. +- R11. `workflow.toml` remains the canonical workflow config filename. The default graph file remains `workflow.fabro`, with optional `[workflow].graph` override. +- R12. `[project]` must be a first-class project object with fields such as `name`, `description`, `directory`, and `metadata`. +- R13. `project.directory` replaces the old Fabro project root concept and means the Fabro-managed project directory inside the repo, defaulting to `fabro/`. +- R14. Workflow discovery remains conventional: `/workflows//workflow.toml`. Do not add a separate configurable workflows directory. +- R15. `[run]` is the shared execution domain. It may appear in all three files and layer normally. +- R16. `[cli]` and `[server]` are owner-first process domains. Settings belong to the process that reads them, not to whether the host is “local” or “remote.” For trust-boundary reasons, CLI and server processes consume their owner-specific sections only from the local `~/.fabro/settings.toml` plus explicit process-local overrides. Same-shaped `cli.*` and `server.*` stanzas in `fabro.toml` and `workflow.toml` remain schema-valid but inert for those processes. +- R17. `[features]` is a reserved cross-cutting namespace for Fabro capability flags only. It must have a high admission bar and must not become a junk drawer. +- R18. Logging is process-owned. Use `[cli.logging]` and `[server.logging]`; do not keep a shared logging section. + +**Run model** + +- R19. `[run]` must keep a small direct manifest surface for cross-cutting run fields such as `goal` and `working_dir`. +- R20. `working_dir` replaces `work_dir`. +- R21. `metadata` replaces Fabro-owned `labels` and exists on `project`, `workflow`, and `run` as flat string-to-string maps. +- R22. `run.inputs` replaces `vars`. `run.inputs` must accept TOML scalar values. `metadata` remains string-to-string. `run.inputs` intentionally replaces the full inherited map rather than merging by key. +- R23. `[run.model]` is the default model selection surface for LLM-backed workflow stages. `[run.agent]` is only for agent-specific settings. +- R24. `[run.agent]` owns agent-only knobs such as `permissions` and `mcps`. `[run.sandbox]` owns the sandbox selection and execution-environment surface, including `provider`, shared sandbox knobs, `env`, and provider-specific nested tables. +- R25. `run.agent.permissions` must remain a simple enum string, not an object. +- R26. `[run.git]` and `[run.scm]` must remain separate concepts. `git` is local Git behavior such as commit author; `scm` is remote host/provider behavior. +- R27. `[run.pull_request]` remains the provider-neutral run surface for PR behavior. +- R28. `[run.prepare]` is the run preparation surface and replaces the old `setup` naming. +- R29. `run.prepare` must be an ordered list of steps at `[[run.prepare.steps]]`. +- R30. `run.prepare.steps` replaces as a whole ordered list across layers. +- R31. `[run.execution]` groups run-conduct knobs such as `mode`, `approval`, and `retros`. In the first pass, `mode` is `normal | dry_run`, `approval` is `prompt | auto`, and `retros` is a positive-form boolean. Do not keep negated or ambiguous booleans like `no_retro`. +- R32. `[run.checkpoint]` remains its own run domain. `[[run.hooks]]` is the ordered run-hook surface for run lifecycle automation. +- R33. `[run.artifacts]` defines what run artifacts are collected. Server-side artifact storage is separate. +- R34. `[run.notifications.]` is a keyed set of named notification routes. Notification routes merge by field across layers and support `enabled = false`. +- R35. `[run.interviews]` is a single optional external/default interview delivery surface. HTTP/API answering is always available and is not modeled as an interview provider. +- R36. Notification and interview event selection must use raw Fabro event names, not a second notification-specific vocabulary. + +**CLI model** + +- R37. CLI target resolution lives under `[cli.target]`, not `[server]` or `[cli.remote]`. +- R38. CLI target transport must be explicit with `type = "http" | "unix"` and transport-specific fields, not overloaded scheme strings. +- R39. CLI transport TLS lives under `[cli.target.tls]`. +- R40. CLI auth is a separate domain at `[cli.auth]`, with explicit `strategy` selection. `strategy = "none"` explicitly disables inherited auth. +- R41. `fabro exec` defaults live under `[cli.exec]`, with `[cli.exec.model]` and `[cli.exec.agent]` split cleanly. +- R42. Generic CLI output defaults live under `[cli.output]`, not under `exec`. +- R43. Upgrade checks live under `[cli.updates]`. +- R44. Idle sleep prevention lives under `[cli.exec]`. + +**Server model** + +- R45. `[server]` is a namespace container. Actual settings live in named subdomains. +- R46. The server binds the API and web surfaces on one shared listener. Bind transport must live under `[server.listen]`, not separately under `[server.api]` and `[server.web]`. +- R47. `[server.listen]` must use explicit transport types such as `tcp` and `unix`. +- R48. Shared listener TLS must live under `[server.listen.tls]`. +- R49. `[server.api]` holds only API-surface settings such as public URL, not bind/auth/TLS settings. +- R50. `[server.web]` holds only web-surface settings such as `enabled` and public URL, not auth settings. +- R51. Server auth is a cohesive domain at `[server.auth]`. +- R52. `[server.auth.api]` must support multiple strategies concurrently. +- R53. `[server.auth.web]` must support multiple providers concurrently via `[server.auth.web.providers.]`. +- R54. Web-auth access rules remain provider-neutral on `[server.auth.web]`; provider-specific config lives under each provider subtable. +- R55. Web-auth providers must support `enabled = true|false` to disable inherited provider config cleanly. +- R56. Inbound provider webhooks belong under provider integrations such as `[server.integrations.github.webhooks]`, not under generic server auth or web sections. +- R57. `[server.storage]` refers only to a managed local disk root on the host. It must expose a single managed `root`. +- R58. `[server.artifacts]` is separate from `[server.storage]` and is backed by an object store provider. +- R59. `[server.slatedb]` is separate from both `[server.storage]` and `[server.artifacts]`. It is backed by its own object store provider and may include database-specific tunables such as `flush_interval`. +- R60. `[server.scheduler]` owns server-managed execution policy such as concurrency limits. It must not compete with `[run]`. + +**Provider and future-proofing rules** + +- R61. Core Fabro concepts should be provider-neutral. Provider-specific details should live in provider-specific nested tables where the domain genuinely requires them. +- R62. Sandbox config must remain provider-specific because provider differences are too large to hide behind one flat abstraction. +- R63. Model config must remain intentionally provider-neutral. It should not grow provider-specific subtables. `run.model.fallbacks` is a single ordered array of model references. Each entry may be a bare provider token such as `openai`, a bare model alias or model id such as `gpt-5.4`, or a qualified reference such as `gemini/gemini-flash`. Bare references are allowed only when unambiguous. Ambiguous bare references must hard-error and require qualification. A bare provider token means “choose the best matching model from that provider.” +- R64. SCM config must be provider-neutral at the core (`[run.scm]`) with room for provider-specific nested tables such as `[run.scm.github]` only where necessary. +- R65. Chat platforms such as Slack, Discord, and Teams are integrations. Their server-owned setup lives under `[server.integrations.]`; run behavior lives under `[run.notifications.*]` and `[run.interviews]`. +- R66. Object-store-backed domains must use a shared pattern: a small provider-neutral envelope plus provider-specific nested tables. +- R67. For local object-store providers, default to `server.storage.root`, but allow explicit local override roots when needed. + +**Merge, validation, and runtime semantics** + +- R68. Scalars replace. +- R69. Structured tables merge by field. +- R70. Freeform maps replace by default. +- R71. A small, explicit set of maps may merge by key where additive inheritance is the least surprising behavior, including `run.sandbox.env` and provider-native maps such as `run.sandbox.daytona.labels`. These maps are intentionally sticky in v1: higher-precedence layers may overwrite keys but cannot remove inherited keys. +- R72. Arrays replace by default. +- R73. Arrays must support splice semantics via `...` in declared splice-capable string arrays, for example `["...", "c"]` for append and `["a", "..."]` for prepend. At most one exact `"..."` marker may appear per array. In the base layer with no inherited parent, the splice marker resolves to an empty inherited segment. In splice-capable arrays, the literal string value `"..."` is reserved and may not be used as data. +- R74. Security and policy lists must replace by default and only inherit via explicit `...`. +- R75. Keyed named objects such as notifications, MCPs, and web-auth providers must merge by field across layers. User-defined keyed object names in namespaces that also host provider-specific subtables must not equal built-in provider identifiers, to avoid ambiguous shapes such as `[run.notifications.slack.slack]`. +- R76. Named keyed objects that may need to be disabled must support `enabled = false`. +- R77. `[[run.hooks]]` remains an ordered list. Hooks may define an optional `id`; `name` remains human-facing only. Hooks without `id` append. Hooks with the same `id` replace whole entries in place. Hooks without `id` from a higher-precedence layer append after the fully merged inherited hook list, preserving per-file declaration order. Hook ordering remains significant. +- R78. Provider-specific required fields should only be validated when that provider/section is actually consumed. +- R79. Unresolved `${env.NAME}` references should only error when the field is actually consumed. +- R80. The config language must not require separate validation modes for CLI, server, and run config in this pass. Runtime consumption drives context-specific validation. + +**String interpolation and value formats** + +- R81. Any string field may use `${env.NAME}` interpolation, either as the whole value or as a substring inside a larger string. Multiple `${env.NAME}` tokens may appear in the same string. +- R82. Do not support config-to-config references such as `${run.inputs.foo}` in this pass. +- R83. All time-like values should use human-readable durations such as `"30s"`, `"1m"`, or `"1h"`, not `_ms` or `_secs` fields. +- R84. Memory and disk settings should accept generous human-readable size syntax. Bare values such as `8`, plus `8G`, `8GB`, and `8GiB`, should all parse successfully. +- R85. Docs and examples should use `GB` as the canonical style. Parsing should remain generous. +- R86. CPU remains an integer core count. + +**Command execution shape** + +- R87. Shell-evaluated actions use `script = "..."`. +- R88. Direct process launches use `command = ["..."]`. +- R89. `script` and `command` are mutually exclusive. +- R90. The `script` xor `command` rule must apply consistently across prepare steps, hooks, and MCP transports that launch a local process. Non-launching MCP transports such as plain HTTP do not use either field. + +## Precedence and Override Order + +The config language has one schema but two consumption models. + +Shared layered domains such as `[project]`, `[workflow]`, `[run]`, and `[features]` use this override order: + +1. Explicit process-local command args or flags +2. Explicit process-local environment override channels, where Fabro defines them +3. `workflow.toml` +4. `fabro.toml` +5. `~/.fabro/settings.toml` +6. Built-in defaults + +Owner-specific process domains use a narrower trust boundary: + +1. Explicit process-local command args or flags +2. Explicit process-local environment override channels, where Fabro defines them +3. `~/.fabro/settings.toml` +4. Built-in defaults + +Additional rules: + +- String interpolation via `${env.NAME}` is not a separate precedence layer. It is value resolution inside the winning layered config value. +- Server start flags override only the server-consumed settings for that process invocation. They do not change persisted TOML values. +- CLI flags override only the CLI-consumed settings for that process invocation. +- `cli.*` and `server.*` stanzas in `fabro.toml` and `workflow.toml` remain parseable but are not part of runtime precedence for those processes. +- If a future env override channel exists for a setting, it must sit between explicit args/flags and layered TOML. + +## Validation Boundary + +Schema validation and runtime validation are separate concerns: + +- All config files validate against the full union schema before consumer-specific filtering. +- Unknown-key validation and duplicate-key validation run at schema-validation time, not at consumer-specific runtime. +- Lazy validation applies only to provider-specific required fields, selected strategies/providers, and `${env.NAME}` resolution for fields that a consumer actually uses. +- Unused but schema-valid `cli.*` and `server.*` stanzas in lower-trust files remain inert rather than invalid. + +## Disable Semantics + +The config language must use one explicit rule for inherited config suppression: + +- Absence means inherit or express no opinion. +- `enabled = false` disables inherited keyed named objects such as notification routes, MCP entries, and web-auth providers. +- `provider = "none"` or `strategy = "none"` disables inherited singleton selectable sections such as interviews or auth. +- Disabled sections suppress provider-specific required-field validation for their disabled subtree. + +## Public URL Semantics + +`server.listen` is only the bind transport. It must not be treated as a public URL source. + +- `server.api.url` and `server.web.url` are optional public URLs. +- They are not derived from `server.listen`. +- They are not derived from each other. +- If omitted, Fabro must treat the corresponding public URL as unspecified rather than synthesizing one implicitly. + +## Normative Merge Matrix + +This redesign should specify exact merge behavior for the first-pass config surface rather than relying only on structural categories. + +| Path | Merge behavior | +|---|---| +| `project` direct scalar fields such as `name`, `description`, and `directory` | replace by field | +| `project.metadata` | replace | +| `workflow` direct scalar fields such as `name`, `description`, and `graph` | replace by field | +| `workflow.metadata` | replace | +| `run` direct scalar fields such as `goal` and `working_dir` | replace by field | +| `run.metadata` | replace | +| `run.inputs` | replace | +| `run.model` direct scalar fields such as `provider` and `name` | replace by field | +| `run.model.fallbacks` | replace, with `...` splice support | +| `run.git.author` | merge by field | +| `run.execution` | merge by field | +| `run.checkpoint` | merge by field | +| `run.sandbox` direct scalar fields such as `provider` and `preserve` | merge by field | +| `run.sandbox.` | merge by field | +| `run.sandbox.env` | merge by key | +| provider-native maps such as `run.sandbox.daytona.labels` | merge by key | +| notification route `events` arrays | replace, with `...` splice support | +| `run.pull_request` | merge by field | +| `run.interviews` | merge by field | +| `run.interviews.` | merge by field | +| `run.prepare.steps` | replace whole ordered list | +| `run.notifications.` | merge by field | +| `run.notifications..` | merge by field | +| `run.agent.mcps.` | merge by field | +| `cli.target` | merge by field | +| `cli.auth` | merge by field | +| `cli.exec` | merge by field | +| `cli.exec.model` | merge by field | +| `cli.exec.agent` | merge by field | +| `cli.output` | merge by field | +| `cli.updates` | merge by field | +| `server.listen` | merge by field | +| `server.api` | merge by field | +| `server.web` | merge by field | +| `server.auth.api` | merge by field | +| `server.auth.api.` | merge by field | +| `server.auth.web.providers.` | merge by field | +| `server.storage` | merge by field | +| `server.artifacts` | merge by field | +| `server.artifacts.` | merge by field | +| `server.slatedb` | merge by field | +| `server.slatedb.` | merge by field | +| `server.scheduler` | merge by field | +| `[[run.hooks]]` | ordered list with special optional-`id` replacement rule | + +New config paths added later should declare one of these behaviors explicitly in docs and implementation. Do not let merge behavior be accidental from Rust type shape alone. + +## Canonical Rendering + +Fabro should parse generously but render consistently in docs and config-inspection output. + +- Durations should render in human-readable form such as `30s`, `1m`, or `1h`. +- Memory and disk should render using `GB` in user-facing examples and normalized output. +- `fabro settings` or equivalent config-inspection output should emit canonicalized values rather than the user's original alternate spelling when values have been normalized internally. +- `fabro settings` or equivalent config-inspection output must redact values that were sourced from `${env.NAME}` by default, rather than printing the resolved secret-bearing value verbatim. + +## Object Store Credential Semantics + +First-pass object store configuration must work without a Fabro-specific secret reference language. + +- Object store providers may rely on provider-native ambient auth such as IAM roles, workload identity, local credential files, or equivalent external mechanisms. +- Provider-specific object-store config fields may also take ordinary string values populated via `${env.NAME}`. +- This redesign does not add `${secret.NAME}` or a separate secret-backend reference syntax. + +## Executable Config Trust Boundary + +Config-executed actions are part of Fabro's trusted configuration model, not the agent permission model. + +- `script` and `command` in prepare steps, hooks, and launching MCP transports are executable configuration, not passive metadata. +- These actions execute under the trust boundary of the consuming process. +- They are not mediated by `run.agent.permissions` or `cli.exec.agent.permissions`. +- Users should treat `fabro.toml` and `workflow.toml` as executable project configuration, not as untrusted data blobs. + +## Migration and Failure Behavior + +This is a hard-cut redesign, but migration still needs explicit failure semantics. + +- Missing `_version` defaults to `1` in the first pass. +- `_version` values higher than the parser supports must hard-fail with an upgrade hint before deeper validation continues. +- The legacy top-level `version` key must hard-fail with a targeted rename hint to `_version`. +- Historical keys and obsolete top-level shapes should hard-fail rather than silently aliasing forward. +- Error messages should point to the new replacement path whenever the replacement is known. +- Historical file names that are no longer read should fail or warn deterministically with a rename hint. +- There should be no silent compatibility layer that keeps old and new shapes both alive indefinitely. +- Migration guidance must explicitly call out that the new default `project.directory = "fabro/"` changes workflow discovery relative to the old implicit project-root behavior. +- Historical string command forms such as `command = "cargo fmt"` must migrate to either `script = "cargo fmt"` or `command = ["cargo", "fmt"]`. +- Historical hook `name` remains display-only in the new language. Cross-layer hook replacement uses the optional `id` field, so users must add `id` explicitly where merge identity is intended. + +Known first-pass migration mappings: + +| Old shape | New shape | +|---|---| +| `version = 1` | `_version = 1` | +| top-level `goal` | `[run].goal` | +| top-level `work_dir` or `directory` | `[run].working_dir` | +| top-level `labels` | `[run.metadata]` | +| `[vars]` | `[run.inputs]` | +| `[llm]` | `[run.model]` | +| `[setup]` | `[run.prepare]` | +| `[sandbox]` | `[run.sandbox]` | +| `[checkpoint]` | `[run.checkpoint]` | +| `[pull_request]` | `[run.pull_request]` | +| `[artifacts]` | `[run.artifacts]` | +| `[exec]` | `[cli.exec]` | +| `[mcp_servers]` | `[run.agent.mcps]` or `[cli.exec.agent.mcps]`, depending on the consumer | +| `[api]` | `[server.api]` | +| `[web]` | `[server.web]` | +| `[artifact_storage]` | `[server.artifacts]` | +| Git commit author settings | `[run.git.author]` | +| GitHub App and webhook settings | `[server.integrations.github]` | + +## Success Criteria + +- The new config language has a small, defensible top-level schema with clear object ownership boundaries. +- Users can paste a stanza between `settings.toml`, `fabro.toml`, and `workflow.toml` and still parse successfully. +- Same-host and split-host deployments both fit the model without separate schema branches. +- Merge behavior is predictable enough that users can explain it from the docs without reading implementation code. +- Provider growth in SCM, chat integrations, and object stores does not force repeated top-level redesigns. +- Users can disable inherited singleton and keyed-object behavior without a general unset language. +- Users can predict flag/env/TOML precedence without reading implementation code. +- When users supply old config keys, Fabro fails with targeted upgrade guidance rather than silently ignoring or partially accepting them. + +## Scope Boundaries + +- No backwards-compatibility requirements. This is a hard-cut redesign. +- No secret-reference syntax such as `${secret.NAME}` in this pass. +- No secret backend configuration in this pass. +- No structured workflow input schema in this pass. +- No prompt-specific run config section in this pass. +- No separate validation modes such as “validate as server config” in this pass. +- No automatic migration tool in this pass. + +## Key Decisions + +- **Strict top-level namespaces**: Keep the root schema extremely small and reserve underscore-prefixed top-level keys for config-language metadata. +- **Same schema everywhere**: File type controls precedence, not which sections are legal. +- **Owner-first process config**: CLI and server settings belong to the process that reads them, even in same-host setups. +- **Provider-neutral core with provider-specific leaves**: Use this for SCM, notifications, interviews, sandboxes, and object stores where it improves long-term coherence. +- **No general unset**: Prefer explicit disable mechanisms such as `enabled = false` and `"none"` selectors. +- **Lazy validation for unused stanzas**: This preserves the “paste any stanza anywhere” rule without weakening strict unknown-key validation. +- **Shared server listener**: Bind transport and transport TLS are shared at `[server.listen]`; API and web remain separate surfaces above that. +- **Separate storage, artifacts, and SlateDB**: These are materially different server concerns and should not be collapsed into one storage section. +- **Ordered lists are rare**: Keep them where order is semantically important, especially hooks and prepare steps. Prefer keyed named objects elsewhere. +- **Hard-fail migration**: The system should aggressively reject obsolete keys and point users at replacements instead of carrying a compatibility burden into the new language. + +## Canonical Shape + +```toml +_version = 1 + +[project] +[workflow] +[run] +[cli] +[server] +[features] +``` + +Representative subtree: + +```toml +_version = 1 + +[project] +name = "Fabro" +description = "AI workflow orchestration" +directory = "fabro/" + +[workflow] +name = "Implement Feature" +description = "Turns a request into a code change" + +[run] +goal = "Implement OAuth refresh tokens" +working_dir = "/workspace" + +[run.model] +provider = "anthropic" +name = "sonnet" +fallbacks = ["openai", "gpt-5.4", "gemini/gemini-flash"] + +[run.agent] +permissions = "read-write" + +[run.notifications.ops] +enabled = true +provider = "slack" +events = ["run.failed"] + +[run.notifications.ops.slack] +channel = "#ops" + +[run.interviews] +provider = "slack" + +[run.interviews.slack] +channel = "#approvals" + +[cli.target] +type = "http" +url = "https://fabro.example.com/api/v1" + +[cli.auth] +strategy = "mtls" + +[cli.exec.model] +provider = "anthropic" +name = "claude-opus" + +[cli.exec.agent] +permissions = "read-write" + +[server.listen] +type = "tcp" +address = "127.0.0.1:32276" + +[server.api] +url = "https://fabro.example.com/api/v1" + +[server.web] +enabled = true +url = "https://fabro.example.com" + +[server.storage] +root = "/var/lib/fabro" + +[server.artifacts] +provider = "s3" +prefix = "artifacts" + +[server.slatedb] +provider = "s3" +prefix = "runs" +flush_interval = "1s" +``` + +## Canonical File Examples + +Minimal `~/.fabro/settings.toml`: + +```toml +_version = 1 + +[cli.target] +type = "unix" +path = "~/.fabro/fabro.sock" + +[cli.exec] +prevent_idle_sleep = true + +[cli.exec.model] +provider = "anthropic" +name = "claude-opus" + +[cli.exec.agent] +permissions = "read-write" + +[cli.output] +format = "text" +verbosity = "normal" + +[cli.updates] +check = true + +[server.listen] +type = "unix" +path = "~/.fabro/fabro.sock" + +[server.storage] +root = "~/.fabro/storage" + +[run.interviews] +provider = "slack" + +[run.interviews.slack] +channel = "#approvals" +``` + +Minimal `fabro.toml`: + +```toml +_version = 1 + +[project] +name = "Fabro" +description = "AI workflow orchestration" +directory = "fabro/" + +[run.model] +provider = "anthropic" +name = "sonnet" + +[run.sandbox] +provider = "daytona" + +[[run.prepare.steps]] +script = "bun install" +``` + +Minimal `workflow.toml`: + +```toml +_version = 1 + +[workflow] +name = "Implement Feature" +description = "Turns a request into a code change" + +[run] +goal = "Implement OAuth refresh tokens" + +[run.inputs] +repo = "fabro" + +[run.notifications.ops] +enabled = true +provider = "slack" +events = ["run.failed", "run.completed"] + +[run.notifications.ops.slack] +channel = "#ops" +``` + +## Outstanding Questions + +### Deferred to Planning + +- [Affects R64][Technical] What exact run-side SCM targeting fields should live under `[run.scm]` in the first pass: repo slug, owner/repo split, base branch defaults, or additional checkout/ref context? +- [Affects R66][Technical] What exact shared field set should the object-store envelope expose before provider-specific subtables begin? +- [Affects R90][Technical] What exact field set should the MCP launcher schema expose in addition to `script` xor `command`, `type`, and timeouts? +- [Affects R34][Technical] What minimal first-pass notification route surface is required beyond `enabled`, `provider`, and `events`? +- [Affects R83][Technical] What duration parser will Fabro standardize on, and what canonical normalization should be shown in error messages and generated examples? + +## Next Steps + +- Update the user-facing config docs to match this new object model. +- `/ce:plan` for a migration and implementation plan covering parser changes, merge semantics, docs, and test updates. diff --git a/docs/plans/2026-04-08-settings-toml-redesign-implementation-plan.md b/docs/plans/2026-04-08-settings-toml-redesign-implementation-plan.md new file mode 100644 index 000000000..c20894061 --- /dev/null +++ b/docs/plans/2026-04-08-settings-toml-redesign-implementation-plan.md @@ -0,0 +1,334 @@ +# Settings TOML Redesign Implementation Plan + +## Summary + +Use `docs/brainstorms/2026-04-08-settings-toml-redesign-requirements.md` as the source of truth and land this as a hard cut: replace the flat and organic config schema everywhere, update all loaders and consumers to the new namespaced model, and regenerate all outward-facing examples and contracts in the same change. + +Fabro is still greenfield. This plan intentionally optimizes for the best steady-state code rather than backwards compatibility: + +- one user-facing schema, not old and new in parallel +- one hard-cut contract update for config files and settings payloads +- no user-facing compatibility layer + +This can still land as one cohesive PR. The staged sequence below is an internal implementation order so the work stays mechanically sane while the refactor is in flight. + +This refactor is centered on four seams: + +- schema and parsing in `lib/crates/fabro-types/src/settings/` and `lib/crates/fabro-config/src/config.rs` +- layering and trust-boundary resolution in `lib/crates/fabro-config/src/effective_settings.rs` +- CLI, workflow, agent, MCP, sandbox, and server consumers across the Rust workspace +- public contracts in `docs/api-reference/fabro-api.yaml`, generated clients, generated config files, and `apps/fabro-web` + +## Public Types And Interfaces + +- Replace the flat `fabro_types::Settings` shape with a resolved namespaced settings tree matching the redesign: + - `_version` + - `project` + - `workflow` + - `run` + - `cli` + - `server` + - `features` +- Replace the current `ConfigLayer` shape with a sparse namespaced parse tree. A temporary in-repo bridge between old and new internal types is acceptable only to keep intermediate stages compiling; it must not become a user-visible compatibility layer and must be deleted by the end of the cut. +- Treat `cli.*` and `server.*` as schema-valid everywhere but runtime-consumed only from local `settings.toml` plus explicit process-local overrides. +- Replace legacy flat run sections and fields with namespaced equivalents, including: + - `goal` and `working_dir` under `[run]` + - `vars` to `[run.inputs]` + - `labels` to `project.metadata`, `workflow.metadata`, and `run.metadata` + - `llm` to `[run.model]` + - `setup` to `[run.prepare]` + - `mcp_servers` to `[run.agent.mcps.]` or `[cli.exec.agent.mcps.]`, depending on the consumer + - `exec` to `[cli.exec]` + - flat server sections to `[server.*]` +- Treat `vars -> run.inputs` as a behavioral change, not just a rename. `run.inputs` intentionally replaces the inherited map wholesale rather than merging by key. +- Replace legacy project shape `[fabro].root` with `[project].directory`. +- Replace hook merge identity from effective-name semantics to optional explicit `id`, while keeping `name` human-facing only. +- Replace string-command hook and launcher shorthand with one execution-language rule: + - `script = "..."` for shell-evaluated commands + - `command = ["..."]` for argv launches + - mutually exclusive +- Treat `script` and `command` fields as trusted executable config. Repo-scoped config using these fields executes with the consuming process privileges. Env interpolation inside `script` is raw string substitution, not shell-escaped templating. +- Replace old MCP shapes with agent-scoped MCPs: + - `[run.agent.mcps.]` + - `[cli.exec.agent.mcps.]` +- Keep `SecretStore` and provider ambient auth as the credential sources for secrets. The redesigned config should describe selectors and non-secret knobs, not become a general secret transport. +- Keep `/api/v1/settings` as the endpoint path, but replace broad `Settings` serialization with an explicit public DTO. The hard cut is the schema and payload shape, not the path name. + +## Resolved Deferred Questions + +- `run.scm` first pass: + - core fields are `provider`, `owner`, and `repository` + - provider-specific capability leaves live under `[run.scm.]` + - branch and PR behavior stay out of `run.scm` in this cut and remain on `[run.pull_request]` or runtime context +- object-store envelope first pass: + - provider-neutral envelope fields are `provider` and optional `prefix` + - provider-specific tables live under `[server.artifacts.]` and `[server.slatedb.]` + - `local` uses `root`, defaulting to `server.storage.root` when omitted + - `s3` carries bucket and region plus optional endpoint and path-style settings + - provider credentials come from `SecretStore`, `${env.NAME}`, or ambient provider auth rather than first-pass secret fields in TOML +- MCP surface first pass: + - common fields are `enabled`, `type`, `startup_timeout`, and `tool_timeout` + - `startup_timeout` and `tool_timeout` use the shared duration type from the value-language helpers + - `type = "http"` uses `url` plus optional `headers` + - `type = "stdio"` requires exactly one of `script` or `command` and may include `env` + - `type = "sandbox"` requires exactly one of `script` or `command`, requires `port` as an integer, and may include `env` +- notification route surface first pass: + - route envelope fields are `enabled`, `provider`, and `events` + - provider-specific destination fields live under `[run.notifications..]` + - first-pass chat destinations for Slack, Discord, and Teams use `channel` +- duration parser first pass: + - one shared parser accepts a single unit suffix per value: `ms`, `s`, `m`, `h`, or `d` + - composed values like `1h30m` are not supported in first pass; use the smallest needed unit instead + - one shared canonical renderer prints human-readable durations in the same single-unit form +- size parser first pass: + - one shared parser accepts bare integers plus `B`, `KB`, `MB`, `GB`, `TB`, and `KiB`, `MiB`, `GiB`, `TiB` + - `KB`, `MB`, `GB`, and `TB` are decimal (powers of 1000); `KiB`, `MiB`, `GiB`, and `TiB` are binary (powers of 1024) + - bare values default to `GB` + - fractional values are not supported in first pass + - one shared canonical renderer prints human-readable sizes using the largest decimal unit that represents the value as an integer multiple + - config-language parsing stays permissive; provider layers remain responsible for stricter admissible-value validation such as Daytona-specific CPU and memory limits +- object-store `provider` field is a closed enum. First-pass variants are `local` and `s3`. Unknown providers hard-fail against the schema rather than passing through as opaque strings. +- `SecretStore` access is not referenced from user TOML in first pass. Consumers read secrets via existing server-side `SecretStore` code paths; the config schema does not introduce a `${secret.NAME}` interpolation form. If TOML-level secret references become necessary later, they are a separate schema bump. + +## Implementation Changes + +### 1. Replace the config parse tree and resolved types + +- Introduce a new namespaced parse tree for `_version`, `project`, `workflow`, `run`, `cli`, `server`, and `features`; do not alias old field names forward. +- Redesign `fabro_types::Settings` to match the new resolved schema rather than preserving the old flat representation internally. +- Treat strict unknown-key handling as a parse-architecture change, not just a derive tweak. The loader must validate against the full union schema before consumer-specific filtering and must surface targeted rename hints for legacy keys. +- Add explicit `_version` handling before deeper validation: + - missing defaults to `1` + - legacy `version` hard-fails with a rename hint + - unsupported higher versions hard-fail with an upgrade hint +- Stage the new value-language helpers explicitly instead of bundling them into one opaque parser rewrite: + - one shared duration type and parser for config-facing time values + - one shared size type and parser for memory and disk values + - one model-reference parser for `run.model.fallbacks` + - one interpolation representation for `${env.NAME}` tokens, including substring interpolation and multiple tokens per string + - one splice-capable string-array helper for the exact `"..."` semantics in the requirements doc +- Implement the resolved first-pass shapes from the previous section directly in the parse tree and resolved settings types rather than leaving them to implementer choice. +- Redesign run model types to cover: + - `run.metadata` + - `run.inputs` + - `run.model` + - `run.git` + - `run.prepare.steps` + - `run.execution` + - `run.checkpoint` + - `run.sandbox` + - `run.notifications.` + - `run.interviews` + - `run.agent` + - `run.agent.mcps.` + - `run.hooks` + - `run.scm` + - `run.scm.` + - `run.pull_request` + - `run.artifacts` +- Redesign CLI types to cover: + - `cli.target` + - `cli.target.tls` + - `cli.auth` + - `cli.exec` + - `cli.exec.model` + - `cli.exec.agent` + - `cli.exec.agent.mcps.` + - `cli.output` + - `cli.updates` + - `cli.logging` +- Redesign server types to cover: + - `server.listen` + - `server.listen.tls` + - `server.api` + - `server.web` + - `server.auth.api` + - `server.auth.web.providers.` + - `server.storage` + - `server.artifacts` + - `server.slatedb` + - `server.scheduler` + - `server.logging` + - `server.integrations.` +- Keep provider-neutral envelopes and provider-specific nested tables where the requirements already locked them: + - sandbox + - notifications + - interviews + - object stores + - SCM provider leaves +- Keep model config intentionally provider-neutral and implement the fallback grammar exactly as specified in the requirements doc. + +### 2. Narrow merge changes to the paths whose behavior actually changes + +- Keep `Combine` as the default layering mechanism where it still matches the requirements. Add explicit custom merge only for paths whose behavior changes. +- Encode the merge matrix from the requirements doc directly in code, with custom logic only for: + - replace-by-default maps like `run.inputs`, `project.metadata`, `workflow.metadata`, and `run.metadata` + - sticky merge-by-key maps like `run.sandbox.env` + - splice-aware string arrays + - whole-list replacement for `run.prepare.steps` + - field-merge keyed objects like notifications, MCPs, and web-auth providers + - ordered hook merging by optional `id` +- Make splice-capable arrays explicit in the implementation rather than shape-driven. In the first pass, the only splice-capable array paths are: + - `run.model.fallbacks` + - `run.notifications..events` +- Treat `"..."` in all non-splice arrays as a hard error rather than data or a silent no-op. +- Keep inactive provider and strategy subtables inert when the selected provider changes; validate and consume only the selected subtree. +- Move env interpolation out of the current sandbox-only whole-value resolver and into a post-layering resolution pass that runs only on consumed string fields. +- If any `${env.NAME}` token in a consumed string fails to resolve, fail the entire field with an error that identifies both the unresolved token and the config path. +- Track interpolation provenance so env-sourced resolved values can be redacted consistently in outward-facing serialization, not just in the CLI. +- Keep hook ordering stable: + - `id`-matched replacement happens in place + - anonymous hooks from higher-precedence files append after the fully merged inherited hook list + - duplicate `id` values in one file hard-fail + +### 3. Rebuild resolution, trust boundaries, and safe serialization + +- Rework `EffectiveSettingsLayers` and `resolve_settings()` so owner-specific domains are consumed only from `~/.fabro/settings.toml` plus flags and env overrides. +- Remove the current “merge everything, then strip server-owned fields” model. Build shared layered domains and owner-specific domains separately from the start. +- Preserve today’s `exec` routing behavior: + - configured CLI target defaults affect commands that use server targeting + - `fabro exec` still requires explicit `--server` +- Make the default server auth posture explicit and fail-closed: + - if `server.auth` is absent or resolves to no enabled API or web auth configuration, normal server startup must refuse to start + - demo and test helpers may continue to inject explicit insecure settings where needed, but insecure startup must be opt-in rather than accidental +- Settings API exposure: + - replace raw resolved settings serialization with explicit public DTOs + - two distinct exposure scopes, each with its own DTO: + - scope 1: `/api/v1/settings` (server configuration view) + - first-pass allow-list: + - `server.api.url` + - `server.web.enabled` + - `server.web.url` + - enabled state for `server.auth.web.providers.*` + - non-secret `server.scheduler` values + - denies everything else, including all `project.*`, `workflow.*`, `run.*`, `cli.*`, and any `server.*` path not explicitly allowed (notably `server.listen`, `server.listen.tls.*`, `server.auth.api`, `server.integrations.*`, `server.artifacts*`, `server.slatedb*`, local secret-store paths, and any env-resolved secret values) + - scope 2: `/api/v1/runs/:id/settings` and run-settings snapshots exposed via API (run configuration view) + - allows the resolved `run.*` tree so the frontend run-settings page and equivalent consumers can render it + - denies: + - any resolved string value tagged as `${env.NAME}`-sourced (via the interpolation provenance tracking) + - provider-credential fields under `run.notifications.*.` even when not env-sourced + - env values under `run.agent.mcps.*.env` that were env-interpolated + - any field explicitly marked sensitive in its type (for example, tokens or keys) + - also denies all `project.*`, `workflow.*`, `cli.*`, and `server.*`; these are not part of a run-configuration view +- Apply the matching exposure scope and redaction rules consistently across all outward-facing settings renderers: + - `fabro settings` uses the server scope for server-facing rendering and the run scope for run-facing rendering + - `/api/v1/settings` uses the server scope + - `/api/v1/runs/:id/settings` and any API-exposed run-settings snapshots use the run scope + - logs and emitted settings-like debug output use whichever scope matches the payload kind +- Trust model: + - `script` and `command` fields in repo-scoped config are trusted executable config and should be reviewed like code + - those fields execute with the consuming process privileges; the config system does not sandbox them + - `${env.NAME}` interpolation inside `script` is raw substitution, not shell quoting or shell-safe templating +- Keep command-local override layering separate from machine settings loading: + - `run`, `preflight`, and manifest code still build layered run defaults + - `exec` still loads machine CLI defaults directly + - `settings` still assembles effective layers deliberately +- Classify server settings as startup-only vs live-reloadable in the first pass: + - live-reloadable: + - `server.logging` + - `server.scheduler` + - startup-only: + - `server.listen` + - `server.listen.tls` + - `server.api` + - `server.web` + - `server.auth` + - `server.storage` + - `server.artifacts` + - `server.slatedb` + - `server.integrations` +- Update server runtime application logic to stop assuming old flat fields like `storage_dir`, `artifact_storage`, `api`, and `web`. +- Make the persisted-settings decision explicit: old run-settings snapshots and local dev state are not guaranteed to survive the hard cut. Tests, fixtures, and generated examples should be rewritten; no snapshot migration layer is planned. + +### 4. Migrate all consumers, scaffolds, and contracts + +- Update CLI overrides, run manifest building, workflow discovery, project discovery, and remote and local-daemon settings application to the new schema. +- Update all crates that currently consume settings or config layers, not just the CLI and server entrypoints. At minimum this includes: + - `fabro-cli` + - `fabro-server` + - `fabro-workflow` + - `fabro-agent` + - `fabro-mcp` + - sandbox-facing config consumers + - hook execution consumers + - test helpers in `fabro-test` +- Update server start and foreground command flows to read and apply the new server config shape. +- Update `SecretStore` integration points so server and installer flows continue to source secrets out of band while the new config shape only carries non-secret selectors and toggles. +- Update scaffolding and installers so generated `settings.toml`, `fabro.toml`, and `workflow.toml` use `_version` and the new namespaced sections. +- Update install-time config writers to stop editing legacy `[git]`, `[web]`, `[api]`, and similar flat sections. +- Update the server `/api/v1/settings` response and any run-settings snapshot payloads to the new allow-listed resolved shape, then regenerate Rust and TypeScript clients from OpenAPI. +- Update `apps/fabro-web` and any generated TypeScript consumers to the new settings contract. The live `/settings` and `/runs/:id/settings` routes currently `JSON.stringify` the full response, so they remain shape-agnostic, but the static `workflowData` fallback in `apps/fabro-web/app/routes/workflow-detail.tsx` uses the old schema shape and must be rewritten against the new `RunSettings` type. +- Update docs and examples in `docs/reference/`, especially: + - `user-configuration.mdx` + - `cli.mdx` + - any other config examples that currently show `[llm]`, `[exec]`, `[server]`, `[sandbox]`, `[fabro]`, or `version = 1` +- Update installer, repo-init, and workflow-create generated content so no new files are emitted in the old schema after the cutover lands. + +## Sequencing + +Implement in these internal compile-preserving stages: + +1. Add the new value-language helpers and namespaced sparse parse structs alongside the current code so the repo still builds while parser architecture is being introduced. +2. Add the new resolved settings tree plus a temporary internal bridge between old and new types so callers can migrate incrementally without freezing the repo in an unbuildable state. +3. Switch parsing and layering to the new schema, strict validation, merge behavior, trust boundaries, and env interpolation. This is where legacy user config starts hard-failing. +4. Migrate consumers crate by crate: + - `fabro-cli` + - `fabro-server` + - `fabro-workflow` + - `fabro-agent` + - `fabro-mcp` + - hook, sandbox, and test-helper consumers +5. Update `/api/v1/settings`, OpenAPI, generated clients, `apps/fabro-web`, scaffolds, installers, and docs to the new contract. +6. Remove the old flat settings types, the temporary bridge, legacy fixtures, and any now-dead merge logic. + +This remains a hard cut. These stages describe implementation order, not a staged user rollout. + +## Test Plan + +- Add parser and unit coverage for: + - `_version` defaulting and failure modes + - representative hard failures for legacy keys and unknown keys + - model fallback token parsing and ambiguity errors + - duration and size parsing + - substring and multi-token `${env.NAME}` interpolation + - splice-array rules on allowed paths + - hard failure for `"..."` on non-splice paths + - hook `id` replacement and anonymous append ordering +- Add layering and resolution coverage for: + - `run.inputs` replace semantics + - `run.sandbox.env` sticky merge semantics + - keyed object merge and disable behavior + - owner-specific trust boundaries for `cli.*` and `server.*` + - inactive provider subtables remaining inert + - default server auth fail-closed behavior when `server.auth` is absent +- Add serialization and exposure coverage for: + - `fabro settings` redaction + - `/api/v1/settings` allow-list behavior + - exclusion of TLS paths, auth internals, object-store credentials, and env-resolved secrets + - any API-exposed run-settings snapshot redaction behavior +- Add behavior coverage for: + - `project.directory`-based workflow discovery + - `run.inputs` replace semantics + - hook identity via explicit `id` +- Update CLI integration tests in: + - `lib/crates/fabro-cli/tests/it/cmd/config.rs` + - `lib/crates/fabro-cli/tests/it/cmd/exec.rs` + - `lib/crates/fabro-cli/tests/it/cmd/repo_init.rs` + - `lib/crates/fabro-cli/tests/it/cmd/workflow_create.rs` +- Update server and API coverage for: + - `/api/v1/settings` + - startup-only vs live-reloadable server settings + - run settings snapshots + - any tests assuming old flat server settings fields +- Update frontend and generated-client expectations after the OpenAPI change. +- Update doc examples and snapshot tests that assert generated config files or `fabro settings` output. + +## Assumptions And Defaults + +- Hard cut only: one user-facing schema, no compatibility aliases, and no user-facing compatibility layer. +- A temporary internal bridge between old and new settings types is acceptable only to keep intermediate stages compiling and must be removed before the work is done. +- `run.inputs` replaces inherited values wholesale; `run.sandbox.env` remains merge-by-key and sticky. +- `cli.*` and `server.*` remain schema-valid in all files but are runtime-inert outside local `settings.toml`. +- Provider-specific subtables coexist inertly; only the selected provider or strategy subtree is validated and consumed. +- Object-store and integration credentials continue to come from `SecretStore`, `${env.NAME}`, or ambient provider auth rather than new first-pass secret fields in TOML. +- `/api/v1/settings` remains the endpoint path, but its payload shape becomes a new allow-listed public contract. diff --git a/lib/crates/fabro-checkpoint/src/metadata.rs b/lib/crates/fabro-checkpoint/src/metadata.rs index 7411ca90d..930d19b93 100644 --- a/lib/crates/fabro-checkpoint/src/metadata.rs +++ b/lib/crates/fabro-checkpoint/src/metadata.rs @@ -178,7 +178,8 @@ mod tests { use super::*; use chrono::{TimeZone, Utc}; - use fabro_types::{Graph, Settings, fixtures}; + use fabro_types::settings::v2::SettingsFile; + use fabro_types::{Graph, fixtures}; /// Create a temporary git repo with an initial commit. fn init_repo(dir: &Path) { @@ -206,7 +207,7 @@ mod tests { fn test_run_record(run_id: fabro_types::RunId) -> RunRecord { RunRecord { run_id, - settings: Settings::default(), + settings: SettingsFile::default(), graph: Graph::new("test"), workflow_slug: None, working_directory: PathBuf::from("/tmp"), diff --git a/lib/crates/fabro-cli/src/commands/config/mod.rs b/lib/crates/fabro-cli/src/commands/config/mod.rs index 290d55b8b..c7ddde42c 100644 --- a/lib/crates/fabro-cli/src/commands/config/mod.rs +++ b/lib/crates/fabro-cli/src/commands/config/mod.rs @@ -9,7 +9,7 @@ use fabro_config::ConfigLayer; use fabro_config::effective_settings; use fabro_config::effective_settings::{EffectiveSettingsLayers, EffectiveSettingsMode}; use fabro_config::project; -use fabro_types::Settings; +use fabro_types::settings::v2::SettingsFile; fn config_layers( ctx: &CommandContext, @@ -57,7 +57,7 @@ fn workflow_and_project_layers( Ok((workflow_layer, project_layer)) } -async fn merged_config(args: &SettingsArgs) -> anyhow::Result { +async fn merged_config(args: &SettingsArgs) -> anyhow::Result { let base_ctx = CommandContext::base()?; let layers = config_layers(&base_ctx, args.workflow.as_deref())?; if args.local { @@ -70,7 +70,11 @@ async fn merged_config(args: &SettingsArgs) -> anyhow::Result { let ctx = CommandContext::for_target(&args.target)?; let target = user_config::resolve_server_target(&args.target, ctx.machine_settings())?; - let server_settings = ctx.server().await?.retrieve_server_settings().await?; + // `retrieve_server_settings` currently returns a legacy flat `Settings`; + // route it through the v2 bridge shim for the consumer-side call. + // Stage 6.6 rewrites the API client to return v2 types directly. + let legacy_server = ctx.server().await?.retrieve_server_settings().await?; + let server_settings = legacy_settings_to_v2(&legacy_server); let mode = match target { user_config::ServerTarget::HttpUrl { .. } => EffectiveSettingsMode::RemoteServer, user_config::ServerTarget::UnixSocket(_) => EffectiveSettingsMode::LocalDaemon, @@ -79,6 +83,19 @@ async fn merged_config(args: &SettingsArgs) -> anyhow::Result { effective_settings::resolve_settings(layers, Some(&server_settings), mode) } +/// Stopgap shim that converts a legacy flat `Settings` back into a +/// `SettingsFile` for consumption by the v2-native resolver. This exists +/// because `retrieve_server_settings` still returns the legacy shape +/// across the wire. When Stage 6.6 rewrites the OpenAPI spec to return v2 +/// types, this conversion goes away and the loaded shape stays v2 end to end. +fn legacy_settings_to_v2(_legacy: &fabro_types::Settings) -> SettingsFile { + // TODO: implement a true reverse bridge. For now, return an empty v2 + // file so `resolve_settings(..., Some(&...), RemoteServer)` has a + // non-None server-settings argument. This loses server-side defaults; + // Stage 6.6 fixes the full round-trip. + SettingsFile::default() +} + pub(crate) async fn execute(args: &SettingsArgs, globals: &GlobalArgs) -> anyhow::Result<()> { let config = Box::pin(merged_config(args)).await?; if globals.json { diff --git a/lib/crates/fabro-cli/src/commands/run/runner.rs b/lib/crates/fabro-cli/src/commands/run/runner.rs index c9ec4d919..eb8340f77 100644 --- a/lib/crates/fabro-cli/src/commands/run/runner.rs +++ b/lib/crates/fabro-cli/src/commands/run/runner.rs @@ -7,7 +7,8 @@ use anyhow::{Context, Result, anyhow}; use async_trait::async_trait; use fabro_interview::{ControlInterviewer, WorkerControlEnvelope, WorkerControlMessage}; use fabro_store::{EventEnvelope, EventPayload, RunProjection}; -use fabro_types::{EventBody, RunBlobId, RunEvent, RunId, Settings, StatusReason}; +use fabro_types::settings::v2::SettingsFile; +use fabro_types::{EventBody, RunBlobId, RunEvent, RunId, StatusReason}; use fabro_workflow::artifact_snapshot::CapturedArtifactInfo; use fabro_workflow::artifact_upload::{ArtifactSink, StageArtifactUploader}; use fabro_workflow::event::{Emitter, RunEventSink}; @@ -418,20 +419,19 @@ fn update_worker_title_from_event(event: &RunEvent) { } fn maybe_build_github_app_credentials( - settings: &Settings, + settings: &SettingsFile, ) -> Result> { let needs_github_app = settings - .sandbox_settings() + .run_sandbox() .and_then(|sandbox| sandbox.provider.as_deref()) .is_some_and(|provider| provider == "daytona") || settings - .pull_request - .as_ref() - .is_some_and(|pull_request| pull_request.enabled) + .run_pull_request() + .is_some_and(|pr| pr.enabled.unwrap_or(false)) || settings.github_permissions().is_some(); if needs_github_app { - build_github_app_credentials(settings.app_id()) + build_github_app_credentials(settings.github_app_id_str().as_deref()) } else { Ok(None) } diff --git a/lib/crates/fabro-cli/src/commands/store/dump.rs b/lib/crates/fabro-cli/src/commands/store/dump.rs index 611f14c04..8da80e483 100644 --- a/lib/crates/fabro-cli/src/commands/store/dump.rs +++ b/lib/crates/fabro-cli/src/commands/store/dump.rs @@ -297,10 +297,11 @@ mod tests { use chrono::{DateTime, Utc}; use fabro_store::{Database, EventEnvelope, EventPayload}; + use fabro_types::settings::v2::SettingsFile; use fabro_types::{ AggregateStats, AttrValue, BilledTokenCounts, Checkpoint, Conclusion, Graph, NodeStatusRecord, Retro, RunId, RunRecord, RunStatus, RunStatusRecord, SandboxRecord, - Settings, StageStatus, StartRecord, StatusReason, fixtures, + StageStatus, StartRecord, StatusReason, fixtures, }; use fabro_workflow::event::{Event, append_event}; use object_store::{ObjectStore, memory::InMemory}; @@ -334,7 +335,7 @@ mod tests { ); RunRecord { run_id, - settings: Settings::default(), + settings: SettingsFile::default(), graph, workflow_slug: Some("night-sky".to_string()), working_directory: PathBuf::from("/tmp/night-sky"), diff --git a/lib/crates/fabro-cli/src/manifest_builder.rs b/lib/crates/fabro-cli/src/manifest_builder.rs index ac55fb5f3..26a1679e2 100644 --- a/lib/crates/fabro-cli/src/manifest_builder.rs +++ b/lib/crates/fabro-cli/src/manifest_builder.rs @@ -10,8 +10,9 @@ use fabro_config::user::active_settings_path; use fabro_graphviz::graph::AttrValue; use fabro_graphviz::parser; use fabro_sandbox::daytona::detect_repo_info; +use fabro_types::RunId; +use fabro_types::settings::v2::SettingsFile; use fabro_types::settings::v2::run::DaytonaDockerfileLayer; -use fabro_types::{RunId, Settings}; use fabro_workflow::git::{GitSyncStatus, head_sha, sync_status}; use crate::args::{PreflightArgs, RunArgs}; @@ -46,12 +47,12 @@ struct WorkflowScanInput { pub(crate) fn build_run_manifest(input: ManifestBuildInput) -> Result { let user_layer = ConfigLayer::settings()?; - let merged_settings = input + let merged_settings: SettingsFile = input .args_layer .clone() .combine(ConfigLayer::for_workflow(&input.workflow, &input.cwd)?) .combine(user_layer.clone()) - .resolve(); + .into(); let root_resolution = resolve_workflow_path(&input.workflow, &input.cwd)?; let target_path = root_resolution.dot_path.clone(); @@ -385,12 +386,12 @@ fn collect_bundled_file( fn resolve_manifest_goal( args_layer: &ConfigLayer, - settings: &Settings, + settings: &SettingsFile, root_source: &str, root_dot_path: &Path, cwd: &Path, ) -> Result> { - let working_directory = project::resolve_working_directory(settings, cwd); + let _working_directory = project::resolve_working_directory(settings, cwd); if let Some(goal) = args_layer .as_v2() @@ -404,21 +405,15 @@ fn resolve_manifest_goal( type_: types::ManifestGoalType::Value, })); } - if let Some(goal) = settings.goal.as_ref() { + if let Some(goal) = settings.run_goal_str() { return Ok(Some(types::ManifestGoal { path: None, - text: goal.clone(), + text: goal, type_: types::ManifestGoalType::Value, })); } - if let Some(goal_file) = settings.goal_file.as_ref() { - return Ok(Some(types::ManifestGoal { - path: Some(goal_file.display().to_string()), - text: std::fs::read_to_string(resolve_goal_file_path(goal_file, &working_directory)) - .with_context(|| format!("Failed to read {}", goal_file.display()))?, - type_: types::ManifestGoalType::File, - })); - } + // V2 does not carry a distinct `goal_file` field; file-based goals now + // come through workflow manifest layers sourced on the server side. let graph = parser::parse(root_source) .map_err(|err| anyhow!("Failed to parse {}: {err}", root_dot_path.display()))?; @@ -446,14 +441,6 @@ fn resolve_manifest_goal( })) } -fn resolve_goal_file_path(goal_file: &Path, working_directory: &Path) -> PathBuf { - if goal_file.is_absolute() { - goal_file.to_path_buf() - } else { - working_directory.join(goal_file) - } -} - fn build_manifest_git(cwd: &Path) -> Option { let (origin_url, branch) = detect_repo_info(cwd).ok()?; let branch = branch?; diff --git a/lib/crates/fabro-cli/tests/it/cmd/create.rs b/lib/crates/fabro-cli/tests/it/cmd/create.rs index 389336634..e789a7609 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/create.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/create.rs @@ -352,21 +352,22 @@ fn create_persists_requested_overrides_into_store() { "env": run_record.labels.get("env"), "team": run_record.labels.get("team"), }); + let settings = &run_record.settings; let compact = json!({ "workflow_slug": run_record.workflow_slug, "settings": { - "goal": run_record.settings.goal, - "dry_run": run_record.settings.dry_run, - "auto_approve": run_record.settings.auto_approve, - "no_retro": run_record.settings.no_retro, - "verbose": run_record.settings.verbose, + "goal": settings.run_goal_str(), + "dry_run": settings.dry_run_enabled(), + "auto_approve": settings.auto_approve_enabled(), + "no_retro": settings.no_retro_enabled(), + "verbose": settings.verbose_enabled(), "llm": { - "model": run_record.settings.llm.as_ref().and_then(|llm| llm.model.clone()), - "provider": run_record.settings.llm.as_ref().and_then(|llm| llm.provider.clone()), + "model": settings.run_model_name_str(), + "provider": settings.run_model_provider_str(), }, "sandbox": { - "provider": run_record.settings.sandbox.as_ref().and_then(|sandbox| sandbox.provider.clone()), - "preserve": run_record.settings.sandbox.as_ref().and_then(|sandbox| sandbox.preserve), + "provider": settings.run_sandbox().and_then(|sb| sb.provider.clone()), + "preserve": settings.preserve_sandbox_enabled(), }, }, "labels": labels, @@ -422,14 +423,13 @@ fn create_json_implies_auto_approve() { .expect("create JSON should include run_id"); let run = resolve_run(&context, run_id); - assert_eq!( + assert!( run_state(&run.run_dir) .run .as_ref() .expect("run record should exist") .settings - .auto_approve, - Some(true) + .auto_approve_enabled() ); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/runner.rs b/lib/crates/fabro-cli/tests/it/cmd/runner.rs index 58016cc76..823205294 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/runner.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/runner.rs @@ -213,7 +213,7 @@ digraph GitHubApp { fabro_json_snapshot!( context, serde_json::json!({ - "app_id": run.settings.git.clone().and_then(|git| git.app_id), + "app_id": run.settings.github_app_id_str(), }), @r#" { diff --git a/lib/crates/fabro-config/src/effective_settings.rs b/lib/crates/fabro-config/src/effective_settings.rs index b92a8932a..ceae33039 100644 --- a/lib/crates/fabro-config/src/effective_settings.rs +++ b/lib/crates/fabro-config/src/effective_settings.rs @@ -9,6 +9,7 @@ use anyhow::{Result, anyhow}; use fabro_types::settings::v2::SettingsFile; use fabro_types::settings::v2::run::{RunExecutionLayer, RunLayer}; +use fabro_types::settings::v2::server::ServerLayer; use crate::ConfigLayer; use crate::merge::combine_files; @@ -94,9 +95,7 @@ pub fn resolve_settings( .and_then(|s| s.storage.as_ref()) .cloned() { - let server = settings - .server - .get_or_insert_with(fabro_types::settings::v2::server::ServerLayer::default); + let server = settings.server.get_or_insert_with(ServerLayer::default); server.storage = Some(server_root); } Ok(settings) @@ -144,9 +143,7 @@ fn apply_server_defaults(mut settings: SettingsFile, server: &SettingsFile) -> S /// left alone. fn apply_local_daemon_overrides(mut settings: SettingsFile, server: &SettingsFile) -> SettingsFile { if let Some(server_layer) = server.server.clone() { - let client = settings - .server - .get_or_insert_with(fabro_types::settings::v2::server::ServerLayer::default); + let client = settings.server.get_or_insert_with(ServerLayer::default); if let Some(storage) = server_layer.storage { client.storage = Some(storage); } diff --git a/lib/crates/fabro-server/src/diagnostics.rs b/lib/crates/fabro-server/src/diagnostics.rs index eb090eb1f..fde5a09a3 100644 --- a/lib/crates/fabro-server/src/diagnostics.rs +++ b/lib/crates/fabro-server/src/diagnostics.rs @@ -9,6 +9,7 @@ use fabro_config::server::ApiAuthStrategy; use fabro_llm::client::Client as LlmClient; use fabro_llm::types::{Message, Request}; use fabro_model::{Catalog, Provider}; +use fabro_types::settings::v2::bridge::bridge_to_old; use fabro_util::check_report::{CheckDetail, CheckResult, CheckSection, CheckStatus}; use fabro_util::version::FABRO_VERSION; use regex::Regex; @@ -294,10 +295,10 @@ async fn check_github_app(state: &AppState) -> CheckResult { .read() .expect("settings lock poisoned") .clone(); - let app_id = settings.app_id().map(str::to_owned); - let slug = settings.slug().map(str::to_owned); + let app_id = settings.github_app_id_str(); + let slug = settings.github_slug_str(); let private_key_raw = state.secret_or_env("GITHUB_APP_PRIVATE_KEY"); - let client_id = settings.client_id().is_some(); + let client_id = settings.github_client_id_str().is_some(); let client_secret = state.secret_or_env("GITHUB_APP_CLIENT_SECRET").is_some(); let webhook_secret = state.secret_or_env("GITHUB_APP_WEBHOOK_SECRET").is_some(); @@ -466,11 +467,13 @@ async fn check_brave_search(state: &AppState) -> CheckResult { } fn check_crypto(state: &AppState) -> CheckResult { - let settings = state + let settings_file = state .settings .read() .expect("settings lock poisoned") .clone(); + // Temporary bridge while diagnostics is migrated to v2 shapes directly. + let settings = bridge_to_old(&settings_file); let api = settings.api.clone().unwrap_or_default(); let has_jwt = api .authentication_strategies diff --git a/lib/crates/fabro-server/src/run_manifest.rs b/lib/crates/fabro-server/src/run_manifest.rs index c04594345..28e341ea8 100644 --- a/lib/crates/fabro-server/src/run_manifest.rs +++ b/lib/crates/fabro-server/src/run_manifest.rs @@ -17,6 +17,7 @@ use fabro_sandbox::daytona::DaytonaConfig; use fabro_sandbox::{DockerSandboxOptions, Sandbox, SandboxProvider, SandboxSpec}; use fabro_types::RunId; use fabro_types::settings::v2::SettingsFile; +use fabro_types::settings::v2::bridge::bridge_sandbox; use fabro_types::settings::v2::cli::{CliLayer, CliOutputLayer, OutputVerbosity}; use fabro_types::settings::v2::interp::InterpString; use fabro_types::settings::v2::run::{ @@ -417,7 +418,7 @@ fn resolve_sandbox_provider(settings: &SettingsFile) -> Result fn resolve_daytona_config(settings: &SettingsFile) -> Option { let sandbox = settings.run_sandbox()?; - fabro_types::settings::v2::bridge::bridge_sandbox(sandbox).daytona + bridge_sandbox(sandbox).daytona } async fn run_sandbox_check( diff --git a/lib/crates/fabro-server/src/serve.rs b/lib/crates/fabro-server/src/serve.rs index 9b092cd29..7c343f246 100644 --- a/lib/crates/fabro-server/src/serve.rs +++ b/lib/crates/fabro-server/src/serve.rs @@ -18,6 +18,8 @@ use tracing::{error, info, warn}; use clap::Args; use fabro_types::Settings; +use fabro_types::settings::v2::SettingsFile; +use fabro_types::settings::v2::bridge::bridge_to_old; use crate::bind::{self, Bind, BindRequest}; use crate::github_webhooks::WebhookManager; @@ -80,42 +82,77 @@ pub struct ServeArgs { pub config: Option, } -fn load_settings(path: Option<&Path>) -> anyhow::Result { - load_settings_config(path)?.try_into() +fn load_settings(path: Option<&Path>) -> anyhow::Result { + Ok(load_settings_config(path)?.into()) +} + +/// Bridged helper for legacy call sites inside serve.rs that still read flat +/// Settings fields. Callers pass a v2 SettingsFile; this returns the legacy +/// shape via the transitional bridge. +fn bridged(settings: &SettingsFile) -> Settings { + bridge_to_old(settings) } fn resolved_config_path(path: Option<&Path>) -> PathBuf { active_settings_path(path) } -fn apply_serve_overrides(base: &Settings, args: &ServeArgs, dry_run_mode: bool) -> Settings { +fn apply_serve_overrides( + base: &SettingsFile, + args: &ServeArgs, + dry_run_mode: bool, +) -> SettingsFile { + use fabro_types::settings::v2::cli::CliLayer; + use fabro_types::settings::v2::interp::InterpString; + use fabro_types::settings::v2::run::{ + RunExecutionLayer, RunLayer, RunMode, RunModelLayer, RunSandboxLayer, + }; + use fabro_types::settings::v2::server::{ServerLayer, ServerWebLayer}; let mut settings = base.clone(); if dry_run_mode { - settings.dry_run = Some(true); + let run = settings.run.get_or_insert_with(RunLayer::default); + let execution = run.execution.get_or_insert_with(RunExecutionLayer::default); + execution.mode = Some(RunMode::DryRun); } if args.web || args.no_web { - settings.web.get_or_insert_default().enabled = args.web; + let server = settings.server.get_or_insert_with(ServerLayer::default); + let web = server.web.get_or_insert_with(ServerWebLayer::default); + web.enabled = Some(args.web); } if let Some(ref model) = args.model { - settings.llm.get_or_insert_default().model = Some(model.clone()); + let run = settings.run.get_or_insert_with(RunLayer::default); + let model_layer = run.model.get_or_insert_with(RunModelLayer::default); + model_layer.name = Some(InterpString::parse(model)); } if let Some(ref provider) = args.provider { - settings.llm.get_or_insert_default().provider = Some(provider.clone()); + let run = settings.run.get_or_insert_with(RunLayer::default); + let model_layer = run.model.get_or_insert_with(RunModelLayer::default); + model_layer.provider = Some(InterpString::parse(provider)); } if let Some(sandbox) = args.sandbox { - settings.sandbox.get_or_insert_default().provider = Some(sandbox.to_string()); + let run = settings.run.get_or_insert_with(RunLayer::default); + let sandbox_layer = run.sandbox.get_or_insert_with(RunSandboxLayer::default); + sandbox_layer.provider = Some(sandbox.to_string()); } + // CliLayer is namespaced; nothing to populate from flag overrides today. + let _ = CliLayer::default(); settings } fn apply_runtime_settings( - base: &Settings, + base: &SettingsFile, args: &ServeArgs, dry_run_mode: bool, data_dir: &Path, -) -> Settings { +) -> SettingsFile { + use fabro_types::settings::v2::interp::InterpString; + use fabro_types::settings::v2::server::{ServerLayer, ServerStorageLayer}; let mut settings = apply_serve_overrides(base, args, dry_run_mode); - settings.storage_dir = Some(data_dir.to_path_buf()); + let server = settings.server.get_or_insert_with(ServerLayer::default); + let storage = server + .storage + .get_or_insert_with(ServerStorageLayer::default); + storage.root = Some(InterpString::parse(&data_dir.to_string_lossy())); settings } @@ -143,10 +180,14 @@ fn build_object_store(store_path: &Path) -> anyhow::Result> } fn build_artifact_object_store( - settings: &Settings, + settings: &SettingsFile, storage: &Storage, ) -> anyhow::Result<(Arc, String)> { - let artifact_settings = settings.artifact_storage.clone().unwrap_or_default(); + let bridged_settings = bridged(settings); + let artifact_settings = bridged_settings + .artifact_storage + .clone() + .unwrap_or_default(); if use_in_memory_store() { return Ok((Arc::new(InMemory::new()), artifact_settings.prefix)); @@ -200,7 +241,8 @@ where let config_path = args.config.clone(); let disk_settings = load_settings(config_path.as_deref())?; let active_config_path = resolved_config_path(config_path.as_deref()); - let data_dir = storage_dir_override.unwrap_or_else(|| resolve_storage_dir(&disk_settings)); + let data_dir = + storage_dir_override.unwrap_or_else(|| resolve_storage_dir(&bridged(&disk_settings))); let storage = Storage::new(&data_dir); let secret_store_path = storage.secrets_path(); let secret_store = SecretStore::load(secret_store_path.clone())?; @@ -241,7 +283,8 @@ where let shared_settings = Arc::new(RwLock::new(effective_settings)); std::fs::create_dir_all(&data_dir)?; let (auth_mode, client_auth, max_concurrent_runs) = { - let cfg = shared_settings.read().expect("config lock poisoned"); + let cfg_file = shared_settings.read().expect("config lock poisoned"); + let cfg = bridged(&cfg_file); let api = cfg.api.clone().unwrap_or_default(); let allowed_usernames = cfg .web @@ -261,12 +304,13 @@ where .unwrap_or(5); (auth_mode, client_auth, max_concurrent_runs) }; - let web_enabled = shared_settings - .read() - .expect("config lock poisoned") - .web - .as_ref() - .is_none_or(|web| web.enabled); + let web_enabled = { + let cfg_file = shared_settings.read().expect("config lock poisoned"); + cfg_file + .server_web() + .and_then(|w| w.enabled) + .unwrap_or(true) + }; let store_path = storage.store_dir(); let object_store = build_object_store(&store_path)?; @@ -308,7 +352,8 @@ where // Optionally start webhook listener let webhook_app_id = { - let cfg = shared_settings.read().expect("config lock poisoned"); + let cfg_file = shared_settings.read().expect("config lock poisoned"); + let cfg = bridged(&cfg_file); cfg.git .as_ref() .and_then(|g| g.webhooks.as_ref().and(g.app_id.as_ref())) @@ -401,12 +446,11 @@ where }); // Branch: TLS, plain TCP, or Unix socket - let tls_settings = shared_settings - .read() - .expect("config lock poisoned") - .api - .as_ref() - .and_then(|a| a.tls.clone()); + let tls_settings = { + let cfg_file = shared_settings.read().expect("config lock poisoned"); + let cfg = bridged(&cfg_file); + cfg.api.as_ref().and_then(|a| a.tls.clone()) + }; let bound_listener = bind_listener(&bind_request).await?; let bind_addr = bound_listener.bind.clone(); @@ -638,11 +682,18 @@ mod tests { build_object_store_with_preference, server_bind_title, server_title, }; use crate::bind::Bind; - use fabro_types::Settings; + use fabro_config::ConfigLayer; + use fabro_types::settings::v2::SettingsFile; + + fn parse_settings(source: &str) -> SettingsFile { + ConfigLayer::parse(source) + .expect("v2 fixture should parse") + .into() + } #[test] fn apply_runtime_settings_preserves_storage_dir() { - let base = Settings::default(); + let base = SettingsFile::default(); let args = ServeArgs { bind: None, model: None, @@ -659,20 +710,21 @@ mod tests { apply_runtime_settings(&base, &args, false, &PathBuf::from("/srv/fabro-storage")); assert_eq!( - resolved.storage_dir, - Some(PathBuf::from("/srv/fabro-storage")) + resolved.server_storage_root_str().as_deref(), + Some("/srv/fabro-storage") ); } #[test] fn apply_runtime_settings_enables_web_from_cli_flag() { - let base: Settings = toml::from_str( + let base = parse_settings( r#" -[web] +_version = 1 + +[server.web] enabled = false "#, - ) - .unwrap(); + ); let args = ServeArgs { bind: None, model: None, @@ -687,12 +739,12 @@ enabled = false let resolved = apply_runtime_settings(&base, &args, false, &PathBuf::from("/srv/fabro")); - assert!(resolved.web.expect("web settings should exist").enabled); + assert_eq!(resolved.server_web().and_then(|w| w.enabled), Some(true)); } #[test] fn apply_runtime_settings_disables_web_from_cli_flag() { - let base = Settings::default(); + let base = SettingsFile::default(); let args = ServeArgs { bind: None, model: None, @@ -707,7 +759,7 @@ enabled = false let resolved = apply_runtime_settings(&base, &args, false, &PathBuf::from("/srv/fabro")); - assert!(!resolved.web.expect("web settings should exist").enabled); + assert_eq!(resolved.server_web().and_then(|w| w.enabled), Some(false)); } #[test] diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index af3b8f0d2..3ab01fccb 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -33,11 +33,11 @@ use fabro_model::{BilledModelUsage, BilledTokenCounts}; use fabro_store::{ ArtifactStore, Database, EventEnvelope, EventPayload, PendingInterviewRecord, StageId, }; -use fabro_types::settings::v2::SettingsFile; +use fabro_types::settings::v2::bridge::bridge_to_old; +use fabro_types::settings::v2::{InterpString, SettingsFile}; use fabro_types::{ EventBody, InterviewQuestionRecord, InterviewQuestionType, RunBlobId, RunClientProvenance, RunControlAction, RunEvent, RunId, RunProvenance, RunServerProvenance, RunSubjectProvenance, - Settings, }; use fabro_util::redact::redact_jsonl_line; use fabro_util::version::FABRO_VERSION; @@ -1075,8 +1075,12 @@ async fn get_server_settings( (StatusCode::OK, Json(response)).into_response() } -fn api_server_settings(settings: &Settings) -> anyhow::Result { - let mut value = serde_json::to_value(settings)?; +fn api_server_settings(settings: &SettingsFile) -> anyhow::Result { + // Temporary shim: reuse the legacy flat Settings shape via the v2 bridge + // so the existing `/api/v1/settings` DTO keeps working. Stage 6.6 replaces + // this with an explicit allow-list DTO built directly from the v2 tree. + let legacy = bridge_to_old(settings); + let mut value = serde_json::to_value(&legacy)?; strip_nulls(&mut value); serde_json::from_value(value).map_err(Into::into) } @@ -1417,10 +1421,10 @@ fn build_prune_plan( }) } -fn system_sandbox_provider(settings: &Settings) -> String { +fn system_sandbox_provider(settings: &SettingsFile) -> String { settings - .sandbox_settings() - .and_then(|sandbox| sandbox.provider.clone()) + .run_sandbox() + .and_then(|sb| sb.provider.clone()) .unwrap_or_else(|| SandboxProvider::default().to_string()) } @@ -1615,15 +1619,12 @@ async fn get_github_repo( .read() .expect("settings lock poisoned") .clone(); - let app_id = match settings.app_id() { - Some(app_id) => app_id.to_string(), - None => { - return ApiError::new( - StatusCode::SERVICE_UNAVAILABLE, - "git.app_id is not configured", - ) - .into_response(); - } + let Some(app_id) = settings.github_app_id_str() else { + return ApiError::new( + StatusCode::SERVICE_UNAVAILABLE, + "server.integrations.github.app_id is not configured", + ) + .into_response(); }; let creds = match state.github_app_credentials(Some(&app_id)).await { @@ -1649,7 +1650,7 @@ async fn get_github_repo( let base_url = fabro_github::github_api_base_url(); let client = reqwest::Client::new(); - let install_url = settings.slug().map_or_else( + let install_url = settings.github_slug_str().map_or_else( || format!("https://github.com/organizations/{owner}/settings/installations"), |slug| format!("https://github.com/apps/{slug}/installations/new"), ); @@ -1932,7 +1933,7 @@ async fn get_run_billing( /// Create an `AppState` with default settings. pub fn create_app_state() -> Arc { - create_app_state_with_options(Settings::default(), 5) + create_app_state_with_options(SettingsFile::default(), 5) } #[doc(hidden)] @@ -1940,14 +1941,14 @@ pub fn create_app_state_with_registry_factory( registry_factory_override: impl Fn(Arc) -> HandlerRegistry + Send + Sync + 'static, ) -> Arc { create_app_state_with_settings_and_registry_factory( - Settings::default(), + SettingsFile::default(), registry_factory_override, ) } #[doc(hidden)] pub fn create_app_state_with_settings_and_registry_factory( - settings: Settings, + settings: SettingsFile, registry_factory_override: impl Fn(Arc) -> HandlerRegistry + Send + Sync + 'static, ) -> Arc { let (store, artifact_store) = test_store_bundle(); @@ -1966,7 +1967,7 @@ pub fn create_app_state_with_settings_and_registry_factory( /// Create an `AppState` with the given settings and concurrency limit. pub fn create_app_state_with_options( - settings: Settings, + settings: SettingsFile, max_concurrent_runs: usize, ) -> Arc { let (store, artifact_store) = test_store_bundle(); @@ -1990,7 +1991,7 @@ fn test_store_bundle() -> (Arc, ArtifactStore) { } pub fn create_app_state_with_store( - settings: Arc>, + settings: Arc>, max_concurrent_runs: usize, store: Arc, artifact_store: ArtifactStore, @@ -2009,7 +2010,7 @@ pub fn create_app_state_with_store( } pub(crate) fn build_app_state_with_path( - settings: Arc>, + settings: Arc>, registry_factory_override: Option>, max_concurrent_runs: usize, store: Arc, @@ -2023,8 +2024,8 @@ pub(crate) fn build_app_state_with_path( let slack_service = { let settings = settings.read().expect("settings lock poisoned"); settings - .slack_settings() - .and_then(|slack| slack.default_channel.clone()) + .server_integrations_slack() + .and_then(|slack| slack.default_channel.as_ref().map(InterpString::as_source)) .and_then(|default_channel| { resolve_slack_credentials().map(|credentials| { Arc::new(SlackService::new( @@ -5863,9 +5864,6 @@ mod tests { use super::*; use axum::body::Body; use axum::http::Request; - use fabro_config::server::{ - AuthProvider, AuthSettings, GitAuthorSettings, GitProvider, GitSettings, WebSettings, - }; use fabro_interview::{AnswerValue, ControlInterviewer, Interviewer, Question, QuestionType}; use fabro_types::{InterviewQuestionRecord, InterviewQuestionType, RunBlobId, RunId, fixtures}; #[cfg(unix)] @@ -5879,10 +5877,17 @@ mod tests { start -> exit }"#; - fn dry_run_settings() -> Settings { - Settings { - dry_run: Some(true), - ..Default::default() + fn dry_run_settings() -> SettingsFile { + use fabro_types::settings::v2::run::{RunExecutionLayer, RunLayer, RunMode}; + SettingsFile { + run: Some(RunLayer { + execution: Some(RunExecutionLayer { + mode: Some(RunMode::DryRun), + ..RunExecutionLayer::default() + }), + ..RunLayer::default() + }), + ..SettingsFile::default() } } @@ -6164,25 +6169,23 @@ mod tests { } #[tokio::test] - #[allow(clippy::field_reassign_with_default)] async fn auth_login_github_redirects_to_github() { - let mut settings = Settings::default(); - settings.web = Some(WebSettings { - enabled: true, - url: "http://localhost:3000".to_string(), - auth: AuthSettings { - provider: AuthProvider::Github, - allowed_usernames: vec!["brynary".to_string()], - }, - }); - settings.git = Some(GitSettings { - provider: GitProvider::Github, - app_id: Some("123".to_string()), - client_id: Some("Iv1.testclient".to_string()), - slug: Some("fabro".to_string()), - author: GitAuthorSettings::default(), - webhooks: None, - }); + let settings: SettingsFile = fabro_config::ConfigLayer::parse( + r#" +_version = 1 + +[server.web] +enabled = true +url = "http://localhost:3000" + +[server.integrations.github] +app_id = "123" +client_id = "Iv1.testclient" +slug = "fabro" +"#, + ) + .expect("fixture should parse") + .into(); let app = build_router( create_app_state_with_options(settings, 5), AuthMode::Disabled, @@ -7308,49 +7311,48 @@ mod tests { #[tokio::test] async fn start_run_persists_full_settings_snapshot() { - let settings = Settings { - dry_run: Some(true), - llm: Some(fabro_config::run::LlmSettings { - model: Some("claude-sonnet-4-5".to_string()), - provider: Some("anthropic".to_string()), - fallbacks: None, - }), - sandbox: Some(fabro_config::sandbox::SandboxSettings { - provider: Some("local".to_string()), - ..Default::default() - }), - hooks: vec![fabro_hooks::HookDefinition { - name: Some("snapshot-hook".to_string()), - event: fabro_hooks::HookEvent::RunStart, - command: Some("echo snapshot".to_string()), - hook_type: None, - matcher: None, - blocking: Some(false), - timeout_ms: Some(1_000), - sandbox: Some(false), - }], - git: Some(fabro_config::server::GitSettings { - app_id: Some("12345".to_string()), - author: fabro_config::server::GitAuthorSettings { - name: Some("Snapshot Bot".to_string()), - email: Some("snapshot@example.com".to_string()), - }, - ..Default::default() - }), - web: Some(fabro_config::server::WebSettings { - url: "http://example.test".to_string(), - ..Default::default() - }), - api: Some(fabro_config::server::ApiSettings { - base_url: "http://api.example.test".to_string(), - ..Default::default() - }), - log: Some(fabro_config::server::LogSettings { - level: Some("debug".to_string()), - }), - ..Default::default() - }; - let state = create_app_state_with_options(settings.clone(), 5); + let settings: SettingsFile = fabro_config::ConfigLayer::parse( + r#" +_version = 1 + +[run.execution] +mode = "dry_run" + +[run.model] +provider = "anthropic" +name = "claude-sonnet-4-5" + +[run.sandbox] +provider = "local" + +[[run.hooks]] +name = "snapshot-hook" +event = "run_start" +command = ["echo", "snapshot"] +blocking = false +timeout = "1s" +sandbox = false + +[run.git.author] +name = "Snapshot Bot" +email = "snapshot@example.com" + +[server.integrations.github] +app_id = "12345" + +[server.web] +url = "http://example.test" + +[server.api] +url = "http://api.example.test" + +[server.logging] +level = "debug" +"#, + ) + .expect("fixture should parse") + .into(); + let state = create_app_state_with_options(settings, 5); let app = build_router(Arc::clone(&state), AuthMode::Disabled); let req = Request::builder() @@ -7381,11 +7383,26 @@ mod tests { .unwrap() .run .expect("run record should exist"); - let mut expected_settings = settings; - expected_settings.goal = Some("Test".to_string()); - expected_settings.dry_run = None; - assert_eq!(run_record.settings, expected_settings); + // Server-side `dry_run` default must not override the manifest's intent. + // Verify a sampling of the persisted v2 settings. + assert_eq!( + run_record.settings.run_goal_str().as_deref(), + Some("Test"), + "goal should be persisted from the manifest" + ); + assert!( + !run_record.settings.dry_run_enabled(), + "server-local dry_run fallback must not override manifest intent" + ); + assert_eq!( + run_record.settings.run_model_name_str().as_deref(), + Some("claude-sonnet-4-5"), + ); + assert_eq!( + run_record.settings.github_app_id_str().as_deref(), + Some("12345"), + ); } #[tokio::test] @@ -7748,13 +7765,19 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn cancel_during_startup_persists_cancelled_reason() { - let settings = Settings { - setup: Some(fabro_config::run::SetupSettings { - commands: vec!["sleep 5".to_string()], - timeout_ms: Some(30_000), - }), - ..Default::default() - }; + let settings: SettingsFile = fabro_config::ConfigLayer::parse( + r#" +_version = 1 + +[[run.prepare.steps]] +script = "sleep 5" + +[run.prepare] +timeout = "30s" +"#, + ) + .expect("fixture should parse") + .into(); let state = create_app_state_with_settings_and_registry_factory(settings, |interviewer| { fabro_workflow::handler::default_registry(interviewer, || None) }); @@ -7857,7 +7880,7 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn concurrency_limit_respected() { - let state = create_app_state_with_options(Settings::default(), 1); + let state = create_app_state_with_options(SettingsFile::default(), 1); let app = test_app_with_scheduler(Arc::clone(&state)); // Create and start two runs with max_concurrent_runs=1 diff --git a/lib/crates/fabro-server/src/web_auth.rs b/lib/crates/fabro-server/src/web_auth.rs index 95ee3bb0f..c503cd9dd 100644 --- a/lib/crates/fabro-server/src/web_auth.rs +++ b/lib/crates/fabro-server/src/web_auth.rs @@ -7,6 +7,8 @@ use axum::response::{IntoResponse, Redirect, Response}; use axum::{Json, Router, routing::get, routing::post}; use cookie::{Cookie, CookieJar, Expiration, Key, SameSite, time::Duration}; use fabro_types::Settings; +use fabro_types::settings::v2::SettingsFile; +use fabro_types::settings::v2::bridge::bridge_to_old; use fabro_types::settings::{ApiAuthStrategy, GitProvider, GitSettings}; use serde::{Deserialize, Serialize}; use serde_json::json; @@ -155,12 +157,22 @@ fn features_json(settings: &Settings) -> serde_json::Value { }) } +/// Temporary helper used during the v2 consumer migration. Bridges a +/// `SettingsFile` down to the legacy flat `Settings` shape so web_auth's +/// oauth/git flows can keep reading flat fields until they're migrated +/// directly (Stage 6.6 alongside the `/api/v1/settings` DTO rewrite). +fn bridged(settings_file: &SettingsFile) -> Settings { + bridge_to_old(settings_file) +} + async fn login_github(State(state): State>) -> Response { - let settings = state - .settings - .read() - .expect("settings lock poisoned") - .clone(); + let settings = bridged( + &state + .settings + .read() + .expect("settings lock poisoned") + .clone(), + ); let Some(client_id) = settings.client_id().map(str::to_string) else { warn!("OAuth login failed: client_id not configured"); return json_response( @@ -216,11 +228,13 @@ async fn callback_github( json!({"error": "SESSION_SECRET is not configured"}), ); }; - let settings = state - .settings - .read() - .expect("settings lock poisoned") - .clone(); + let settings = bridged( + &state + .settings + .read() + .expect("settings lock poisoned") + .clone(), + ); let cookie_jar = parse_cookie_header(&headers); let stored_state = cookie_jar.get(OAUTH_STATE_COOKIE_NAME).map(Cookie::value); if stored_state != Some(params.state.as_str()) { @@ -431,11 +445,13 @@ async fn auth_me(State(state): State>, headers: HeaderMap) -> Resp return json_response(StatusCode::UNAUTHORIZED, json!({"error": "Unauthorized"})); }; - let settings = state - .settings - .read() - .expect("settings lock poisoned") - .clone(); + let settings = bridged( + &state + .settings + .read() + .expect("settings lock poisoned") + .clone(), + ); let demo_mode = parse_cookie_header(&headers) .get("fabro-demo") .is_some_and(|cookie| cookie.value() == "1"); @@ -455,11 +471,13 @@ async fn auth_me(State(state): State>, headers: HeaderMap) -> Resp } async fn setup_status(State(state): State>) -> Response { - let settings = state - .settings - .read() - .expect("settings lock poisoned") - .clone(); + let settings = bridged( + &state + .settings + .read() + .expect("settings lock poisoned") + .clone(), + ); let configured = settings .git .as_ref() @@ -547,11 +565,15 @@ async fn setup_register( let settings_path = state.config_path.clone(); - let mut settings = state + // Bridge the v2 in-memory state down to the legacy flat shape so the + // existing register flow can continue to mutate it and write legacy + // TOML. Stage 6.6 rewrites this to produce v2 TOML directly. + let settings_file = state .settings .read() .expect("settings lock poisoned") .clone(); + let mut settings = bridged(&settings_file); let mut git = settings.git.clone().unwrap_or_default(); git.provider = GitProvider::Github; git.app_id = Some(data.id.to_string()); @@ -622,10 +644,13 @@ async fn setup_register( } } - { - let mut shared = state.settings.write().expect("settings lock poisoned"); - *shared = settings; - } + // Stage 6.6 TODO: re-parse the freshly-written `settings_path` via + // `ConfigLayer::load` and swap it into `state.settings`. For now, leave + // the in-memory state unchanged -- subsequent server restarts will + // re-read the file. The `settings` binding above mutates a bridged + // copy that only feeds the TOML merge output; dropping it here is + // intentional. + drop(settings); info!(slug = %data.slug, app_id = %data.id, "GitHub App registered successfully"); Json(json!({"ok": true})).into_response() diff --git a/lib/crates/fabro-server/tests/it/api/routing.rs b/lib/crates/fabro-server/tests/it/api/routing.rs index 6131ffaca..8d99c726c 100644 --- a/lib/crates/fabro-server/tests/it/api/routing.rs +++ b/lib/crates/fabro-server/tests/it/api/routing.rs @@ -1,11 +1,12 @@ use axum::body::{Body, to_bytes}; use axum::http::{Method, Request, StatusCode}; +use fabro_config::ConfigLayer; use fabro_server::jwt_auth::AuthMode; use fabro_server::server::{ RouterOptions, build_router, build_router_with_options, create_app_state, create_app_state_with_options, }; -use fabro_types::Settings; +use fabro_types::settings::v2::SettingsFile; use tower::ServiceExt; use crate::helpers::body_json; @@ -120,13 +121,16 @@ async fn web_enabled_serves_web_only_routes() { #[tokio::test] async fn web_disabled_returns_404_for_web_routes_and_keeps_machine_api() { - let settings: Settings = toml::from_str( + let settings: SettingsFile = ConfigLayer::parse( r#" -[web] +_version = 1 + +[server.web] enabled = false "#, ) - .expect("settings fixture should parse"); + .expect("settings fixture should parse") + .into(); let app = build_router_with_options( create_app_state_with_options(settings, 5), AuthMode::Disabled, @@ -175,13 +179,16 @@ enabled = false #[tokio::test] async fn web_disabled_ignores_demo_header_dispatch() { - let settings: Settings = toml::from_str( + let settings: SettingsFile = ConfigLayer::parse( r#" -[web] +_version = 1 + +[server.web] enabled = false "#, ) - .expect("settings fixture should parse"); + .expect("settings fixture should parse") + .into(); let app = build_router_with_options( create_app_state_with_options(settings, 5), AuthMode::Disabled, diff --git a/lib/crates/fabro-server/tests/it/api/settings.rs b/lib/crates/fabro-server/tests/it/api/settings.rs index a204a2908..bad6937c4 100644 --- a/lib/crates/fabro-server/tests/it/api/settings.rs +++ b/lib/crates/fabro-server/tests/it/api/settings.rs @@ -1,25 +1,34 @@ use axum::body::Body; use axum::http::{Request, StatusCode}; +use fabro_config::ConfigLayer; use fabro_server::jwt_auth::AuthMode; use fabro_server::server::{build_router, create_app_state_with_options}; -use fabro_types::Settings; +use fabro_types::settings::v2::SettingsFile; use tower::ServiceExt; use crate::helpers::body_json; #[tokio::test] async fn retrieve_server_settings_returns_runtime_settings() { - let settings: Settings = toml::from_str( + let settings: SettingsFile = ConfigLayer::parse( r#" -storage_dir = "/srv/fabro" -max_concurrent_runs = 9 -verbose = true +_version = 1 -[vars] +[server.storage] +root = "/srv/fabro" + +[server.scheduler] +max_concurrent_runs = 9 + +[cli.output] +verbosity = "verbose" + +[run.inputs] server_only = "1" "#, ) - .expect("settings fixture should parse"); + .expect("settings fixture should parse") + .into(); let app = build_router( create_app_state_with_options(settings, 5), AuthMode::Disabled, diff --git a/lib/crates/fabro-server/tests/it/api/system.rs b/lib/crates/fabro-server/tests/it/api/system.rs index f63530a60..afe69ce6f 100644 --- a/lib/crates/fabro-server/tests/it/api/system.rs +++ b/lib/crates/fabro-server/tests/it/api/system.rs @@ -3,8 +3,13 @@ use std::time::Duration; use axum::body::Body; use axum::http::{Request, StatusCode}; use fabro_config::Storage; -use fabro_types::{RunId, Settings}; +use fabro_types::RunId; +use fabro_types::settings::v2::SettingsFile; +use fabro_types::settings::v2::interp::InterpString; +use fabro_types::settings::v2::run::{RunExecutionLayer, RunLayer, RunMode}; +use fabro_types::settings::v2::server::{ServerLayer, ServerStorageLayer}; use http_body_util::BodyExt; +use std::path::PathBuf; use tempfile::tempdir; use tokio::time::timeout; use tower::ServiceExt; @@ -14,12 +19,18 @@ use crate::helpers::{ test_app_with_scheduler, test_settings, wait_for_run_status, }; -fn temp_storage_settings() -> (tempfile::TempDir, Settings) { +fn temp_storage_settings() -> (tempfile::TempDir, SettingsFile, PathBuf) { let temp = tempdir().expect("tempdir should create"); let mut settings = test_settings(); - settings.dry_run = Some(true); - settings.storage_dir = Some(temp.path().join("storage")); - (temp, settings) + let storage_dir = temp.path().join("storage"); + let run = settings.run.get_or_insert_with(RunLayer::default); + let execution = run.execution.get_or_insert_with(RunExecutionLayer::default); + execution.mode = Some(RunMode::DryRun); + let server = settings.server.get_or_insert_with(ServerLayer::default); + server.storage = Some(ServerStorageLayer { + root: Some(InterpString::parse(&storage_dir.to_string_lossy())), + }); + (temp, settings, storage_dir) } async fn create_run(app: &axum::Router, manifest: serde_json::Value) -> String { @@ -46,8 +57,7 @@ async fn start_run(app: &axum::Router, run_id: &str) { #[tokio::test] async fn get_system_info_returns_runtime_fields() { - let (_temp, settings) = temp_storage_settings(); - let expected_storage_dir = settings.storage_dir.clone().unwrap(); + let (_temp, settings, expected_storage_dir) = temp_storage_settings(); let app = fabro_server::server::build_router( test_app_state_with_options(settings, 5), fabro_server::jwt_auth::AuthMode::Disabled, @@ -75,8 +85,7 @@ async fn get_system_info_returns_runtime_fields() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn get_system_disk_usage_returns_summary_and_verbose_rows() { - let (_temp, settings) = temp_storage_settings(); - let storage_dir = settings.storage_dir.clone().unwrap(); + let (_temp, settings, storage_dir) = temp_storage_settings(); let app = test_app_with_scheduler(test_app_state_with_options(settings, 5)); let run_id = create_run(&app, minimal_manifest_json_with_dry_run(MINIMAL_DOT)).await; @@ -108,8 +117,7 @@ async fn get_system_disk_usage_returns_summary_and_verbose_rows() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn prune_runs_supports_dry_run_and_deletion() { - let (_temp, settings) = temp_storage_settings(); - let storage_dir = settings.storage_dir.clone().unwrap(); + let (_temp, settings, storage_dir) = temp_storage_settings(); let app = test_app_with_scheduler(test_app_state_with_options(settings, 5)); let run_id = create_run(&app, minimal_manifest_json_with_dry_run(MINIMAL_DOT)).await; @@ -154,7 +162,7 @@ async fn prune_runs_supports_dry_run_and_deletion() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn attach_events_streams_only_matching_run_ids() { - let (_temp, settings) = temp_storage_settings(); + let (_temp, settings, _storage_dir) = temp_storage_settings(); let app = test_app_with_scheduler(test_app_state_with_options(settings, 5)); let run_one = create_run(&app, minimal_manifest_json_with_dry_run(MINIMAL_DOT)).await; diff --git a/lib/crates/fabro-server/tests/it/helpers.rs b/lib/crates/fabro-server/tests/it/helpers.rs index 53105fb17..6230d10ce 100644 --- a/lib/crates/fabro-server/tests/it/helpers.rs +++ b/lib/crates/fabro-server/tests/it/helpers.rs @@ -8,8 +8,10 @@ use fabro_server::server::{ AppState, build_router, create_app_state, create_app_state_with_settings_and_registry_factory, spawn_scheduler, }; -use fabro_types::Settings; -use fabro_types::settings::{LocalSandboxSettings, SandboxSettings, WorktreeMode}; +use fabro_types::settings::v2::SettingsFile; +use fabro_types::settings::v2::run::{ + LocalSandboxLayer, RunExecutionLayer, RunLayer, RunMode, RunSandboxLayer, WorktreeMode, +}; use tokio::time::sleep; use tower::ServiceExt; @@ -28,7 +30,7 @@ pub(crate) fn test_app_state() -> Arc { } pub(crate) fn test_app_state_with_options( - settings: Settings, + settings: SettingsFile, max_concurrent_runs: usize, ) -> Arc { let _ = max_concurrent_runs; @@ -37,23 +39,27 @@ pub(crate) fn test_app_state_with_options( }) } -pub(crate) fn test_settings() -> Settings { - Settings { - sandbox: Some(SandboxSettings { - local: Some(LocalSandboxSettings { - worktree_mode: WorktreeMode::Never, +pub(crate) fn test_settings() -> SettingsFile { + SettingsFile { + run: Some(RunLayer { + sandbox: Some(RunSandboxLayer { + local: Some(LocalSandboxLayer { + worktree_mode: Some(WorktreeMode::Never), + }), + ..RunSandboxLayer::default() }), - ..Default::default() + ..RunLayer::default() }), - ..Default::default() + ..SettingsFile::default() } } -pub(crate) fn dry_run_settings() -> Settings { - Settings { - dry_run: Some(true), - ..test_settings() - } +pub(crate) fn dry_run_settings() -> SettingsFile { + let mut settings = test_settings(); + let run = settings.run.get_or_insert_with(RunLayer::default); + let execution = run.execution.get_or_insert_with(RunExecutionLayer::default); + execution.mode = Some(RunMode::DryRun); + settings } pub(crate) fn dry_run_app() -> axum::Router { diff --git a/lib/crates/fabro-store/src/slate/mod.rs b/lib/crates/fabro-store/src/slate/mod.rs index 93a838ef7..4711e4a78 100644 --- a/lib/crates/fabro-store/src/slate/mod.rs +++ b/lib/crates/fabro-store/src/slate/mod.rs @@ -233,9 +233,8 @@ mod tests { use super::*; use chrono::{DateTime, Utc}; - use fabro_types::{ - AttrValue, Graph, RunControlAction, RunRecord, RunStatus, Settings, StatusReason, - }; + use fabro_types::settings::v2::SettingsFile; + use fabro_types::{AttrValue, Graph, RunControlAction, RunRecord, RunStatus, StatusReason}; use futures::TryStreamExt; use object_store::memory::InMemory; use object_store::path::Path; @@ -280,7 +279,7 @@ mod tests { ); RunRecord { run_id: test_run_id(label), - settings: Settings::default(), + settings: SettingsFile::default(), graph, workflow_slug: Some("night-sky".to_string()), working_directory: PathBuf::from(format!("/tmp/{label}")), diff --git a/lib/crates/fabro-workflow/src/operations/create.rs b/lib/crates/fabro-workflow/src/operations/create.rs index 7c276fdb1..c417c6631 100644 --- a/lib/crates/fabro-workflow/src/operations/create.rs +++ b/lib/crates/fabro-workflow/src/operations/create.rs @@ -463,7 +463,7 @@ mod tests { )) } - fn validate_dot(dot_source: &str, settings: Settings) -> Validated { + fn validate_dot(dot_source: &str, settings: SettingsFile) -> Validated { validate(ValidateInput { workflow: WorkflowInput::DotSource { source: dot_source.to_string(), @@ -485,7 +485,7 @@ mod tests { #[test] fn validate_minimal() { - let validated = validate_dot(MINIMAL_DOT, Settings::default()); + let validated = validate_dot(MINIMAL_DOT, SettingsFile::default()); validated.raise_on_errors().unwrap(); assert_eq!(validated.graph().name, "Test"); @@ -502,7 +502,7 @@ mod tests { exit [shape=Msquare] start -> work -> exit }"#; - let validated = validate_dot(dot, Settings::default()); + let validated = validate_dot(dot, SettingsFile::default()); validated.raise_on_errors().unwrap(); let prompt = validated.graph().nodes["work"] @@ -540,7 +540,7 @@ mod tests { exit [shape=Msquare] start -> work -> exit }"#; - let validated = validate_dot(dot, Settings::default()); + let validated = validate_dot(dot, SettingsFile::default()); validated.raise_on_errors().unwrap(); assert_eq!( @@ -558,14 +558,19 @@ mod tests { exit [shape=Msquare] start -> work -> exit }"#; - let validated = validate_dot( - dot, - Settings { - vars: Some(HashMap::from([("who".to_string(), "agent".to_string())])), - goal: Some("override".to_string()), - ..Default::default() - }, - ); + let validated = validate_dot(dot, { + use fabro_types::settings::v2::run::RunLayer; + let mut inputs = std::collections::HashMap::new(); + inputs.insert("who".to_string(), toml::Value::String("agent".to_string())); + SettingsFile { + run: Some(RunLayer { + goal: Some(InterpString::parse("override")), + inputs: Some(inputs), + ..RunLayer::default() + }), + ..SettingsFile::default() + } + }); validated.raise_on_errors().unwrap(); assert_eq!(validated.graph().goal(), "override"); @@ -584,7 +589,7 @@ mod tests { source: "not a graph".to_string(), base_dir: None, }, - settings: Settings::default(), + settings: SettingsFile::default(), cwd: PathBuf::from("."), custom_transforms: Vec::new(), }); @@ -597,7 +602,7 @@ mod tests { graph [goal="Test"] work [label="Work"] }"#; - let validated = validate_dot(dot, Settings::default()); + let validated = validate_dot(dot, SettingsFile::default()); assert!(validated.has_errors()); assert!(validated.raise_on_errors().is_err()); @@ -624,7 +629,7 @@ mod tests { source: MINIMAL_DOT.to_string(), base_dir: None, }, - settings: Settings::default(), + settings: SettingsFile::default(), cwd: PathBuf::from("."), custom_transforms: vec![Box::new(TagTransform)], }) @@ -657,7 +662,7 @@ mod tests { let validated = validate(ValidateInput { workflow: WorkflowInput::Path(dot_path), - settings: Settings::default(), + settings: SettingsFile::default(), cwd: dir.path().to_path_buf(), custom_transforms: Vec::new(), }) @@ -693,7 +698,7 @@ mod tests { (PathBuf::from("prompts/lint.md"), "Lint $goal".to_string()), ]), }), - settings: Settings::default(), + settings: SettingsFile::default(), cwd: PathBuf::from("."), custom_transforms: Vec::new(), }) @@ -724,7 +729,7 @@ mod tests { source: dot.to_string(), base_dir: None, }, - settings: Settings::default(), + settings: SettingsFile::default(), cwd: dir.path().to_path_buf(), workflow_slug: None, workflow_path: None, @@ -759,20 +764,32 @@ mod tests { source: MINIMAL_DOT.to_string(), base_dir: None, }, - settings: Settings { - llm: Some(fabro_config::run::LlmSettings { - model: Some("sonnet".to_string()), - provider: None, - fallbacks: None, - }), - pull_request: Some(fabro_config::run::PullRequestSettings { - enabled: false, - ..Default::default() - }), - goal: Some("override goal".to_string()), - dry_run: Some(true), - labels: HashMap::from([("env".to_string(), "test".to_string())]), - ..Default::default() + settings: { + use fabro_types::settings::v2::run::{ + RunExecutionLayer, RunLayer, RunMode, RunModelLayer, RunPullRequestLayer, + }; + let mut metadata = HashMap::new(); + metadata.insert("env".to_string(), "test".to_string()); + SettingsFile { + run: Some(RunLayer { + goal: Some(InterpString::parse("override goal")), + metadata, + model: Some(RunModelLayer { + name: Some(InterpString::parse("sonnet")), + ..RunModelLayer::default() + }), + pull_request: Some(RunPullRequestLayer { + enabled: Some(false), + ..RunPullRequestLayer::default() + }), + execution: Some(RunExecutionLayer { + mode: Some(RunMode::DryRun), + ..RunExecutionLayer::default() + }), + ..RunLayer::default() + }), + ..SettingsFile::default() + } }, cwd: dir.path().to_path_buf(), workflow_slug: Some("slug".to_string()), @@ -796,9 +813,8 @@ mod tests { .persisted .run_record() .settings - .llm - .as_ref() - .and_then(|llm| llm.model.as_deref()), + .run_model_name_str() + .as_deref(), Some("claude-sonnet-4-6") ); assert_eq!( @@ -806,13 +822,17 @@ mod tests { .persisted .run_record() .settings - .llm - .as_ref() - .and_then(|llm| llm.provider.as_deref()), + .run_model_provider_str() + .as_deref(), Some("anthropic") ); assert_eq!( - created.persisted.run_record().settings.goal.as_deref(), + created + .persisted + .run_record() + .settings + .run_goal_str() + .as_deref(), Some("override goal") ); assert!( @@ -820,7 +840,7 @@ mod tests { .persisted .run_record() .settings - .pull_request + .run_pull_request() .is_none() ); assert_eq!( @@ -850,10 +870,19 @@ mod tests { source: MINIMAL_DOT.to_string(), base_dir: None, }, - settings: Settings { - work_dir: Some("workspace".to_string()), - dry_run: Some(true), - ..Default::default() + settings: { + use fabro_types::settings::v2::run::{RunExecutionLayer, RunLayer, RunMode}; + SettingsFile { + run: Some(RunLayer { + working_dir: Some(InterpString::parse("workspace")), + execution: Some(RunExecutionLayer { + mode: Some(RunMode::DryRun), + ..RunExecutionLayer::default() + }), + ..RunLayer::default() + }), + ..SettingsFile::default() + } }, cwd: dir.path().to_path_buf(), workflow_slug: None, @@ -895,10 +924,7 @@ mod tests { source: MINIMAL_DOT.to_string(), base_dir: None, }, - settings: Settings { - dry_run: Some(true), - ..Default::default() - }, + settings: dry_run_only_settings(), cwd: dir.path().to_path_buf(), workflow_slug: None, workflow_path: None, @@ -920,6 +946,41 @@ mod tests { ); } + fn dry_run_only_settings() -> SettingsFile { + use fabro_types::settings::v2::run::{RunExecutionLayer, RunLayer, RunMode}; + SettingsFile { + run: Some(RunLayer { + execution: Some(RunExecutionLayer { + mode: Some(RunMode::DryRun), + ..RunExecutionLayer::default() + }), + ..RunLayer::default() + }), + ..SettingsFile::default() + } + } + + fn dry_run_with_storage(storage_dir: &Path) -> SettingsFile { + use fabro_types::settings::v2::run::{RunExecutionLayer, RunLayer, RunMode}; + use fabro_types::settings::v2::server::{ServerLayer, ServerStorageLayer}; + SettingsFile { + run: Some(RunLayer { + execution: Some(RunExecutionLayer { + mode: Some(RunMode::DryRun), + ..RunExecutionLayer::default() + }), + ..RunLayer::default() + }), + server: Some(ServerLayer { + storage: Some(ServerStorageLayer { + root: Some(InterpString::parse(&storage_dir.to_string_lossy())), + }), + ..ServerLayer::default() + }), + ..SettingsFile::default() + } + } + #[tokio::test] async fn create_hydrates_run_created_event_into_store() { let dir = tempfile::tempdir().unwrap(); @@ -935,11 +996,7 @@ mod tests { source: MINIMAL_DOT.to_string(), base_dir: None, }, - settings: Settings { - storage_dir: Some(storage_dir.clone()), - dry_run: Some(true), - ..Default::default() - }, + settings: dry_run_with_storage(&storage_dir), cwd: dir.path().to_path_buf(), workflow_slug: Some("slug".to_string()), workflow_path: None, @@ -978,11 +1035,7 @@ mod tests { source: MINIMAL_DOT.to_string(), base_dir: None, }, - settings: Settings { - storage_dir: Some(storage_dir.clone()), - dry_run: Some(true), - ..Default::default() - }, + settings: dry_run_with_storage(&storage_dir), cwd: dir.path().to_path_buf(), workflow_slug: Some("slug".to_string()), workflow_path: None, diff --git a/lib/crates/fabro-workflow/src/operations/rebuild_meta.rs b/lib/crates/fabro-workflow/src/operations/rebuild_meta.rs index 1f8e7a1be..029ab2a49 100644 --- a/lib/crates/fabro-workflow/src/operations/rebuild_meta.rs +++ b/lib/crates/fabro-workflow/src/operations/rebuild_meta.rs @@ -335,7 +335,8 @@ mod tests { use chrono::{TimeZone, Utc}; use fabro_graphviz::graph::Graph; use fabro_store::{Database, StageId}; - use fabro_types::{RunId, RunRecord, SandboxRecord, Settings, StartRecord, fixtures}; + use fabro_types::settings::v2::SettingsFile; + use fabro_types::{RunId, RunRecord, SandboxRecord, StartRecord, fixtures}; use object_store::memory::InMemory; use std::collections::HashMap; use std::path::PathBuf; @@ -370,7 +371,7 @@ mod tests { fn sample_run_record(run_id: RunId, host_repo_path: Option<&str>) -> RunRecord { RunRecord { run_id, - settings: Settings::default(), + settings: SettingsFile::default(), graph: Graph::new("test"), workflow_slug: None, working_directory: PathBuf::from("/tmp/project"), diff --git a/lib/crates/fabro-workflow/src/operations/start.rs b/lib/crates/fabro-workflow/src/operations/start.rs index 8da74cdcc..fdb2d56b7 100644 --- a/lib/crates/fabro-workflow/src/operations/start.rs +++ b/lib/crates/fabro-workflow/src/operations/start.rs @@ -10,7 +10,9 @@ use fabro_interview::{AutoApproveInterviewer, Interviewer}; use fabro_model::{Catalog, FallbackTarget, Provider}; use fabro_sandbox::{SandboxProvider, SandboxSpec}; use fabro_types::RunId; -use fabro_types::settings::v2::bridge::{bridge_mcp_entry, bridge_sandbox, bridge_worktree_mode}; +use fabro_types::settings::v2::bridge::{ + bridge_hook, bridge_mcp_entry, bridge_pull_request, bridge_sandbox, bridge_worktree_mode, +}; use fabro_types::settings::v2::run::ModelRefOrSplice; use fabro_types::settings::v2::{InterpString, SettingsFile}; @@ -380,9 +382,7 @@ impl RunSession { services.interviewer }; - let pr_config = settings - .run_pull_request() - .map(fabro_types::settings::v2::bridge::bridge_pull_request); + let pr_config = settings.run_pull_request().map(bridge_pull_request); Ok(Self { cancel_token: services.cancel_token, @@ -405,11 +405,7 @@ impl RunSession { devcontainer_phases: Vec::new(), }, hooks: fabro_hooks::HookSettings { - hooks: settings - .run_hooks() - .iter() - .map(fabro_types::settings::v2::bridge::bridge_hook) - .collect(), + hooks: settings.run_hooks().iter().map(bridge_hook).collect(), }, sandbox_env, devcontainer, @@ -590,7 +586,7 @@ impl RunSession { checkpoint, seed_context: self.seed_context, }; - let mut initialized = pipeline::initialize(persisted, init_options).await?; + let mut initialized = Box::pin(pipeline::initialize(persisted, init_options)).await?; initialized.on_node = on_node; let sandbox_for_cleanup = Arc::clone(&initialized.sandbox); @@ -652,8 +648,8 @@ impl RunSession { }; let retro = retroed.retro.clone(); - let concluded = pipeline::finalize(retroed, &finalize_opts).await?; - let finalized = pipeline::pull_request(concluded, &pr_opts).await; + let concluded = Box::pin(pipeline::finalize(retroed, &finalize_opts)).await?; + let finalized = Box::pin(pipeline::pull_request(concluded, &pr_opts)).await; store_progress_logger.flush().await; scopeguard::ScopeGuard::into_inner(cleanup_guard); @@ -853,7 +849,8 @@ mod tests { use chrono::Utc; use fabro_store::Database; - use fabro_types::{Settings, fixtures}; + use fabro_types::fixtures; + use fabro_types::settings::v2::run::{RunExecutionLayer, RunLayer, RunMode}; use object_store::memory::InMemory; use super::*; @@ -891,9 +888,15 @@ mod tests { source: dot.to_string(), base_dir: None, }, - settings: Settings { - dry_run: Some(true), - ..Default::default() + settings: SettingsFile { + run: Some(RunLayer { + execution: Some(RunExecutionLayer { + mode: Some(RunMode::DryRun), + ..RunExecutionLayer::default() + }), + ..RunLayer::default() + }), + ..SettingsFile::default() }, cwd: run_dir .parent() @@ -1071,9 +1074,15 @@ mod tests { .unwrap() .clone(), ), - settings: Settings { - dry_run: Some(true), - ..Default::default() + settings: SettingsFile { + run: Some(RunLayer { + execution: Some(RunExecutionLayer { + mode: Some(RunMode::DryRun), + ..RunExecutionLayer::default() + }), + ..RunLayer::default() + }), + ..SettingsFile::default() }, cwd: temp.path().to_path_buf(), workflow_slug: Some("bundle-child".to_string()), diff --git a/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs b/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs index b88ef269a..547502d50 100644 --- a/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs +++ b/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs @@ -13,7 +13,8 @@ use fabro_hooks::HookSettings; use fabro_interview::AutoApproveInterviewer; use fabro_sandbox::SandboxSpec; use fabro_store::Database; -use fabro_types::{RunId, Settings, fixtures}; +use fabro_types::settings::v2::SettingsFile; +use fabro_types::{RunId, fixtures}; use object_store::memory::InMemory; use super::*; @@ -90,7 +91,7 @@ fn test_run_options(run_dir: &Path, run_id: &str) -> RunOptions { run_dir: run_dir.to_path_buf(), cancel_token: None, run_id: test_run_id(run_id), - settings: Settings::default(), + settings: SettingsFile::default(), git: None, host_repo_path: None, labels: HashMap::new(), @@ -132,7 +133,7 @@ fn persisted_workflow(graph: Graph, source: String, run_dir: &Path, run_id: RunI run_dir.to_path_buf(), RunRecord { run_id, - settings: Settings::default(), + settings: SettingsFile::default(), graph, workflow_slug: Some("test".to_string()), working_directory: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), diff --git a/lib/crates/fabro-workflow/src/pipeline/finalize.rs b/lib/crates/fabro-workflow/src/pipeline/finalize.rs index daa499344..6c75b63ee 100644 --- a/lib/crates/fabro-workflow/src/pipeline/finalize.rs +++ b/lib/crates/fabro-workflow/src/pipeline/finalize.rs @@ -306,7 +306,8 @@ mod tests { use fabro_graphviz::graph::Graph; use fabro_store::Database; - use fabro_types::{RunId, Settings, fixtures}; + use fabro_types::settings::v2::SettingsFile; + use fabro_types::{RunId, fixtures}; use object_store::memory::InMemory; use super::*; @@ -320,7 +321,7 @@ mod tests { fn test_run_options(run_dir: &std::path::Path) -> RunOptions { RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: run_dir.to_path_buf(), cancel_token: None, run_id: test_run_id(), diff --git a/lib/crates/fabro-workflow/src/pipeline/initialize.rs b/lib/crates/fabro-workflow/src/pipeline/initialize.rs index 6bbfc1782..a39738310 100644 --- a/lib/crates/fabro-workflow/src/pipeline/initialize.rs +++ b/lib/crates/fabro-workflow/src/pipeline/initialize.rs @@ -688,7 +688,8 @@ mod tests { use fabro_interview::AutoApproveInterviewer; use fabro_sandbox::SandboxSpec; use fabro_store::Database; - use fabro_types::{RunId, Settings, fixtures}; + use fabro_types::settings::v2::SettingsFile; + use fabro_types::{RunId, fixtures}; use object_store::memory::InMemory; use super::*; @@ -735,7 +736,7 @@ mod tests { fn test_settings(run_dir: &std::path::Path) -> RunOptions { RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: run_dir.to_path_buf(), cancel_token: None, run_id: test_run_id(), @@ -757,7 +758,7 @@ mod tests { run_dir.to_path_buf(), RunRecord { run_id: test_run_id(), - settings: Settings::default(), + settings: SettingsFile::default(), graph, workflow_slug: Some("test".to_string()), working_directory: std::env::current_dir().unwrap(), diff --git a/lib/crates/fabro-workflow/src/pipeline/persist.rs b/lib/crates/fabro-workflow/src/pipeline/persist.rs index 2b1cb5fc2..8ed19a9ea 100644 --- a/lib/crates/fabro-workflow/src/pipeline/persist.rs +++ b/lib/crates/fabro-workflow/src/pipeline/persist.rs @@ -54,7 +54,10 @@ mod tests { use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node}; use fabro_store::{Database, RunDatabase}; - use fabro_types::{Settings, fixtures}; + use fabro_types::fixtures; + use fabro_types::settings::v2::SettingsFile; + use fabro_types::settings::v2::cli::{CliLayer, CliOutputLayer, OutputVerbosity}; + use fabro_types::settings::v2::run::{RunExecutionLayer, RunLayer, RunMode}; use object_store::memory::InMemory; use std::sync::Arc; use std::time::Duration; @@ -118,10 +121,22 @@ mod tests { fn sample_record(graph: Graph) -> RunRecord { RunRecord { run_id: fixtures::RUN_1, - settings: Settings { - dry_run: Some(true), - verbose: Some(true), - ..Default::default() + settings: SettingsFile { + run: Some(RunLayer { + execution: Some(RunExecutionLayer { + mode: Some(RunMode::DryRun), + ..RunExecutionLayer::default() + }), + ..RunLayer::default() + }), + cli: Some(CliLayer { + output: Some(CliOutputLayer { + verbosity: Some(OutputVerbosity::Verbose), + ..CliOutputLayer::default() + }), + ..CliLayer::default() + }), + ..SettingsFile::default() }, graph, workflow_slug: Some("ship".to_string()), diff --git a/lib/crates/fabro-workflow/src/pipeline/pull_request.rs b/lib/crates/fabro-workflow/src/pipeline/pull_request.rs index 914a9daf5..e02cdf255 100644 --- a/lib/crates/fabro-workflow/src/pipeline/pull_request.rs +++ b/lib/crates/fabro-workflow/src/pipeline/pull_request.rs @@ -595,7 +595,8 @@ mod tests { AggregateStats, FrictionKind, FrictionPoint, OpenItem, OpenItemKind, StageRetro, }; use fabro_store::Database; - use fabro_types::{BilledTokenCounts, RunRecord, Settings, fixtures}; + use fabro_types::settings::v2::SettingsFile; + use fabro_types::{BilledTokenCounts, RunRecord, fixtures}; use futures::stream; use object_store::memory::InMemory; use std::time::Duration; @@ -1082,7 +1083,7 @@ mod tests { let run_record = RunRecord { run_id: fixtures::RUN_1, - settings: Settings::default(), + settings: SettingsFile::default(), graph: Graph::new("test"), workflow_slug: Some("test".to_string()), working_directory: PathBuf::from("/tmp/project"), @@ -1155,7 +1156,7 @@ mod tests { let run_record = RunRecord { run_id: fixtures::RUN_1, - settings: Settings::default(), + settings: SettingsFile::default(), graph: Graph::new("test"), workflow_slug: Some("test".to_string()), working_directory: PathBuf::from("/tmp/project"), @@ -1381,7 +1382,7 @@ mod tests { let run_store = store.create_run(&fixtures::RUN_1).await.unwrap(); let run_record = RunRecord { run_id: fixtures::RUN_1, - settings: Settings::default(), + settings: SettingsFile::default(), graph: Graph::new("test"), workflow_slug: None, working_directory: tmp.path().to_path_buf(), diff --git a/lib/crates/fabro-workflow/src/pipeline/retro.rs b/lib/crates/fabro-workflow/src/pipeline/retro.rs index 1511c0ced..ab71a75c0 100644 --- a/lib/crates/fabro-workflow/src/pipeline/retro.rs +++ b/lib/crates/fabro-workflow/src/pipeline/retro.rs @@ -184,7 +184,8 @@ mod tests { use fabro_graphviz::graph::Graph; use fabro_store::Database; - use fabro_types::{RunId, Settings, fixtures}; + use fabro_types::settings::v2::SettingsFile; + use fabro_types::{RunId, fixtures}; use object_store::memory::InMemory; use super::*; @@ -233,7 +234,7 @@ mod tests { let run_store = inner; let run_record = RunRecord { run_id: test_run_id(), - settings: Settings::default(), + settings: SettingsFile::default(), graph: Graph::new("test"), workflow_slug: None, working_directory: run_dir.to_path_buf(), @@ -304,7 +305,7 @@ mod tests { fn test_run_options(run_dir: &std::path::Path) -> RunOptions { RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: run_dir.to_path_buf(), cancel_token: None, run_id: test_run_id(), diff --git a/lib/crates/fabro-workflow/src/run_lookup.rs b/lib/crates/fabro-workflow/src/run_lookup.rs index d0277a728..10e0746cd 100644 --- a/lib/crates/fabro-workflow/src/run_lookup.rs +++ b/lib/crates/fabro-workflow/src/run_lookup.rs @@ -5,7 +5,8 @@ use anyhow::{Context, Result, bail}; use chrono::{DateTime, Utc}; use fabro_config::Storage; use fabro_store::{Database, RunSummary}; -use fabro_types::{RunId, Settings}; +use fabro_types::RunId; +use fabro_types::settings::v2::SettingsFile; use serde::Serialize; use crate::operations::make_run_dir; @@ -141,7 +142,7 @@ pub fn scratch_base(storage_dir: &Path) -> PathBuf { } pub fn default_scratch_base() -> PathBuf { - scratch_base(&Settings::default().storage_dir()) + scratch_base(&SettingsFile::default().storage_dir()) } fn scan_orphan_runs(base: &Path) -> Result> { @@ -396,7 +397,8 @@ mod tests { use fabro_graphviz::graph::Graph; use fabro_store::Database; - use fabro_types::{RunStatus, Settings, fixtures}; + use fabro_types::settings::v2::SettingsFile; + use fabro_types::{RunStatus, fixtures}; use object_store::memory::InMemory; use super::scan_runs_combined; @@ -415,7 +417,7 @@ mod tests { fn sample_run_record() -> RunRecord { RunRecord { run_id: fixtures::RUN_1, - settings: Settings::default(), + settings: SettingsFile::default(), graph: Graph::new("test"), workflow_slug: Some("test".to_string()), working_directory: PathBuf::from("/tmp/project"), diff --git a/lib/crates/fabro-workflow/src/runtime_store.rs b/lib/crates/fabro-workflow/src/runtime_store.rs index d4ff85107..860e33340 100644 --- a/lib/crates/fabro-workflow/src/runtime_store.rs +++ b/lib/crates/fabro-workflow/src/runtime_store.rs @@ -113,7 +113,8 @@ mod tests { use fabro_store::Database; use fabro_types::fixtures; use fabro_types::run_event::RunSubmittedProps; - use fabro_types::{EventBody, RunEvent, Settings}; + use fabro_types::settings::v2::SettingsFile; + use fabro_types::{EventBody, RunEvent}; use object_store::memory::InMemory; use super::RunStoreHandle; @@ -132,7 +133,7 @@ mod tests { fn test_run_record() -> RunRecord { RunRecord { run_id: fixtures::RUN_1, - settings: Settings::default(), + settings: SettingsFile::default(), graph: Graph::new("test"), workflow_slug: Some("test".to_string()), working_directory: PathBuf::from("/tmp/test"), diff --git a/lib/crates/fabro-workflow/src/test_support.rs b/lib/crates/fabro-workflow/src/test_support.rs index ebe61f89d..b8763c29a 100644 --- a/lib/crates/fabro-workflow/src/test_support.rs +++ b/lib/crates/fabro-workflow/src/test_support.rs @@ -339,13 +339,13 @@ impl WorkflowRunner { .unwrap() .take() .expect("WorkflowRunner may only be used once"); - run_graph( + Box::pin(run_graph( registry, Arc::clone(&self.emitter), Arc::clone(&self.sandbox), graph, run_options, - ) + )) .await } @@ -382,14 +382,14 @@ impl WorkflowRunner { .unwrap() .take() .expect("WorkflowRunner may only be used once"); - run_graph_from_checkpoint( + Box::pin(run_graph_from_checkpoint( registry, Arc::clone(&self.emitter), Arc::clone(&self.sandbox), graph, run_options, checkpoint, - ) + )) .await } diff --git a/lib/crates/fabro-workflow/tests/it/daytona_integration.rs b/lib/crates/fabro-workflow/tests/it/daytona_integration.rs index 514a38eca..7ba0b0189 100644 --- a/lib/crates/fabro-workflow/tests/it/daytona_integration.rs +++ b/lib/crates/fabro-workflow/tests/it/daytona_integration.rs @@ -22,7 +22,9 @@ use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node}; use fabro_llm::provider::Provider; use fabro_sandbox::daytona::{DaytonaConfig, DaytonaSandbox, DaytonaSnapshotConfig}; use fabro_store::{ArtifactStore, Database}; -use fabro_types::{RunId, Settings, StageId}; +use fabro_types::settings::v2::SettingsFile; +use fabro_types::settings::v2::run::{RunArtifactsLayer, RunLayer}; +use fabro_types::{RunId, StageId}; use fabro_workflow::artifact::sync_artifacts_to_env; use fabro_workflow::context::Context; use fabro_workflow::error::FabroError; @@ -496,7 +498,7 @@ async fn daytona_pipeline_artifact_offload_and_sync() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), env.clone()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -674,7 +676,7 @@ async fn daytona_git_checkpoint_remote_emits_events() { let engine = WorkflowRunner::new(registry, Arc::new(emitter), env.clone()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("git-cp-test"), @@ -845,7 +847,7 @@ async fn daytona_parallel_git_branching_e2e() { let engine = WorkflowRunner::new(registry, Arc::new(emitter), Arc::clone(&env)); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: run_tmp.path().to_path_buf(), cancel_token: None, run_id, @@ -1192,7 +1194,7 @@ async fn daytona_git_checkpoint_with_shadow_branch() { let meta_branch = MetadataStore::branch_name(&run_id.to_string()); let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), env.clone()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id, @@ -1326,11 +1328,14 @@ async fn daytona_asset_collection() { graph.edges.push(Edge::new("create_assets", "exit")); let run_options = RunOptions { - settings: Settings { - artifacts: Some(fabro_config::run::ArtifactsSettings { - include: vec!["test-results/**".to_string()], + settings: SettingsFile { + run: Some(RunLayer { + artifacts: Some(RunArtifactsLayer { + include: vec!["test-results/**".to_string()], + }), + ..RunLayer::default() }), - ..Settings::default() + ..SettingsFile::default() }, run_dir: dir.path().to_path_buf(), cancel_token: None, @@ -1586,7 +1591,7 @@ async fn daytona_git_push_run_branch_to_origin() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), env.clone()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id, diff --git a/lib/crates/fabro-workflow/tests/it/integration.rs b/lib/crates/fabro-workflow/tests/it/integration.rs index 6fd54edf6..383eeef3b 100644 --- a/lib/crates/fabro-workflow/tests/it/integration.rs +++ b/lib/crates/fabro-workflow/tests/it/integration.rs @@ -26,6 +26,8 @@ use fabro_interview::{ }; use fabro_llm::provider::Provider; use fabro_store::{ArtifactStore, Database}; +use fabro_types::settings::v2::SettingsFile; +use fabro_types::settings::v2::run::{RunArtifactsLayer, RunLayer}; use fabro_types::{RunEvent, RunId, Settings, StageId}; use fabro_validate::{Severity, validate, validate_or_raise}; use fabro_workflow::context::Context; @@ -336,7 +338,7 @@ async fn end_to_end_linear_pipeline() { local_env(), ); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -465,7 +467,7 @@ async fn end_to_end_branching_pipeline() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -584,7 +586,7 @@ async fn end_to_end_human_gate_pipeline() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -679,7 +681,7 @@ async fn human_gate_interrupted_input_fails_closed_without_fail_route() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -789,7 +791,7 @@ async fn human_gate_interrupted_input_routes_via_outcome_fail_condition() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -901,7 +903,7 @@ async fn goal_gate_routes_to_retry_target_on_failure() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -1021,7 +1023,7 @@ async fn goal_gate_routes_to_retry_target_when_present() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -1332,7 +1334,7 @@ async fn retry_on_failure_then_succeed() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -1406,7 +1408,7 @@ async fn pipeline_with_many_nodes() { local_env(), ); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -1751,7 +1753,7 @@ async fn smoke_test_with_mock_codergen_backend() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -1852,7 +1854,7 @@ async fn end_to_end_parallel_fan_out_fan_in() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -1964,7 +1966,7 @@ async fn resume_from_checkpoint_completes_pipeline() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -2062,7 +2064,7 @@ async fn resume_from_checkpoint_preserves_goal_gate_outcomes() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -2104,7 +2106,7 @@ async fn graph_goal_in_context() { local_env(), ); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -2142,7 +2144,7 @@ async fn event_streaming_lifecycle() { let events = collect_events(&emitter); let engine = WorkflowRunner::new(make_linear_registry(), Arc::new(emitter), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -2221,7 +2223,7 @@ async fn context_flow_between_stages() { local_env(), ); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -2276,7 +2278,7 @@ async fn tool_handler_e2e() { local_env(), ); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -2350,7 +2352,7 @@ async fn auto_approve_interviewer_e2e() { local_env(), ); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -2389,7 +2391,7 @@ async fn codergen_without_backend_simulated() { local_env(), ); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -2493,7 +2495,7 @@ async fn branching_loop_back_on_failure() { ); let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -2578,7 +2580,7 @@ async fn human_gate_loops_back() { registry.register("human", Box::new(HumanHandler::new(interviewer))); let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -2638,7 +2640,7 @@ async fn scenario_ship_a_feature() { local_env(), ); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -2722,7 +2724,7 @@ async fn scenario_parallel_expert_review() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -2808,7 +2810,7 @@ async fn scenario_node_retries_on_retry_status() { ); let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -2872,7 +2874,7 @@ async fn scenario_loop_restart_resets_context() { ); let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -2939,7 +2941,7 @@ async fn scenario_bug_triage_router() { registry.register("conditional", Box::new(ConditionalHandler)); let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -3000,7 +3002,7 @@ async fn scenario_crash_recovery() { registry.register("exit", Box::new(ExitHandler)); let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -3108,7 +3110,7 @@ async fn manager_loop_stop_condition_satisfied_e2e() { registry.register("stack.manager_loop", Box::new(SubWorkflowHandler)); let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -3189,7 +3191,7 @@ async fn manager_loop_max_cycles_exceeded_e2e() { registry.register("stack.manager_loop", Box::new(SubWorkflowHandler)); let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -3329,7 +3331,7 @@ async fn conditional_branching_success_fail_paths() { registry.register("always_fail", Box::new(AlwaysFailHandler)); let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -3384,7 +3386,7 @@ async fn edge_selection_condition_match_wins_over_weight() { registry.register("exit", Box::new(ExitHandler)); let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -3433,7 +3435,7 @@ async fn edge_selection_weight_breaks_ties() { registry.register("exit", Box::new(ExitHandler)); let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -3474,7 +3476,7 @@ async fn edge_selection_lexical_tiebreak() { registry.register("exit", Box::new(ExitHandler)); let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -3534,7 +3536,7 @@ async fn context_updates_visible_across_nodes() { registry.register("context_setter", Box::new(ContextSetterHandler)); let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -3580,7 +3582,7 @@ async fn stylesheet_applies_model_override() { local_env(), ); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -3635,7 +3637,7 @@ async fn custom_handler_registration_and_execution() { registry.register("my_custom", Box::new(CustomHandler)); let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -3708,7 +3710,7 @@ async fn integration_smoke_plan_implement_review_done() { local_env(), ); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -3799,7 +3801,7 @@ async fn manager_loop_runs_child_engine_e2e() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -3932,7 +3934,7 @@ async fn manager_loop_context_flows_e2e() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -4007,7 +4009,7 @@ async fn manager_loop_child_dotfile_e2e() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -4111,7 +4113,7 @@ async fn import_e2e_through_engine() { local_env(), ); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -4264,7 +4266,7 @@ async fn fidelity_default_is_compact() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -4320,7 +4322,7 @@ async fn fidelity_graph_default_applied() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -4372,7 +4374,7 @@ async fn fidelity_node_overrides_graph_default() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -4430,7 +4432,7 @@ async fn fidelity_edge_overrides_node_and_graph() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -4478,7 +4480,7 @@ async fn fidelity_full_produces_empty_preamble() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -4536,7 +4538,7 @@ async fn fidelity_truncate_preamble_minimal() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -4607,7 +4609,7 @@ async fn fidelity_summary_low_mode() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -4673,7 +4675,7 @@ async fn fidelity_summary_medium_mode() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -4739,7 +4741,7 @@ async fn fidelity_summary_high_mode() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -4798,7 +4800,7 @@ async fn fidelity_full_sets_thread_id_in_context() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -4868,7 +4870,7 @@ async fn fidelity_full_nodes_share_thread_id() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -4948,7 +4950,7 @@ async fn fidelity_resume_degrades_full_to_summary_high() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -5044,7 +5046,7 @@ async fn fidelity_resume_degrade_only_affects_first_hop() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -5127,7 +5129,7 @@ async fn fidelity_resume_no_degrade_when_not_full() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -5168,7 +5170,7 @@ async fn fidelity_stored_in_checkpoint_context() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -5260,7 +5262,7 @@ async fn fidelity_precedence_multi_node_pipeline() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -5327,7 +5329,7 @@ async fn fidelity_compact_preamble_includes_completed_stages_and_context() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -5401,7 +5403,7 @@ async fn fidelity_summary_low_excludes_context_values_in_pipeline() { ); let engine_low = WorkflowRunner::new(registry_low, Arc::new(Emitter::default()), local_env()); let run_options_low = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir_low.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -5467,7 +5469,7 @@ async fn fidelity_summary_low_excludes_context_values_in_pipeline() { ); let engine_med = WorkflowRunner::new(registry_med, Arc::new(Emitter::default()), local_env()); let run_options_med = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir_med.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -5537,7 +5539,7 @@ async fn fidelity_thread_id_fallback_to_previous_node_in_pipeline() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -5590,7 +5592,7 @@ async fn fidelity_thread_id_from_node_class_in_pipeline() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -5646,7 +5648,7 @@ async fn fidelity_edge_thread_id_override_in_pipeline() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -5703,7 +5705,7 @@ async fn fidelity_full_without_explicit_thread_id_uses_previous_node() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -5770,7 +5772,7 @@ async fn fidelity_from_parsed_dot_pipeline() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -5817,7 +5819,7 @@ async fn fidelity_checkpoint_roundtrip_preserves_fidelity() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -5888,7 +5890,7 @@ async fn fidelity_node_thread_id_overrides_edge_thread_id_in_pipeline() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -5974,7 +5976,7 @@ async fn fidelity_resume_preserves_context_values_across_checkpoint() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -6017,7 +6019,7 @@ mod real_llm { use async_trait::async_trait; use fabro_graphviz::graph::Node; - use fabro_types::Settings; + use fabro_types::settings::v2::SettingsFile; use fabro_workflow::context::Context; use fabro_workflow::error::FabroError; use fabro_workflow::handler::agent::{AgentHandler, CodergenBackend, CodergenResult}; @@ -6211,7 +6213,7 @@ mod real_llm { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -6319,7 +6321,7 @@ mod real_llm { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -6451,7 +6453,7 @@ mod real_llm { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -6551,7 +6553,7 @@ mod real_llm { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -6644,7 +6646,7 @@ async fn human_gate_freeform_only_routes_text() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -6773,7 +6775,7 @@ async fn human_gate_freeform_with_fixed_choice_match() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -6887,7 +6889,7 @@ async fn human_gate_freeform_fallback_on_unmatched_text() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -7014,7 +7016,7 @@ async fn human_gate_freeform_sets_allow_freeform_on_question() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -7121,7 +7123,7 @@ async fn human_gate_without_freeform_sets_allow_freeform_false() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -7421,7 +7423,7 @@ fn engine_with_hooks_and_events( fn make_run_options(dir: &std::path::Path) -> RunOptions { RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.to_path_buf(), cancel_token: None, run_id: test_run_id("hook-test-run"), @@ -8437,7 +8439,7 @@ async fn run_fidelity_prompt_pipeline(fidelity: &str) -> String { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -8637,7 +8639,7 @@ async fn large_context_values_are_offloaded_to_artifact_store() { let events = collect_events(&emitter); let engine = WorkflowRunner::new(registry, Arc::new(emitter), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -8840,7 +8842,7 @@ async fn artifact_pointers_rewritten_for_remote_sandbox() { let remote_env = Arc::new(RemoteMockEnv::new("/sandbox")); let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), remote_env.clone()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -8927,7 +8929,7 @@ async fn downstream_local_execution_materializes_blob_refs_to_runtime_files() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -9014,7 +9016,7 @@ async fn downstream_remote_execution_materializes_blob_refs_to_sandbox_files() { let remote_env = Arc::new(RemoteMockEnv::new("/sandbox")); let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), remote_env.clone()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -9144,7 +9146,7 @@ async fn node_dir_uses_visit_count_on_revisit() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -10013,7 +10015,7 @@ async fn full_pipeline_with_cli_backend_node() { let dir = tempfile::tempdir().unwrap(); let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), env); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -10131,7 +10133,7 @@ async fn stylesheet_backend_property_routes_to_cli() { let dir = tempfile::tempdir().unwrap(); let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), env); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), @@ -10321,7 +10323,7 @@ async fn git_checkpoint_host_emits_events_and_diff_patch() { let engine = WorkflowRunner::new(registry, Arc::new(emitter), env); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: run_dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-docker"), @@ -10487,7 +10489,7 @@ async fn git_checkpoint_host_writes_shadow_branch() { let meta_branch = MetadataStore::branch_name(&run_id.to_string()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: run_dir.path().to_path_buf(), cancel_token: None, run_id, @@ -10684,7 +10686,7 @@ async fn parallel_git_branching_host_e2e() { let engine = WorkflowRunner::new(registry, Arc::new(emitter), env); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: run_dir.path().to_path_buf(), cancel_token: None, run_id, @@ -10933,7 +10935,7 @@ async fn git_checkpoint_host_skips_empty_diff_patch() { let engine = WorkflowRunner::new(registry, Arc::new(emitter), env); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: run_dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("empty-diff"), @@ -11300,7 +11302,7 @@ async fn e2e_circuit_breaker_deterministic_self_loop() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("e2e-circuit-breaker"), @@ -11346,7 +11348,7 @@ async fn e2e_circuit_breaker_custom_limit() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("e2e-custom-limit"), @@ -11385,7 +11387,7 @@ async fn e2e_circuit_breaker_ignores_transient_failures() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("e2e-transient-no-breaker"), @@ -11431,7 +11433,7 @@ async fn e2e_circuit_breaker_different_reasons_separate_counters() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("e2e-varying-reasons"), @@ -11470,7 +11472,7 @@ async fn e2e_circuit_breaker_loop_restart() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("e2e-restart-breaker"), @@ -11531,7 +11533,7 @@ async fn e2e_failure_signature_persisted_in_context() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("e2e-sig-context"), @@ -11594,7 +11596,7 @@ async fn e2e_failure_signature_hint_overrides_reason_in_context() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("e2e-sig-hint"), @@ -11649,7 +11651,7 @@ async fn e2e_signature_maps_persist_in_checkpoint() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("e2e-sig-persist"), @@ -11775,7 +11777,7 @@ async fn e2e_circuit_breaker_emits_events_before_abort() { let engine = WorkflowRunner::new(registry, Arc::new(emitter), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("e2e-events"), @@ -11839,7 +11841,7 @@ async fn e2e_circuit_breaker_does_not_fire_below_limit() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("e2e-below-limit"), @@ -11934,7 +11936,7 @@ async fn e2e_circuit_breaker_multi_stage_impl_verify_cycle() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("e2e-impl-verify-cycle"), @@ -12030,7 +12032,7 @@ async fn e2e_loop_restart_blocked_for_deterministic_failure() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("e2e-restart-blocked-det"), @@ -12069,7 +12071,7 @@ async fn e2e_loop_restart_blocked_for_structural_failure() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("e2e-restart-blocked-struct"), @@ -12108,7 +12110,7 @@ async fn e2e_loop_restart_blocked_for_budget_exhausted_failure() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("e2e-restart-blocked-budget"), @@ -12147,7 +12149,7 @@ async fn e2e_loop_restart_blocked_for_canceled_failure() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("e2e-restart-blocked-canceled"), @@ -12183,7 +12185,7 @@ async fn e2e_loop_restart_blocked_for_compilation_loop_failure() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("e2e-restart-blocked-comploop"), @@ -12223,7 +12225,7 @@ async fn e2e_loop_restart_allowed_for_transient_infra() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("e2e-restart-allowed-transient"), @@ -12330,7 +12332,7 @@ async fn e2e_stall_watchdog_triggers_from_dot_parsed_pipeline() { let engine = WorkflowRunner::new(registry, Arc::new(emitter), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("stall-e2e"), @@ -12385,7 +12387,7 @@ async fn e2e_stall_watchdog_kept_alive_by_handler_events() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("stall-alive-e2e"), @@ -12430,7 +12432,7 @@ async fn e2e_stall_watchdog_disabled_with_zero_timeout() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("stall-disabled-e2e"), @@ -12494,7 +12496,7 @@ async fn e2e_stall_watchdog_with_explicit_timeout_override() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("stall-override-e2e"), @@ -12624,11 +12626,14 @@ async fn asset_collection_local_sandbox_success() { graph.edges.push(Edge::new("create_assets", "exit")); let run_options = RunOptions { - settings: Settings { - artifacts: Some(fabro_config::run::ArtifactsSettings { - include: vec!["test-results/**".to_string()], + settings: SettingsFile { + run: Some(RunLayer { + artifacts: Some(RunArtifactsLayer { + include: vec!["test-results/**".to_string()], + }), + ..RunLayer::default() }), - ..Settings::default() + ..SettingsFile::default() }, run_dir: run_dir.path().to_path_buf(), cancel_token: None, @@ -12753,11 +12758,14 @@ async fn asset_collection_local_sandbox_on_failure() { graph.edges.push(Edge::new("create_assets", "exit")); let run_options = RunOptions { - settings: Settings { - artifacts: Some(fabro_config::run::ArtifactsSettings { - include: vec!["test-results/**".to_string()], + settings: SettingsFile { + run: Some(RunLayer { + artifacts: Some(RunArtifactsLayer { + include: vec!["test-results/**".to_string()], + }), + ..RunLayer::default() }), - ..Settings::default() + ..SettingsFile::default() }, run_dir: run_dir.path().to_path_buf(), cancel_token: None, @@ -12854,11 +12862,14 @@ async fn asset_collection_docker_sandbox() { graph.edges.push(Edge::new("create_assets", "exit")); let run_options = RunOptions { - settings: Settings { - artifacts: Some(fabro_config::run::ArtifactsSettings { - include: vec!["test-results/**".to_string()], + settings: SettingsFile { + run: Some(RunLayer { + artifacts: Some(RunArtifactsLayer { + include: vec!["test-results/**".to_string()], + }), + ..RunLayer::default() }), - ..Settings::default() + ..SettingsFile::default() }, run_dir: run_dir.path().to_path_buf(), cancel_token: None, @@ -12927,7 +12938,7 @@ async fn wait_timer_e2e() { local_env(), ); let run_options = RunOptions { - settings: Settings::default(), + settings: SettingsFile::default(), run_dir: dir.path().to_path_buf(), cancel_token: None, run_id: test_run_id("test-run"), From 52c295cf768cb3381f89096ab83dc4cd9c184014 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 9 Apr 2026 15:36:09 -0400 Subject: [PATCH 19/47] test(settings): update fabro-cli test suite for v2 settings shape Completes the fabro-cli test migration for Stage 6.1. Every test in `cargo nextest run --workspace` now passes (3,764 passed / 0 failed). Changes: - cmd/support.rs: compact_inspect / compact_git_inspect now walk the v2 tree (/settings/run/goal, /settings/run/sandbox/provider, /settings/run/model/provider) and derive `dry_run` from the v2 execution mode. - cmd/attach.rs: the event-log filter strips _version and redacts settings.cli.target.path to [CLI_SOCKET] so randomized tempdir sockets don't pollute the snapshot. Insta snapshot accepted. - cmd/run.rs: same cli.target redaction in the run event filter. dry_run_persists_event_history_in_store and json_run_implies_auto_approve_for_human_gates check for `settings.run.execution.approval == "auto"` instead of `settings.auto_approve == true`. Insta snapshot accepted. - cmd/config.rs: parse_settings bridges the v2 YAML output back down to the legacy flat Settings shape so the existing helper assertions keep working. settings_fetches_server_settings_and_merges_with_local_config now asserts the v2 R22 behavior (run.inputs replaces wholesale, so server-side `server_only` is dropped in favor of project's vars). settings_uses_fabro_home_for_home_config_resolution walks the v2 JSON paths (cli.output.verbosity, run.model.name). create_explicit_workflow_path_uses_project_config_relative_to_workflow asserts against the v2 run-record shape. - fabro-cli/commands/config/mod.rs: legacy_settings_to_v2 is now a real (if narrow) reverse bridge covering storage, scheduler, github integration, slack integration, run.model, run.inputs, and cli verbosity. Stage 6.6 still replaces this when the API client returns v2 types natively, but for now the server-side defaults round-trip through the resolver with enough fidelity to keep the settings command integration tests honest. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../fabro-cli/src/commands/config/mod.rs | 87 ++++++++++++++++--- lib/crates/fabro-cli/tests/it/cmd/attach.rs | 45 ++++++---- lib/crates/fabro-cli/tests/it/cmd/config.rs | 39 ++++++--- lib/crates/fabro-cli/tests/it/cmd/run.rs | 71 ++++++++------- lib/crates/fabro-cli/tests/it/cmd/support.rs | 16 ++-- 5 files changed, 187 insertions(+), 71 deletions(-) diff --git a/lib/crates/fabro-cli/src/commands/config/mod.rs b/lib/crates/fabro-cli/src/commands/config/mod.rs index c7ddde42c..414d43ae5 100644 --- a/lib/crates/fabro-cli/src/commands/config/mod.rs +++ b/lib/crates/fabro-cli/src/commands/config/mod.rs @@ -83,17 +83,82 @@ async fn merged_config(args: &SettingsArgs) -> anyhow::Result { effective_settings::resolve_settings(layers, Some(&server_settings), mode) } -/// Stopgap shim that converts a legacy flat `Settings` back into a -/// `SettingsFile` for consumption by the v2-native resolver. This exists -/// because `retrieve_server_settings` still returns the legacy shape -/// across the wire. When Stage 6.6 rewrites the OpenAPI spec to return v2 -/// types, this conversion goes away and the loaded shape stays v2 end to end. -fn legacy_settings_to_v2(_legacy: &fabro_types::Settings) -> SettingsFile { - // TODO: implement a true reverse bridge. For now, return an empty v2 - // file so `resolve_settings(..., Some(&...), RemoteServer)` has a - // non-None server-settings argument. This loses server-side defaults; - // Stage 6.6 fixes the full round-trip. - SettingsFile::default() +/// Stopgap reverse bridge from the legacy flat `Settings` to a v2 +/// `SettingsFile`. `retrieve_server_settings` still returns the legacy +/// shape across the wire; the v2 resolver needs server-settings in v2 +/// shape. This reverse-maps the fields that matter for server-side +/// defaults (storage, scheduler, integrations, verbose, run model). +/// Stage 6.6 rewrites the API client to return v2 types directly and +/// deletes this helper. +fn legacy_settings_to_v2(legacy: &fabro_types::Settings) -> SettingsFile { + use fabro_types::settings::v2::cli::{CliLayer, CliOutputLayer, OutputVerbosity}; + use fabro_types::settings::v2::interp::InterpString; + use fabro_types::settings::v2::run::{RunLayer, RunModelLayer}; + use fabro_types::settings::v2::server::{ + GithubIntegrationLayer, ServerIntegrationsLayer, ServerLayer, ServerSchedulerLayer, + ServerStorageLayer, SlackIntegrationLayer, + }; + + let mut file = SettingsFile::default(); + + if let Some(storage_dir) = legacy.storage_dir.as_ref() { + let server = file.server.get_or_insert_with(ServerLayer::default); + server.storage = Some(ServerStorageLayer { + root: Some(InterpString::parse(&storage_dir.to_string_lossy())), + }); + } + if let Some(max_concurrent) = legacy.max_concurrent_runs { + let server = file.server.get_or_insert_with(ServerLayer::default); + server.scheduler = Some(ServerSchedulerLayer { + max_concurrent_runs: Some(max_concurrent), + }); + } + if let Some(git) = legacy.git.as_ref() { + let server = file.server.get_or_insert_with(ServerLayer::default); + let integrations = server + .integrations + .get_or_insert_with(ServerIntegrationsLayer::default); + let github = integrations + .github + .get_or_insert_with(GithubIntegrationLayer::default); + github.app_id = git.app_id.as_deref().map(InterpString::parse); + github.client_id = git.client_id.as_deref().map(InterpString::parse); + github.slug = git.slug.as_deref().map(InterpString::parse); + } + if let Some(slack) = legacy.slack.as_ref() { + let server = file.server.get_or_insert_with(ServerLayer::default); + let integrations = server + .integrations + .get_or_insert_with(ServerIntegrationsLayer::default); + integrations.slack = Some(SlackIntegrationLayer { + enabled: None, + default_channel: slack.default_channel.as_deref().map(InterpString::parse), + }); + } + if let Some(llm) = legacy.llm.as_ref() { + let run = file.run.get_or_insert_with(RunLayer::default); + run.model = Some(RunModelLayer { + provider: llm.provider.as_deref().map(InterpString::parse), + name: llm.model.as_deref().map(InterpString::parse), + fallbacks: Vec::new(), + }); + } + if let Some(vars) = legacy.vars.as_ref() { + let run = file.run.get_or_insert_with(RunLayer::default); + run.inputs = Some( + vars.iter() + .map(|(k, v)| (k.clone(), toml::Value::String(v.clone()))) + .collect(), + ); + } + if let Some(true) = legacy.verbose { + let cli = file.cli.get_or_insert_with(CliLayer::default); + cli.output = Some(CliOutputLayer { + verbosity: Some(OutputVerbosity::Verbose), + ..CliOutputLayer::default() + }); + } + file } pub(crate) async fn execute(args: &SettingsArgs, globals: &GlobalArgs) -> anyhow::Result<()> { diff --git a/lib/crates/fabro-cli/tests/it/cmd/attach.rs b/lib/crates/fabro-cli/tests/it/cmd/attach.rs index 440d940c2..8acaa062d 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/attach.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/attach.rs @@ -427,9 +427,21 @@ fn attach_json_errors_without_prompting_for_human_input() { .pointer_mut("/properties/settings") .and_then(Value::as_object_mut) { + settings.remove("_version"); settings.remove("server"); settings.remove("version"); } + if let Some(target) = event + .pointer_mut("/properties/settings/cli/target") + .and_then(Value::as_object_mut) + { + if target.contains_key("path") { + target.insert( + "path".to_string(), + Value::String("[CLI_SOCKET]".to_string()), + ); + } + } event }) .collect(); @@ -556,22 +568,25 @@ fn attach_json_errors_without_prompting_for_human_input() { }, "run_dir": "[RUN_DIR]", "settings": { - "goal": "Wait for approval", - "llm": { - "fallbacks": null, - "model": "gpt-5.4", - "provider": "openai" + "run": { + "execution": { + "retros": false + }, + "goal": "Wait for approval", + "model": { + "name": "gpt-5.4", + "provider": "openai" + }, + "sandbox": { + "provider": "local" + } }, - "no_retro": true, - "sandbox": { - "daytona": null, - "devcontainer": null, - "env": null, - "local": null, - "preserve": null, - "provider": "local" - }, - "storage_dir": "[STORAGE_DIR]" + "cli": { + "target": { + "path": "[CLI_SOCKET]", + "type": "unix" + } + } }, "workflow_slug": "human-gate", "workflow_source": "digraph HumanGate {/n graph [goal=\"Wait for approval\"]/n start [shape=Mdiamond, label=\"Start\"]/n exit [shape=Msquare, label=\"Exit\"]/n approve [shape=hexagon, label=\"Approve?\"]/n ship [shape=parallelogram, script=\"echo shipped\"]/n revise [shape=parallelogram, script=\"echo revised\"]/n start -> approve/n approve -> ship [label=\"[A] Approve\"]/n approve -> revise [label=\"[R] Revise\"]/n ship -> exit/n revise -> exit/n}/n", diff --git a/lib/crates/fabro-cli/tests/it/cmd/config.rs b/lib/crates/fabro-cli/tests/it/cmd/config.rs index 7a401d967..9cf7fbe99 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/config.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/config.rs @@ -3,6 +3,7 @@ use std::path::PathBuf; use fabro_config::mcp::McpTransport; use fabro_test::{fabro_snapshot, test_context}; use fabro_types::Settings; +use fabro_types::settings::v2::SettingsFile; use httpmock::MockServer; use predicates::prelude::*; @@ -32,7 +33,13 @@ fn old_config_show_command_is_rejected() { // --------------------------------------------------------------------------- fn parse_settings(stdout: &[u8]) -> Settings { - serde_yaml::from_slice(stdout).expect("stdout should be valid YAML Settings") + // The `settings` command now emits a v2 SettingsFile as YAML. Bridge + // it down to the legacy flat shape so the existing test assertions + // (which use flat fields like `cfg.llm`, `cfg.sandbox`, etc.) keep + // working. Stage 6.6 will rewrite these tests against the v2 tree. + let file: SettingsFile = + serde_yaml::from_slice(stdout).expect("stdout should be valid YAML SettingsFile"); + fabro_types::settings::v2::bridge::bridge_to_old(&file) } fn server_settings_fixture() -> Settings { @@ -487,23 +494,26 @@ fn create_explicit_workflow_path_uses_project_config_relative_to_workflow() { let state = run_state(&run_dir); let run_record = serde_json::to_value(state.run.as_ref().expect("run record should exist")).unwrap(); - assert_eq!(run_record["settings"]["auto_approve"].as_bool(), Some(true)); assert_eq!( - run_record["settings"]["storage_dir"].as_str(), + run_record["settings"]["run"]["execution"]["approval"].as_str(), + Some("auto") + ); + assert_eq!( + run_record["settings"]["server"]["storage"]["root"].as_str(), Some(storage_dir.to_str().unwrap()) ); assert_eq!( - run_record["settings"]["sandbox"]["preserve"].as_bool(), + run_record["settings"]["run"]["sandbox"]["preserve"].as_bool(), Some(true) ); assert_eq!( - run_record["settings"]["llm"]["model"].as_str(), + run_record["settings"]["run"]["model"]["name"].as_str(), Some("gpt-5.2") ); // v2 R30: run.prepare.steps replaces the whole ordered list across layers. assert_eq!( - run_record["settings"]["setup"]["commands"], - serde_json::json!(["workflow-setup"]) + run_record["settings"]["run"]["prepare"]["steps"], + serde_json::json!([{"script": "workflow-setup"}]) ); } @@ -659,8 +669,11 @@ name = "from-fabro-home" ); let cfg: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); - assert_eq!(cfg["verbose"].as_bool(), Some(true)); - assert_eq!(cfg["llm"]["model"].as_str(), Some("from-fabro-home")); + assert_eq!(cfg["cli"]["output"]["verbosity"].as_str(), Some("verbose")); + assert_eq!( + cfg["run"]["model"]["name"].as_str(), + Some("from-fabro-home") + ); } #[test] @@ -756,10 +769,16 @@ shared = "cli" assert_eq!(cfg.storage_dir, Some(PathBuf::from("/srv/fabro-server"))); assert_eq!(cfg.verbose, Some(true)); + // R22: run.inputs replaces wholesale across layers. Project is the + // highest-precedence layer that sets inputs, so project's vars win + // and server-side vars are discarded rather than merged. let vars = cfg.vars.as_ref().expect("vars"); - assert_eq!(vars.get("server_only").map(String::as_str), Some("1")); assert_eq!(vars.get("project_only").map(String::as_str), Some("1")); assert_eq!(vars.get("shared").map(String::as_str), Some("project")); + assert!( + !vars.contains_key("server_only"), + "v2 merge matrix replaces run.inputs wholesale; server_only should be dropped" + ); } #[test] diff --git a/lib/crates/fabro-cli/tests/it/cmd/run.rs b/lib/crates/fabro-cli/tests/it/cmd/run.rs index a7abf19a6..e1a9c8eed 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/run.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/run.rs @@ -592,9 +592,9 @@ fn dry_run_persists_event_history_in_store() { assert_eq!( progress .first() - .and_then(|event| event.pointer("/properties/settings/auto_approve")) - .and_then(Value::as_bool), - Some(true) + .and_then(|event| event.pointer("/properties/settings/run/execution/approval")) + .and_then(Value::as_str), + Some("auto") ); assert!( progress @@ -724,25 +724,35 @@ fn json_run_implies_auto_approve_for_human_gates() { ); } } - // Strip v2-shape server/version fields that the bridge now emits. + // Strip fields that vary between runs (version, server stanzas that + // carry machine-specific values, cli.target sockets). if let Some(settings) = event .pointer_mut("/properties/settings") .and_then(Value::as_object_mut) { + settings.remove("_version"); settings.remove("server"); settings.remove("version"); } - let Some(llm) = event.pointer_mut("/properties/settings/llm") else { + if let Some(target) = event + .pointer_mut("/properties/settings/cli/target") + .and_then(Value::as_object_mut) + { + if target.contains_key("path") { + target.insert( + "path".to_string(), + Value::String("[CLI_SOCKET]".to_string()), + ); + } + } + let Some(model) = event.pointer_mut("/properties/settings/run/model") else { continue; }; - let Some(llm) = llm.as_object_mut() else { + let Some(model) = model.as_object_mut() else { continue; }; - llm.insert( - "model".to_string(), - Value::String("[LLM_MODEL]".to_string()), - ); - llm.insert( + model.insert("name".to_string(), Value::String("[LLM_MODEL]".to_string())); + model.insert( "provider".to_string(), Value::String("[LLM_PROVIDER]".to_string()), ); @@ -870,23 +880,26 @@ fn json_run_implies_auto_approve_for_human_gates() { }, "run_dir": "[RUN_DIR]", "settings": { - "auto_approve": true, - "goal": "Route through the default approval path", - "llm": { - "fallbacks": null, - "model": "[LLM_MODEL]", - "provider": "[LLM_PROVIDER]" + "run": { + "execution": { + "approval": "auto", + "retros": false + }, + "goal": "Route through the default approval path", + "model": { + "name": "[LLM_MODEL]", + "provider": "[LLM_PROVIDER]" + }, + "sandbox": { + "provider": "local" + } }, - "no_retro": true, - "sandbox": { - "daytona": null, - "devcontainer": null, - "env": null, - "local": null, - "preserve": null, - "provider": "local" - }, - "storage_dir": "[STORAGE_DIR]" + "cli": { + "target": { + "path": "[CLI_SOCKET]", + "type": "unix" + } + } }, "workflow_slug": "human-gate", "workflow_source": "digraph HumanGate {/n graph [goal=\"Route through the default approval path\"]/n start [shape=Mdiamond, label=\"Start\"]/n exit [shape=Msquare, label=\"Exit\"]/n approve [shape=hexagon, label=\"Approve?\"]/n ship [shape=parallelogram, script=\"echo shipped\"]/n revise [shape=parallelogram, script=\"echo revised\"]/n start -> approve/n approve -> ship [label=\"[A] Approve\"]/n approve -> revise [label=\"[R] Revise\"]/n ship -> exit/n revise -> exit/n}/n", @@ -1431,8 +1444,8 @@ fn json_run_implies_auto_approve_for_human_gates() { "#); assert_eq!( - progress[0].pointer("/properties/settings/auto_approve"), - Some(&serde_json::json!(true)) + progress[0].pointer("/properties/settings/run/execution/approval"), + Some(&serde_json::json!("auto")) ); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/support.rs b/lib/crates/fabro-cli/tests/it/cmd/support.rs index b36c9fbf2..5262bb590 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/support.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/support.rs @@ -803,15 +803,19 @@ pub(crate) fn compact_inspect(output: &Output) -> Value { let checkpoint = item["checkpoint"].clone(); let conclusion = item["conclusion"].clone(); let sandbox = item["sandbox"].clone(); + let dry_run = run_record + .pointer("/settings/run/execution/mode") + .and_then(Value::as_str) + .map(|mode| Value::Bool(mode == "dry_run")); serde_json::json!({ "run_id": "[ULID]", "status": item["status"], "run_record": { - "goal": run_record.pointer("/settings/goal"), + "goal": run_record.pointer("/settings/run/goal"), "workflow_name": run_record.pointer("/graph/name"), "workflow_slug": run_record.pointer("/workflow_slug"), - "sandbox_provider": run_record.pointer("/settings/sandbox/provider"), - "dry_run": run_record.pointer("/settings/dry_run"), + "sandbox_provider": run_record.pointer("/settings/run/sandbox/provider"), + "dry_run": dry_run, "provenance": run_record.pointer("/provenance").as_ref().map(|_| { serde_json::json!({ "server_version": "[VERSION]", @@ -866,11 +870,11 @@ pub(crate) fn compact_git_inspect(output: &Output) -> Value { "run_id": "[ULID]", "status": item["status"], "run_record": { - "goal": run_record.pointer("/settings/goal"), + "goal": run_record.pointer("/settings/run/goal"), "workflow_name": run_record.pointer("/graph/name"), "workflow_slug": run_record.pointer("/workflow_slug"), - "llm_provider": run_record.pointer("/settings/llm/provider"), - "sandbox_provider": run_record.pointer("/settings/sandbox/provider"), + "llm_provider": run_record.pointer("/settings/run/model/provider"), + "sandbox_provider": run_record.pointer("/settings/run/sandbox/provider"), "provenance": run_record.pointer("/provenance").as_ref().map(|_| { serde_json::json!({ "server_version": "[VERSION]", From ea206e0e40855ff28d8207e28261404802d2b484 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 9 Apr 2026 16:12:59 -0400 Subject: [PATCH 20/47] feat(settings): stage 6.2 delete bridge_to_old seam bridge.rs (818 LOC) is gone. Production consumers no longer produce a full legacy `Settings` from v2 state; every read path walks the v2 tree directly or uses one of the narrow v2->runtime helpers in the new `settings::v2::to_runtime` module. Core moves: fabro-types - Delete `settings::v2::bridge::bridge_to_old` and the whole bridge.rs file. - Relocate the narrow v2->runtime helpers (`bridge_sandbox`, `bridge_mcp_entry`, `bridge_mcps`, `bridge_hook`, `bridge_worktree_mode`, `bridge_merge_strategy`, `bridge_pull_request`, `bridge_run_artifacts`) into a new `settings::v2::to_runtime` module. Each helper takes a single v2 subtree and produces the corresponding runtime shape; nothing assembles a full legacy `Settings` anymore. - `settings/mod.rs` doc comment rewritten to describe `Settings` as a runtime shape, not a resolved parse target. Stage 6.3 deletes it. fabro-config - `ConfigLayer::resolve` is gone along with the `TryFrom for Settings` impls. Consumers call `.into()` for a `SettingsFile`, or `.as_v2()` to borrow one. - `fabro_config::server::resolve_storage_dir` now takes `&SettingsFile`. fabro-server - `api_server_settings` emits the v2 `SettingsFile` JSON shape directly instead of bridging to the legacy flat DTO. Stage 6.6 replaces the shape again with an explicit allow-list DTO. - `serve.rs`: `load_settings` returns `SettingsFile`; `apply_serve_overrides` / `apply_runtime_settings` mutate v2 subtrees directly; `build_artifact_object_store` walks `server.artifacts`; `build_legacy_api_settings` projects the v2 auth/listen/api subtrees down to the legacy `ApiSettings` shape for the (still-legacy) auth resolver. - `diagnostics::check_crypto` walks `server.auth.api.{jwt,mtls}` and `server.listen.tls` directly. - `web_auth.rs` oauth / register / setup-status / auth-me flows all read `server.web`, `server.integrations.github`, and `server.auth.web` directly via the v2 accessors. `merge_settings_keys` now writes v2 TOML (with `[server.web]`, `[server.integrations.github]`, etc.) instead of the legacy v1 top-level keys, and the register handler re-parses the freshly-written file back into the in-memory `SettingsFile` state. fabro-cli - `CommandContext::machine_settings` returns `&SettingsFile`. - `user_config::load_settings` and friends return `SettingsFile`. - `user_config::resolve_server_target` / `exec_server_target` / `configured_server_target` walk `cli.target.{http,unix}` directly. Tests rewritten against v2 TOML fixtures. - `main.rs` logging init reads `cli.logging.level` / `server.logging.level` via v2 accessors. - `commands/exec.rs` reads `cli.exec.{model,agent}` and builds mcps from `cli.exec.agent.mcps` (falling back to `run.agent.mcps`) via `to_runtime::bridge_mcp_entry`. - `commands/pr/mod.rs` calls `github_app_id_str()`. - `commands/run/create.rs` drops the legacy `.resolve()` call and uses `Into::::into(...)`. - `commands/config/mod.rs::legacy_settings_to_v2` is now a real reverse-mapping helper that covers `storage`, `scheduler`, `integrations.{github,slack}`, `run.model`, `run.inputs`, and `cli.output.verbosity`. Stage 6.6 deletes it when the API client returns v2 natively. - `tests/it/cmd/config.rs` tests now walk the v2 tree directly (via `cfg.run_model_name_str()`, `cfg.run_inputs()`, `cfg.run_sandbox()`, `cfg.run_hooks()`, `cfg.run_agent_mcps()`, `cfg.run_prepare_commands()`, `cfg.server_storage_root_str()`, etc.). The `bridge_to_old` test helper is gone. - `tests/it/api/settings.rs` asserts against the v2 JSON shape. Build, test, and quality gates all green: - `cargo build --workspace --tests` - `cargo clippy --workspace -- -D warnings` - `cargo fmt --check --all` - `cargo nextest run --workspace`: 3758 / 3758 passed, 182 skipped. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/crates/fabro-cli/src/command_context.rs | 6 +- lib/crates/fabro-cli/src/commands/exec.rs | 50 +- lib/crates/fabro-cli/src/commands/pr/mod.rs | 3 +- .../fabro-cli/src/commands/run/create.rs | 5 +- lib/crates/fabro-cli/src/main.rs | 26 +- lib/crates/fabro-cli/src/server_client.rs | 6 +- lib/crates/fabro-cli/src/user_config.rs | 179 ++-- lib/crates/fabro-cli/tests/it/cmd/config.rs | 152 ++-- lib/crates/fabro-config/src/config.rs | 43 +- lib/crates/fabro-config/src/server.rs | 6 +- lib/crates/fabro-server/src/diagnostics.rs | 60 +- lib/crates/fabro-server/src/run_manifest.rs | 2 +- lib/crates/fabro-server/src/serve.rs | 157 +++- lib/crates/fabro-server/src/server.rs | 22 +- lib/crates/fabro-server/src/web_auth.rs | 333 +++---- .../fabro-server/tests/it/api/settings.rs | 10 +- lib/crates/fabro-types/src/settings/mod.rs | 19 +- .../fabro-types/src/settings/v2/bridge.rs | 836 ------------------ lib/crates/fabro-types/src/settings/v2/mod.rs | 4 +- .../fabro-types/src/settings/v2/to_runtime.rs | 325 +++++++ .../fabro-workflow/src/operations/start.rs | 4 +- 21 files changed, 901 insertions(+), 1347 deletions(-) delete mode 100644 lib/crates/fabro-types/src/settings/v2/bridge.rs create mode 100644 lib/crates/fabro-types/src/settings/v2/to_runtime.rs diff --git a/lib/crates/fabro-cli/src/command_context.rs b/lib/crates/fabro-cli/src/command_context.rs index 8c33954d0..cacae3ef6 100644 --- a/lib/crates/fabro-cli/src/command_context.rs +++ b/lib/crates/fabro-cli/src/command_context.rs @@ -2,7 +2,7 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use anyhow::{Context as _, Result, bail}; -use fabro_types::Settings; +use fabro_types::settings::v2::SettingsFile; use tokio::sync::OnceCell; use crate::args::{ServerConnectionArgs, ServerTargetArgs}; @@ -24,7 +24,7 @@ pub(crate) enum ServerMode { pub(crate) struct CommandContext { cwd: PathBuf, base_config_path: PathBuf, - machine_settings: Settings, + machine_settings: SettingsFile, server_mode: ServerMode, server: OnceCell>, } @@ -75,7 +75,7 @@ impl CommandContext { &self.base_config_path } - pub(crate) fn machine_settings(&self) -> &Settings { + pub(crate) fn machine_settings(&self) -> &SettingsFile { &self.machine_settings } diff --git a/lib/crates/fabro-cli/src/commands/exec.rs b/lib/crates/fabro-cli/src/commands/exec.rs index 0f57b820c..04d6fa619 100644 --- a/lib/crates/fabro-cli/src/commands/exec.rs +++ b/lib/crates/fabro-cli/src/commands/exec.rs @@ -1,9 +1,10 @@ use anyhow::Result; use fabro_agent::cli::{OutputFormat, run_with_args, run_with_args_and_client}; -use fabro_config::mcp::McpServerEntry; use fabro_llm::client::Client; use fabro_llm::providers::FabroServerAdapter; use fabro_mcp::config::McpServerSettings; +use fabro_types::settings::v2::InterpString; +use fabro_types::settings::v2::to_runtime::bridge_mcp_entry; use std::collections::HashMap; use std::sync::Arc; @@ -11,25 +12,52 @@ use crate::args::{ExecArgs, GlobalArgs}; use crate::user_config; pub(crate) async fn execute(mut args: ExecArgs, globals: &GlobalArgs) -> Result<()> { + use fabro_agent::cli::PermissionLevel as AgentPermissionLevel; + use fabro_types::settings::v2::run::AgentPermissions; + let cli_settings = user_config::load_settings()?; #[cfg(feature = "sleep_inhibitor")] let _sleep_guard = crate::sleep_inhibitor::guard(cli_settings.prevent_idle_sleep_enabled()); - let exec_defaults = cli_settings.exec.as_ref(); + let exec_defaults = cli_settings.cli_exec(); + let exec_model = exec_defaults.and_then(|e| e.model.as_ref()); + let exec_agent = exec_defaults.and_then(|e| e.agent.as_ref()); + let provider_str = exec_model + .and_then(|m| m.provider.as_ref()) + .map(InterpString::as_source); + let model_str = exec_model + .and_then(|m| m.name.as_ref()) + .map(InterpString::as_source); + let permissions = exec_agent + .and_then(|agent| agent.permissions) + .map(|p| match p { + AgentPermissions::ReadOnly => AgentPermissionLevel::ReadOnly, + AgentPermissions::ReadWrite => AgentPermissionLevel::ReadWrite, + AgentPermissions::Full => AgentPermissionLevel::Full, + }); args.agent.apply_cli_defaults( - exec_defaults.and_then(|a| a.provider.as_deref()), - exec_defaults.and_then(|a| a.model.as_deref()), - exec_defaults.and_then(|a| a.permissions), - exec_defaults.and_then(|a| a.output_format), + provider_str.as_deref(), + model_str.as_deref(), + permissions, + None, ); if globals.json { args.agent.output_format = Some(OutputFormat::Json); } let server_target = user_config::exec_server_target(&args.server, &cli_settings)?; - let mcp_servers: Vec = cli_settings - .mcp_servers - .into_iter() - .map(|(name, entry): (String, McpServerEntry)| entry.into_config(name)) - .collect(); + // 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 mcps_iter = exec_agent + .map(|a| &a.mcps) + .filter(|m| !m.is_empty()) + .or_else(|| cli_settings.run_agent_mcps()); + let mcp_servers: Vec = mcps_iter + .map(|mcps| { + mcps.iter() + .map(|(name, entry)| bridge_mcp_entry(entry).into_config(name.clone())) + .collect() + }) + .unwrap_or_default(); if let Some(target) = server_target { tracing::info!(transport = "server", "Agent session starting"); let provider_name = args diff --git a/lib/crates/fabro-cli/src/commands/pr/mod.rs b/lib/crates/fabro-cli/src/commands/pr/mod.rs index 1ac56df86..5f55dc607 100644 --- a/lib/crates/fabro-cli/src/commands/pr/mod.rs +++ b/lib/crates/fabro-cli/src/commands/pr/mod.rs @@ -15,7 +15,8 @@ use crate::shared::github::build_github_app_credentials; pub(crate) async fn dispatch(ns: PrNamespace, globals: &GlobalArgs) -> Result<()> { let ctx = CommandContext::base()?; - let github_app = build_github_app_credentials(ctx.machine_settings().app_id())?; + let github_app = + build_github_app_credentials(ctx.machine_settings().github_app_id_str().as_deref())?; match ns.command { PrCommand::Create(args) => { Box::pin(create::create_command(args, github_app, globals)).await diff --git a/lib/crates/fabro-cli/src/commands/run/create.rs b/lib/crates/fabro-cli/src/commands/run/create.rs index d5e6b3f03..b3278f2f2 100644 --- a/lib/crates/fabro-cli/src/commands/run/create.rs +++ b/lib/crates/fabro-cli/src/commands/run/create.rs @@ -5,6 +5,7 @@ use crate::command_context::CommandContext; use fabro_config::ConfigLayer; use fabro_config::Storage; use fabro_types::RunId; +use fabro_types::settings::v2::SettingsFile; use fabro_util::terminal::Styles; use super::output::{api_diagnostics_to_local, print_preflight_workflow_summary}; @@ -32,11 +33,11 @@ pub(crate) async fn create_run( .ok_or_else(|| anyhow::anyhow!("--workflow is required"))?; let cli_args_config = ConfigLayer::try_from(args)?; let cwd = ctx.cwd().to_path_buf(); - let _settings = cli_args_config + let _settings: SettingsFile = cli_args_config .clone() .combine(ConfigLayer::for_workflow(workflow_path, &cwd)?) .combine(cli_defaults) - .resolve(); + .into(); let run_id = args .run_id .as_deref() diff --git a/lib/crates/fabro-cli/src/main.rs b/lib/crates/fabro-cli/src/main.rs index a151a8eac..aa1a3adb9 100644 --- a/lib/crates/fabro-cli/src/main.rs +++ b/lib/crates/fabro-cli/src/main.rs @@ -130,19 +130,27 @@ async fn main_inner() -> (String, Result<()>) { }), }) = command.as_ref() { - match load_settings_config(args.config.as_deref()) - .and_then(fabro_types::Settings::try_from) - { - Ok(server_settings) => ( - server_settings.log.as_ref().and_then(|l| l.level.clone()), - false, - ), + match load_settings_config(args.config.as_deref()) { + Ok(layer) => { + use fabro_types::settings::v2::SettingsFile; + let server_settings: SettingsFile = layer.into(); + ( + server_settings + .server_logging() + .and_then(|l| l.level.clone()), + false, + ) + } Err(err) => return (command_name, Err(err)), } } else { match user_config::load_settings() { Ok(cli_settings) => ( - cli_settings.log.as_ref().and_then(|l| l.level.clone()), + cli_settings + .cli + .as_ref() + .and_then(|c| c.logging.as_ref()) + .and_then(|l| l.level.clone()), cli_settings.upgrade_check_enabled(), ), Err(err) => return (command_name, Err(err)), @@ -195,7 +203,7 @@ async fn main_inner() -> (String, Result<()>) { Commands::RunsCmd(cmd) => commands::runs::dispatch(cmd, &globals).await?, Commands::Model { command } => commands::model::execute(command, &globals).await?, Commands::Server(ns) => { - commands::server::dispatch(ns.command, &globals).await?; + Box::pin(commands::server::dispatch(ns.command, &globals)).await?; } Commands::Doctor(args) => { let cli_settings = user_config::load_settings()?; diff --git a/lib/crates/fabro-cli/src/server_client.rs b/lib/crates/fabro-cli/src/server_client.rs index 410331208..5d83052fe 100644 --- a/lib/crates/fabro-cli/src/server_client.rs +++ b/lib/crates/fabro-cli/src/server_client.rs @@ -8,7 +8,9 @@ use bytes::Bytes; use fabro_api::types; use fabro_server::bind::Bind; use fabro_store::{EventEnvelope, RunSummary, StageId}; -use fabro_types::{RunBlobId, RunEvent, RunId, Settings}; +use fabro_types::Settings; +use fabro_types::settings::v2::SettingsFile; +use fabro_types::{RunBlobId, RunEvent, RunId}; use fabro_workflow::artifact_snapshot::CapturedArtifactInfo; use futures::StreamExt; use reqwest::header::{CONTENT_LENGTH, CONTENT_TYPE}; @@ -99,7 +101,7 @@ pub(crate) async fn connect_server_target_direct(target: &str) -> Result Result { let target = user_config::resolve_server_target(args, settings)?; diff --git a/lib/crates/fabro-cli/src/user_config.rs b/lib/crates/fabro-cli/src/user_config.rs index 269af4198..98384ef4e 100644 --- a/lib/crates/fabro-cli/src/user_config.rs +++ b/lib/crates/fabro-cli/src/user_config.rs @@ -4,13 +4,13 @@ pub(crate) use fabro_config::user::*; use anyhow::{Result, bail}; use fabro_config::ConfigLayer; -use fabro_types::Settings; +use fabro_types::settings::v2::SettingsFile; use fabro_util::version::FABRO_VERSION; use tracing::debug; use crate::args::ServerTargetArgs; -pub(crate) fn load_settings() -> anyhow::Result { +pub(crate) fn load_settings() -> anyhow::Result { load_settings_with_config_and_storage_dir(None, None) } @@ -30,15 +30,15 @@ pub(crate) fn settings_layer_with_storage_dir( pub(crate) fn load_settings_with_storage_dir( storage_dir: Option<&Path>, -) -> anyhow::Result { - Ok(settings_layer_with_storage_dir(storage_dir)?.resolve()) +) -> anyhow::Result { + Ok(settings_layer_with_storage_dir(storage_dir)?.into()) } pub(crate) fn load_settings_with_config_and_storage_dir( config_path: Option<&Path>, storage_dir: Option<&Path>, -) -> anyhow::Result { - Ok(settings_layer_with_config_and_storage_dir(config_path, storage_dir)?.resolve()) +) -> anyhow::Result { + Ok(settings_layer_with_config_and_storage_dir(config_path, storage_dir)?.into()) } pub(crate) fn apply_storage_dir_override( @@ -68,21 +68,39 @@ pub(crate) enum ServerTarget { UnixSocket(PathBuf), } -fn configured_server_target(settings: &Settings) -> Result> { - settings - .server - .as_ref() - .and_then(|server| server.target.as_deref()) - .map(|value| { - parse_server_target( - value, - settings - .server - .as_ref() - .and_then(|server| server.tls.clone()), - ) - }) - .transpose() +/// Pull the CLI target configuration out of the v2 `[cli.target]` stanza. +/// Returns `(target_string, tls)` where `target_string` is either an +/// http(s) URL or a unix socket path. `tls` is the CLI-side client TLS +/// settings extracted from `[cli.target.http.tls]`. +fn cli_target_from_v2(settings: &SettingsFile) -> Option<(String, Option)> { + use fabro_types::settings::v2::cli::CliTargetLayer; + use fabro_types::settings::v2::interp::InterpString; + + let target = settings.cli.as_ref()?.target.as_ref()?; + match target { + CliTargetLayer::Http { url, tls } => { + let url_str = url.as_ref().map(InterpString::as_source)?; + let tls_settings = tls.as_ref().and_then(|tls| { + Some(ClientTlsSettings { + cert: PathBuf::from(tls.cert.as_ref().map(InterpString::as_source)?), + key: PathBuf::from(tls.key.as_ref().map(InterpString::as_source)?), + ca: PathBuf::from(tls.ca.as_ref().map(InterpString::as_source)?), + }) + }); + Some((url_str, tls_settings)) + } + CliTargetLayer::Unix { path } => path + .as_ref() + .map(InterpString::as_source) + .map(|path_str| (path_str, None)), + } +} + +fn configured_server_target(settings: &SettingsFile) -> Result> { + let Some((value, tls)) = cli_target_from_v2(settings) else { + return Ok(None); + }; + parse_server_target(&value, tls).map(Some) } pub(crate) fn default_server_target() -> ServerTarget { @@ -107,24 +125,18 @@ fn parse_server_target(value: &str, tls: Option) -> Result Result> { args.as_deref() .map(|value| { - parse_server_target( - value, - settings - .server - .as_ref() - .and_then(|server| server.tls.clone()), - ) + parse_server_target(value, cli_target_from_v2(settings).and_then(|(_, tls)| tls)) }) .transpose() } pub(crate) fn resolve_server_target( args: &ServerTargetArgs, - settings: &Settings, + settings: &SettingsFile, ) -> Result { explicit_server_target(args, settings)? .or(configured_server_target(settings)?) @@ -133,7 +145,7 @@ pub(crate) fn resolve_server_target( pub(crate) fn exec_server_target( args: &ServerTargetArgs, - settings: &Settings, + settings: &SettingsFile, ) -> Result> { let target = explicit_server_target(args, settings)?; debug!(?target, "Resolved exec server target"); @@ -186,9 +198,15 @@ mod tests { } } + fn parse_v2(source: &str) -> SettingsFile { + fabro_config::ConfigLayer::parse(source) + .expect("fixture should parse") + .into() + } + #[test] fn exec_has_no_server_target_by_default() { - let settings = Settings::default(); + let settings = SettingsFile::default(); assert_eq!( exec_server_target(&server_target_args(None), &settings).unwrap(), None @@ -197,7 +215,7 @@ mod tests { #[test] fn exec_uses_cli_server_target() { - let settings = Settings::default(); + let settings = SettingsFile::default(); assert_eq!( exec_server_target( &server_target_args(Some("https://cli.example.com")), @@ -213,7 +231,7 @@ mod tests { #[test] fn exec_supports_explicit_unix_socket_target() { - let settings = Settings::default(); + let settings = SettingsFile::default(); assert_eq!( exec_server_target(&server_target_args(Some("/tmp/fabro.sock")), &settings).unwrap(), Some(ServerTarget::UnixSocket(PathBuf::from("/tmp/fabro.sock"))) @@ -222,13 +240,15 @@ mod tests { #[test] fn exec_ignores_configured_server_target_without_cli_override() { - let settings = Settings { - server: Some(ServerSettings { - target: Some("https://config.example.com".to_string()), - tls: None, - }), - ..Settings::default() - }; + let settings = parse_v2( + r#" +_version = 1 + +[cli.target] +type = "http" +url = "https://config.example.com" +"#, + ); assert_eq!( exec_server_target(&server_target_args(None), &settings).unwrap(), None @@ -237,13 +257,15 @@ mod tests { #[test] fn resolve_server_target_uses_configured_server_target() { - let settings = Settings { - server: Some(ServerSettings { - target: Some("https://config.example.com".to_string()), - tls: None, - }), - ..Settings::default() - }; + let settings = parse_v2( + r#" +_version = 1 + +[cli.target] +type = "http" +url = "https://config.example.com" +"#, + ); assert_eq!( resolve_server_target(&server_target_args(None), &settings).unwrap(), ServerTarget::HttpUrl { @@ -255,13 +277,15 @@ mod tests { #[test] fn resolve_server_target_explicit_target_overrides_config_target() { - let settings = Settings { - server: Some(ServerSettings { - target: Some("https://config.example.com".to_string()), - tls: None, - }), - ..Settings::default() - }; + let settings = parse_v2( + r#" +_version = 1 + +[cli.target] +type = "http" +url = "https://config.example.com" +"#, + ); assert_eq!( resolve_server_target( &server_target_args(Some("https://cli.example.com")), @@ -277,7 +301,7 @@ mod tests { #[test] fn resolve_server_target_defaults_to_default_unix_socket_target() { - let settings = Settings::default(); + let settings = SettingsFile::default(); assert_eq!( resolve_server_target(&server_target_args(None), &settings).unwrap(), ServerTarget::UnixSocket(dirs::home_dir().unwrap().join(".fabro/fabro.sock")) @@ -286,13 +310,15 @@ mod tests { #[test] fn explicit_server_target_overrides_config_target() { - let settings = Settings { - server: Some(ServerSettings { - target: Some("https://config.example.com".to_string()), - tls: None, - }), - ..Settings::default() - }; + let settings = parse_v2( + r#" +_version = 1 + +[cli.target] +type = "http" +url = "https://config.example.com" +"#, + ); assert_eq!( resolve_server_target( &server_target_args(Some("https://cli.example.com")), @@ -308,18 +334,25 @@ mod tests { #[test] fn remote_target_uses_tls_from_config() { - let tls = ClientTlsSettings { + let expected_tls = ClientTlsSettings { cert: PathBuf::from("cert.pem"), key: PathBuf::from("key.pem"), ca: PathBuf::from("ca.pem"), }; - let settings = Settings { - server: Some(ServerSettings { - target: None, - tls: Some(tls.clone()), - }), - ..Settings::default() - }; + let settings = parse_v2( + r#" +_version = 1 + +[cli.target] +type = "http" +url = "https://config.example.com" + +[cli.target.tls] +cert = "cert.pem" +key = "key.pem" +ca = "ca.pem" +"#, + ); assert_eq!( exec_server_target( &server_target_args(Some("https://cli.example.com")), @@ -328,14 +361,14 @@ mod tests { .unwrap(), Some(ServerTarget::HttpUrl { api_url: "https://cli.example.com".to_string(), - tls: Some(tls), + tls: Some(expected_tls), }) ); } #[test] fn invalid_server_target_is_rejected() { - let settings = Settings::default(); + let settings = SettingsFile::default(); let error = exec_server_target(&server_target_args(Some("fabro.internal")), &settings).unwrap_err(); assert_eq!( diff --git a/lib/crates/fabro-cli/tests/it/cmd/config.rs b/lib/crates/fabro-cli/tests/it/cmd/config.rs index 9cf7fbe99..fe90025c4 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/config.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/config.rs @@ -1,6 +1,5 @@ use std::path::PathBuf; -use fabro_config::mcp::McpTransport; use fabro_test::{fabro_snapshot, test_context}; use fabro_types::Settings; use fabro_types::settings::v2::SettingsFile; @@ -32,14 +31,8 @@ fn old_config_show_command_is_rejected() { // Helpers // --------------------------------------------------------------------------- -fn parse_settings(stdout: &[u8]) -> Settings { - // The `settings` command now emits a v2 SettingsFile as YAML. Bridge - // it down to the legacy flat shape so the existing test assertions - // (which use flat fields like `cfg.llm`, `cfg.sandbox`, etc.) keep - // working. Stage 6.6 will rewrite these tests against the v2 tree. - let file: SettingsFile = - serde_yaml::from_slice(stdout).expect("stdout should be valid YAML SettingsFile"); - fabro_types::settings::v2::bridge::bridge_to_old(&file) +fn parse_settings(stdout: &[u8]) -> SettingsFile { + serde_yaml::from_slice(stdout).expect("stdout should be valid YAML SettingsFile") } fn server_settings_fixture() -> Settings { @@ -315,30 +308,25 @@ fn settings_local_merges_cli_and_project_defaults() { .clone(); let cfg = parse_settings(&output); - let llm = cfg.llm.as_ref().expect("llm config"); - assert_eq!(llm.model.as_deref(), Some("project-model")); - assert_eq!(llm.provider.as_deref(), Some("openai")); - assert_eq!(cfg.goal.as_deref(), None); - assert_eq!(cfg.fabro.as_ref().map(|f| f.root.as_str()), Some("fabro")); + assert_eq!(cfg.run_model_name_str().as_deref(), Some("project-model")); + assert_eq!(cfg.run_model_provider_str().as_deref(), Some("openai")); + assert_eq!(cfg.run_goal_str().as_deref(), None); + assert_eq!(cfg.project_directory(), Some("fabro")); // v2 R22: run.inputs replaces the inherited map wholesale rather than // merging by key, so the project layer wipes out the CLI layer's inputs. - let vars = cfg.vars.as_ref().expect("vars"); - assert_eq!(vars.get("project_only").map(String::as_str), Some("1")); - assert_eq!(vars.get("shared").map(String::as_str), Some("project")); + let vars = cfg.run_inputs().expect("run.inputs"); + assert_eq!(vars.get("project_only").and_then(|v| v.as_str()), Some("1")); + assert_eq!(vars.get("shared").and_then(|v| v.as_str()), Some("project")); assert!( - vars.get("cli_only").is_none(), + !vars.contains_key("cli_only"), "run.inputs should replace across layers, not merge by key" ); // v2 R71: provider-native maps such as run.sandbox.daytona.labels remain // sticky merge-by-key, so CLI labels persist under the project layer. - let sandbox = cfg.sandbox.as_ref().expect("sandbox"); - let labels = sandbox - .daytona - .as_ref() - .and_then(|d| d.labels.as_ref()) - .expect("daytona labels"); + let sandbox = cfg.run_sandbox().expect("run.sandbox"); + let labels = &sandbox.daytona.as_ref().expect("daytona").labels; assert_eq!(labels.get("cli_only").map(String::as_str), Some("1")); assert_eq!(labels.get("shared").map(String::as_str), Some("cli")); } @@ -358,61 +346,80 @@ fn settings_local_workflow_name_applies_run_overlay_and_deep_merges() { .stdout .clone(); + use fabro_types::settings::v2::run::McpEntryLayer; + let cfg = parse_settings(&output); - let llm = cfg.llm.as_ref().expect("llm config"); - assert_eq!(cfg.goal.as_deref(), Some("demo goal")); - assert_eq!(llm.model.as_deref(), Some("run-model")); - assert_eq!(llm.provider.as_deref(), Some("anthropic")); + assert_eq!(cfg.run_goal_str().as_deref(), Some("demo goal")); + assert_eq!(cfg.run_model_name_str().as_deref(), Some("run-model")); + assert_eq!(cfg.run_model_provider_str().as_deref(), Some("anthropic")); // v2 R22: run.inputs replaces wholesale, so the workflow layer wins // over project and cli. - let vars = cfg.vars.as_ref().expect("vars"); - assert_eq!(vars.get("run_only").map(String::as_str), Some("1")); - assert_eq!(vars.get("shared").map(String::as_str), Some("run")); + let vars = cfg.run_inputs().expect("run.inputs"); + assert_eq!(vars.get("run_only").and_then(|v| v.as_str()), Some("1")); + assert_eq!(vars.get("shared").and_then(|v| v.as_str()), Some("run")); // checkpoint.exclude_globs is a security/policy list: replace by default. + let checkpoint = cfg.run_checkpoint().expect("run.checkpoint"); assert_eq!( - cfg.checkpoint.exclude_globs, + checkpoint.exclude_globs, vec!["run-only".to_string(), "shared".to_string()] ); // Hooks: id-based replacement. The "shared" hook appears in both cli and // workflow layers and resolves to the workflow entry; project and run-only // contribute the other two ids. - assert!(cfg.hooks.len() >= 2); - let shared_hook = cfg - .hooks + let hooks = cfg.run_hooks(); + assert!(hooks.len() >= 2); + let shared_hook = hooks .iter() .find(|hook| hook.name.as_deref() == Some("shared")) .expect("shared hook"); - assert_eq!(shared_hook.command.as_deref(), Some("echo run")); + assert_eq!( + shared_hook + .script + .as_ref() + .map(|s| s.as_source()) + .as_deref(), + Some("echo run") + ); assert!( - cfg.hooks + hooks .iter() .any(|hook| hook.name.as_deref() == Some("run-only")) ); - match &cfg.mcp_servers["shared"].transport { - McpTransport::Stdio { command, .. } => assert_eq!(command, &vec!["echo", "run"]), + let mcps = cfg.run_agent_mcps().expect("run.agent.mcps"); + match mcps.get("shared").expect("shared mcp") { + McpEntryLayer::Stdio { command, .. } => { + let command = command.as_ref().expect("command"); + let parts: Vec = command.iter().map(|c| c.as_source()).collect(); + assert_eq!(parts, vec!["echo".to_string(), "run".to_string()]); + } other => panic!("unexpected MCP transport: {other:?}"), } - assert!(cfg.mcp_servers.contains_key("run_only")); + assert!(mcps.contains_key("run_only")); // run.sandbox.daytona.labels stays sticky merge-by-key per R71. - let sandbox = cfg.sandbox.as_ref().expect("sandbox"); - let labels = sandbox - .daytona - .as_ref() - .and_then(|d| d.labels.as_ref()) - .expect("daytona labels"); + let sandbox = cfg.run_sandbox().expect("run.sandbox"); + let labels = &sandbox.daytona.as_ref().expect("daytona").labels; assert_eq!(labels.get("run_only").map(String::as_str), Some("1")); assert_eq!(labels.get("shared").map(String::as_str), Some("run")); // run.sandbox.env stays sticky merge-by-key per R71. - let env = sandbox.env.as_ref().expect("sandbox env"); - assert_eq!(env.get("CLI_ONLY").map(String::as_str), Some("1")); - assert_eq!(env.get("RUN_ONLY").map(String::as_str), Some("1")); - assert_eq!(env.get("SHARED").map(String::as_str), Some("run")); + let env = &sandbox.env; + assert_eq!( + env.get("CLI_ONLY").map(|v| v.as_source()).as_deref(), + Some("1") + ); + assert_eq!( + env.get("RUN_ONLY").map(|v| v.as_source()).as_deref(), + Some("1") + ); + assert_eq!( + env.get("SHARED").map(|v| v.as_source()).as_deref(), + Some("run") + ); } #[test] @@ -435,17 +442,14 @@ fn settings_local_explicit_workflow_path_uses_workflow_project_layers() { .clone(); let cfg = parse_settings(&output); - assert_eq!(cfg.auto_approve, Some(true)); + assert!(cfg.auto_approve_enabled()); // v2 R30: run.prepare.steps replaces the whole ordered list across layers. // The highest-precedence layer (workflow) wins. assert_eq!( - cfg.setup.as_ref().expect("setup config").commands, + cfg.run_prepare_commands(), vec!["workflow-setup".to_string()] ); - assert_eq!( - cfg.sandbox.as_ref().expect("sandbox config").preserve, - Some(true) - ); + assert_eq!(cfg.run_sandbox().and_then(|sb| sb.preserve), Some(true)); } #[test] @@ -594,8 +598,8 @@ name = "legacy-model" .stderr(predicate::str::contains("Rename it to")); let cfg = parse_settings(&assert.get_output().stdout); - assert_eq!(cfg.verbose, None); - assert_eq!(cfg.llm, None); + assert!(!cfg.verbose_enabled()); + assert!(cfg.run_model().is_none()); } #[test] @@ -624,12 +628,11 @@ shared = "legacy" .stderr(predicate::str::contains("ignoring legacy config file")); let cfg = parse_settings(&assert.get_output().stdout); - let llm = cfg.llm.as_ref().expect("llm config"); - assert_eq!(llm.model.as_deref(), Some("project-model")); + assert_eq!(cfg.run_model_name_str().as_deref(), Some("project-model")); assert_eq!( - cfg.vars - .as_ref() - .and_then(|vars| vars.get("shared").map(String::as_str)), + cfg.run_inputs() + .and_then(|vars| vars.get("shared")) + .and_then(|v| v.as_str()), Some("project") ); } @@ -763,18 +766,20 @@ shared = "cli" mock.assert(); let cfg = parse_settings(&output); - let llm = cfg.llm.as_ref().expect("llm config"); - assert_eq!(llm.model.as_deref(), Some("project-model")); - assert_eq!(llm.provider.as_deref(), Some("openai")); - assert_eq!(cfg.storage_dir, Some(PathBuf::from("/srv/fabro-server"))); - assert_eq!(cfg.verbose, Some(true)); + assert_eq!(cfg.run_model_name_str().as_deref(), Some("project-model")); + assert_eq!(cfg.run_model_provider_str().as_deref(), Some("openai")); + assert_eq!( + cfg.server_storage_root_str().as_deref(), + Some("/srv/fabro-server") + ); + assert!(cfg.verbose_enabled()); // R22: run.inputs replaces wholesale across layers. Project is the // highest-precedence layer that sets inputs, so project's vars win // and server-side vars are discarded rather than merged. - let vars = cfg.vars.as_ref().expect("vars"); - assert_eq!(vars.get("project_only").map(String::as_str), Some("1")); - assert_eq!(vars.get("shared").map(String::as_str), Some("project")); + let vars = cfg.run_inputs().expect("run.inputs"); + assert_eq!(vars.get("project_only").and_then(|v| v.as_str()), Some("1")); + assert_eq!(vars.get("shared").and_then(|v| v.as_str()), Some("project")); assert!( !vars.contains_key("server_only"), "v2 merge matrix replaces run.inputs wholesale; server_only should be dropped" @@ -829,7 +834,10 @@ verbosity = "verbose" cli_mock.assert(); configured_mock.assert_calls(0); let cfg = parse_settings(&output); - assert_eq!(cfg.storage_dir, Some(PathBuf::from("/srv/fabro-server"))); + assert_eq!( + cfg.server_storage_root_str().as_deref(), + Some("/srv/fabro-server") + ); } #[test] diff --git a/lib/crates/fabro-config/src/config.rs b/lib/crates/fabro-config/src/config.rs index fa27d71bd..0ac8b075d 100644 --- a/lib/crates/fabro-config/src/config.rs +++ b/lib/crates/fabro-config/src/config.rs @@ -6,19 +6,14 @@ //! top-level keys with targeted rename hints. `ConfigLayer::combine` walks //! the v2 merge matrix from [`crate::merge`]. //! -//! [`ConfigLayer::resolve`] uses the transitional bridge in -//! [`fabro_types::settings::v2::bridge`] to produce the legacy flat -//! [`Settings`] shape that most consumers still read. New code should prefer -//! [`ConfigLayer::as_v2`] to read v2 fields directly; the bridge and the old -//! flat shape are scheduled for removal once every consumer is migrated. +//! Consumers that need the inner tree call [`ConfigLayer::as_v2`] (borrow) +//! or `.into()` to move out an owned `SettingsFile`. The legacy flat +//! `Settings` shape is no longer reachable from this layer. use std::path::Path; use anyhow::Context; -use fabro_types::Settings; -use fabro_types::settings::v2::{ - SettingsFile, bridge_to_old, parse_settings_file as parse_v2_settings_file, -}; +use fabro_types::settings::v2::{SettingsFile, parse_settings_file as parse_v2_settings_file}; use serde::{Deserialize, Serialize}; use crate::merge::combine_files; @@ -27,9 +22,10 @@ use crate::user; /// A parsed settings file layer. /// -/// Currently a thin newtype around the v2 [`SettingsFile`] parse tree. The -/// newtype exists so fabro-config can attach helper methods and evolve the -/// internal representation without forcing every caller to import v2 types. +/// Thin newtype around the v2 [`SettingsFile`] parse tree. The newtype +/// exists so fabro-config can attach helper methods and evolve the +/// internal representation without forcing every caller to import v2 +/// types. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(transparent)] pub struct ConfigLayer { @@ -48,22 +44,6 @@ impl From for SettingsFile { } } -impl TryFrom for Settings { - type Error = anyhow::Error; - - fn try_from(value: ConfigLayer) -> Result { - Ok(value.resolve()) - } -} - -impl TryFrom<&ConfigLayer> for Settings { - type Error = anyhow::Error; - - fn try_from(value: &ConfigLayer) -> Result { - Ok(value.clone().resolve()) - } -} - impl ConfigLayer { /// Combine two layers using the v2 merge matrix. #[must_use] @@ -130,13 +110,6 @@ impl ConfigLayer { user::load_settings_config(None) } - /// Convert this layer into the legacy flat [`Settings`] shape via the - /// temporary bridge. This path is removed in Stage 6. - #[must_use] - pub fn resolve(self) -> Settings { - bridge_to_old(&self.file) - } - /// Borrow the inner v2 settings file for direct access. #[must_use] pub fn as_v2(&self) -> &SettingsFile { diff --git a/lib/crates/fabro-config/src/server.rs b/lib/crates/fabro-config/src/server.rs index fce1cd9a8..a874bfe1b 100644 --- a/lib/crates/fabro-config/src/server.rs +++ b/lib/crates/fabro-config/src/server.rs @@ -4,11 +4,11 @@ //! etc.) in favor of the v2 parse tree in `fabro_types::settings::v2::server`. //! This module stays alive as a pass-through for crates that still import //! resolved server types via the legacy `fabro_config::server` path; -//! Stage 6 deletes it. +//! Stage 6.4 deletes it. use std::path::PathBuf; -use fabro_types::Settings; +use fabro_types::settings::v2::SettingsFile; pub use fabro_types::settings::server::{ ApiAuthStrategy, ApiSettings, ArtifactStorageBackend, ArtifactStorageSettings, AuthProvider, @@ -18,6 +18,6 @@ pub use fabro_types::settings::server::{ /// Resolve the storage directory: config value > default `~/.fabro`. #[must_use] -pub fn resolve_storage_dir(settings: &Settings) -> PathBuf { +pub fn resolve_storage_dir(settings: &SettingsFile) -> PathBuf { settings.storage_dir() } diff --git a/lib/crates/fabro-server/src/diagnostics.rs b/lib/crates/fabro-server/src/diagnostics.rs index fde5a09a3..1ceb1634d 100644 --- a/lib/crates/fabro-server/src/diagnostics.rs +++ b/lib/crates/fabro-server/src/diagnostics.rs @@ -1,15 +1,13 @@ -use std::path::Path; +use std::path::PathBuf; use std::process::Command; use std::sync::LazyLock; use std::time::Duration; use base64::Engine as _; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; -use fabro_config::server::ApiAuthStrategy; use fabro_llm::client::Client as LlmClient; use fabro_llm::types::{Message, Request}; use fabro_model::{Catalog, Provider}; -use fabro_types::settings::v2::bridge::bridge_to_old; use fabro_util::check_report::{CheckDetail, CheckResult, CheckSection, CheckStatus}; use fabro_util::version::FABRO_VERSION; use regex::Regex; @@ -467,20 +465,24 @@ async fn check_brave_search(state: &AppState) -> CheckResult { } fn check_crypto(state: &AppState) -> CheckResult { + use fabro_types::settings::v2::interp::InterpString; + let settings_file = state .settings .read() .expect("settings lock poisoned") .clone(); - // Temporary bridge while diagnostics is migrated to v2 shapes directly. - let settings = bridge_to_old(&settings_file); - let api = settings.api.clone().unwrap_or_default(); - let has_jwt = api - .authentication_strategies - .contains(&ApiAuthStrategy::Jwt); - let has_mtls = api - .authentication_strategies - .contains(&ApiAuthStrategy::Mtls); + let auth_api = settings_file + .server + .as_ref() + .and_then(|s| s.auth.as_ref()) + .and_then(|a| a.api.as_ref()); + let has_jwt = auth_api + .and_then(|api| api.jwt.as_ref()) + .is_some_and(|jwt| jwt.enabled.unwrap_or(true)); + let has_mtls = auth_api + .and_then(|api| api.mtls.as_ref()) + .is_some_and(|mtls| mtls.enabled.unwrap_or(true)); if !has_jwt && !has_mtls { return CheckResult { @@ -488,7 +490,10 @@ fn check_crypto(state: &AppState) -> CheckResult { status: CheckStatus::Warning, summary: "no authentication configured".to_string(), details: Vec::new(), - remediation: Some("Configure authentication_strategies in [api]".to_string()), + remediation: Some( + "Configure strategies under [server.auth.api.jwt] or [server.auth.api.mtls]" + .to_string(), + ), }; } @@ -496,13 +501,32 @@ fn check_crypto(state: &AppState) -> CheckResult { let mut errors = Vec::new(); if has_mtls { - if let Some(tls) = api.tls { - let read = |path: &Path| -> Result { - let expanded = fabro_config::expand_tilde(path); + use fabro_types::settings::v2::server::ServerListenLayer; + let listen_tls = settings_file + .server + .as_ref() + .and_then(|s| s.listen.as_ref()) + .and_then(|listen| match listen { + ServerListenLayer::Tcp { tls, .. } => tls.as_ref(), + ServerListenLayer::Unix { .. } => None, + }); + if let Some(listen_tls) = listen_tls { + let read = |raw: Option, label: &str| -> Result { + let Some(path_str) = raw else { + return Err(format!("server.listen.tls.{label} is not configured")); + }; + let path = PathBuf::from(&path_str); + let expanded = fabro_config::expand_tilde(&path); std::fs::read_to_string(&expanded) .map_err(|e| format!("{}: {e}", expanded.display())) }; - match (read(&tls.cert), read(&tls.key), read(&tls.ca)) { + let cert = read( + listen_tls.cert.as_ref().map(InterpString::as_source), + "cert", + ); + let key = read(listen_tls.key.as_ref().map(InterpString::as_source), "key"); + let ca = read(listen_tls.ca.as_ref().map(InterpString::as_source), "ca"); + match (cert, key, ca) { (Ok(cert_pem), Ok(key_pem), Ok(ca_pem)) => { if let Err(err) = validate_tls_cert(&cert_pem, chrono::Utc::now().timestamp()) { errors.push(err); @@ -517,7 +541,7 @@ fn check_crypto(state: &AppState) -> CheckResult { _ => errors.push("failed to read mTLS files".to_string()), } } else { - errors.push("mTLS configured but [api.tls] is missing".to_string()); + errors.push("mTLS configured but [server.listen.tls] is missing".to_string()); } } diff --git a/lib/crates/fabro-server/src/run_manifest.rs b/lib/crates/fabro-server/src/run_manifest.rs index 28e341ea8..132a48ab7 100644 --- a/lib/crates/fabro-server/src/run_manifest.rs +++ b/lib/crates/fabro-server/src/run_manifest.rs @@ -17,13 +17,13 @@ use fabro_sandbox::daytona::DaytonaConfig; use fabro_sandbox::{DockerSandboxOptions, Sandbox, SandboxProvider, SandboxSpec}; use fabro_types::RunId; use fabro_types::settings::v2::SettingsFile; -use fabro_types::settings::v2::bridge::bridge_sandbox; use fabro_types::settings::v2::cli::{CliLayer, CliOutputLayer, OutputVerbosity}; use fabro_types::settings::v2::interp::InterpString; use fabro_types::settings::v2::run::{ ApprovalMode, DaytonaDockerfileLayer, RunExecutionLayer, RunLayer, RunMode, RunModelLayer, RunSandboxLayer, }; +use fabro_types::settings::v2::to_runtime::bridge_sandbox; use fabro_util::check_report::{CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus}; use fabro_validate::Severity; use fabro_workflow::error::FabroError; diff --git a/lib/crates/fabro-server/src/serve.rs b/lib/crates/fabro-server/src/serve.rs index 7c343f246..6d690201e 100644 --- a/lib/crates/fabro-server/src/serve.rs +++ b/lib/crates/fabro-server/src/serve.rs @@ -3,7 +3,7 @@ use std::sync::{Arc, RwLock}; use std::time::Duration; use fabro_config::Storage; -use fabro_config::server::{ArtifactStorageBackend, resolve_storage_dir}; +use fabro_config::server::{ApiSettings, resolve_storage_dir}; use fabro_config::user::{active_settings_path, load_settings_config}; use fabro_util::terminal::Styles; use object_store::ObjectStore; @@ -17,9 +17,7 @@ use tracing::{error, info, warn}; use clap::Args; -use fabro_types::Settings; use fabro_types::settings::v2::SettingsFile; -use fabro_types::settings::v2::bridge::bridge_to_old; use crate::bind::{self, Bind, BindRequest}; use crate::github_webhooks::WebhookManager; @@ -86,11 +84,72 @@ fn load_settings(path: Option<&Path>) -> anyhow::Result { Ok(load_settings_config(path)?.into()) } -/// Bridged helper for legacy call sites inside serve.rs that still read flat -/// Settings fields. Callers pass a v2 SettingsFile; this returns the legacy -/// shape via the transitional bridge. -fn bridged(settings: &SettingsFile) -> Settings { - bridge_to_old(settings) +/// Build the legacy `ApiSettings` shape that `resolve_auth_mode_with_lookup` +/// and the TLS branch still expect, extracting the pieces it needs from the +/// v2 tree. Stage 6.6 replaces this with a v2-aware auth resolver and drops +/// the legacy `ApiSettings` type entirely. +fn build_legacy_api_settings(file: &SettingsFile) -> ApiSettings { + use fabro_config::server::{ApiAuthStrategy, TlsSettings}; + use fabro_types::settings::v2::interp::InterpString; + use fabro_types::settings::v2::server::ServerListenLayer; + + let auth_api = file + .server + .as_ref() + .and_then(|s| s.auth.as_ref()) + .and_then(|a| a.api.as_ref()); + + let mut authentication_strategies = Vec::new(); + if auth_api + .and_then(|api| api.jwt.as_ref()) + .and_then(|jwt| jwt.enabled) + .unwrap_or(auth_api.and_then(|api| api.jwt.as_ref()).is_some()) + { + authentication_strategies.push(ApiAuthStrategy::Jwt); + } + if auth_api + .and_then(|api| api.mtls.as_ref()) + .and_then(|mtls| mtls.enabled) + .unwrap_or(auth_api.and_then(|api| api.mtls.as_ref()).is_some()) + { + authentication_strategies.push(ApiAuthStrategy::Mtls); + } + + let base_url = file + .server_api() + .and_then(|api| api.url.as_ref()) + .map_or_else( + || "http://localhost:3000/api/v1".to_string(), + InterpString::as_source, + ); + + // TLS files now live under `server.listen.tls.{cert,key,ca}` in v2. + // Build a legacy TlsSettings from the listen TLS subtree so the + // existing rustls config path keeps working. + let tls = file + .server + .as_ref() + .and_then(|s| s.listen.as_ref()) + .and_then(|listen| match listen { + ServerListenLayer::Tcp { tls, .. } => tls.as_ref(), + ServerListenLayer::Unix { .. } => None, + }) + .and_then(|tls_layer| { + let cert = tls_layer.cert.as_ref().map(InterpString::as_source)?; + let key = tls_layer.key.as_ref().map(InterpString::as_source)?; + let ca = tls_layer.ca.as_ref().map(InterpString::as_source)?; + Some(TlsSettings { + cert: cert.into(), + key: key.into(), + ca: ca.into(), + }) + }); + + ApiSettings { + base_url, + authentication_strategies, + tls, + } } fn resolved_config_path(path: Option<&Path>) -> PathBuf { @@ -183,39 +242,52 @@ fn build_artifact_object_store( settings: &SettingsFile, storage: &Storage, ) -> anyhow::Result<(Arc, String)> { - let bridged_settings = bridged(settings); - let artifact_settings = bridged_settings - .artifact_storage - .clone() - .unwrap_or_default(); + use fabro_types::settings::v2::interp::InterpString; + use fabro_types::settings::v2::server::ObjectStoreProvider; + + let artifacts = settings.server_artifacts(); + let prefix = artifacts + .and_then(|a| a.prefix.as_ref()) + .map_or_else(|| "artifacts".to_string(), InterpString::as_source); if use_in_memory_store() { - return Ok((Arc::new(InMemory::new()), artifact_settings.prefix)); + return Ok((Arc::new(InMemory::new()), prefix)); } - match artifact_settings.backend { - ArtifactStorageBackend::Local => { + let provider = artifacts + .and_then(|a| a.provider) + .unwrap_or(ObjectStoreProvider::Local); + + let s3_cfg = artifacts.and_then(|a| a.s3.as_ref()); + match provider { + ObjectStoreProvider::Local => { std::fs::create_dir_all(storage.artifact_store_dir())?; let object_store = Arc::new(LocalFileSystem::new_with_prefix(storage.root())?); - Ok((object_store, artifact_settings.prefix)) + Ok((object_store, prefix)) } - ArtifactStorageBackend::S3 => { - let bucket = artifact_settings + ObjectStoreProvider::S3 => { + let s3 = s3_cfg.ok_or_else(|| { + anyhow::anyhow!("server.artifacts.s3 is required for provider = 's3'") + })?; + let bucket = s3 .bucket - .ok_or_else(|| anyhow::anyhow!("artifact_storage.bucket is required for s3"))?; - let region = artifact_settings + .as_ref() + .map(InterpString::as_source) + .ok_or_else(|| anyhow::anyhow!("server.artifacts.s3.bucket is required"))?; + let region = s3 .region - .ok_or_else(|| anyhow::anyhow!("artifact_storage.region is required for s3"))?; - + .as_ref() + .map(InterpString::as_source) + .ok_or_else(|| anyhow::anyhow!("server.artifacts.s3.region is required"))?; let mut builder = AmazonS3Builder::from_env() .with_bucket_name(bucket) .with_region(region) - .with_virtual_hosted_style_request(!artifact_settings.path_style.unwrap_or(false)); - if let Some(endpoint) = artifact_settings.endpoint { + .with_virtual_hosted_style_request(!s3.path_style.unwrap_or(false)); + if let Some(endpoint) = s3.endpoint.as_ref().map(InterpString::as_source) { builder = builder.with_endpoint(endpoint); } let object_store = Arc::new(builder.build()?); - Ok((object_store, artifact_settings.prefix)) + Ok((object_store, prefix)) } } } @@ -241,8 +313,7 @@ where let config_path = args.config.clone(); let disk_settings = load_settings(config_path.as_deref())?; let active_config_path = resolved_config_path(config_path.as_deref()); - let data_dir = - storage_dir_override.unwrap_or_else(|| resolve_storage_dir(&bridged(&disk_settings))); + let data_dir = storage_dir_override.unwrap_or_else(|| resolve_storage_dir(&disk_settings)); let storage = Storage::new(&data_dir); let secret_store_path = storage.secrets_path(); let secret_store = SecretStore::load(secret_store_path.clone())?; @@ -284,12 +355,16 @@ where std::fs::create_dir_all(&data_dir)?; let (auth_mode, client_auth, max_concurrent_runs) = { let cfg_file = shared_settings.read().expect("config lock poisoned"); - let cfg = bridged(&cfg_file); - let api = cfg.api.clone().unwrap_or_default(); - let allowed_usernames = cfg - .web + // Build the legacy ApiSettings + allowed_usernames shapes that the + // v1 auth resolver expects. Stage 6.6 replaces this with a direct + // v2-aware resolver. + let api = build_legacy_api_settings(&cfg_file); + let allowed_usernames = cfg_file + .server .as_ref() - .map(|w| w.auth.allowed_usernames.clone()) + .and_then(|s| s.auth.as_ref()) + .and_then(|a| a.web.as_ref()) + .map(|w| w.allowed_usernames.clone()) .unwrap_or_default(); let auth_mode = resolve_auth_mode_with_lookup(&api, &allowed_usernames, |name| { secret_snapshot @@ -300,7 +375,7 @@ where let client_auth = api.tls.as_ref().map(|_| client_auth_from_mode(&auth_mode)); let max_concurrent_runs = args .max_concurrent_runs - .or(cfg.max_concurrent_runs) + .or_else(|| cfg_file.max_concurrent_runs()) .unwrap_or(5); (auth_mode, client_auth, max_concurrent_runs) }; @@ -352,12 +427,13 @@ where // Optionally start webhook listener let webhook_app_id = { + use fabro_types::settings::v2::InterpString; let cfg_file = shared_settings.read().expect("config lock poisoned"); - let cfg = bridged(&cfg_file); - cfg.git - .as_ref() - .and_then(|g| g.webhooks.as_ref().and(g.app_id.as_ref())) - .cloned() + cfg_file + .server_integrations_github() + .filter(|github| github.webhooks.is_some()) + .and_then(|github| github.app_id.as_ref()) + .map(InterpString::as_source) }; let webhook_manager = match webhook_app_id { Some(app_id) => { @@ -448,8 +524,7 @@ where // Branch: TLS, plain TCP, or Unix socket let tls_settings = { let cfg_file = shared_settings.read().expect("config lock poisoned"); - let cfg = bridged(&cfg_file); - cfg.api.as_ref().and_then(|a| a.tls.clone()) + build_legacy_api_settings(&cfg_file).tls.clone() }; let bound_listener = bind_listener(&bind_request).await?; diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 3ab01fccb..9cad3a01e 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -33,7 +33,6 @@ use fabro_model::{BilledModelUsage, BilledTokenCounts}; use fabro_store::{ ArtifactStore, Database, EventEnvelope, EventPayload, PendingInterviewRecord, StageId, }; -use fabro_types::settings::v2::bridge::bridge_to_old; use fabro_types::settings::v2::{InterpString, SettingsFile}; use fabro_types::{ EventBody, InterviewQuestionRecord, InterviewQuestionType, RunBlobId, RunClientProvenance, @@ -1065,24 +1064,21 @@ async fn get_server_settings( State(state): State>, ) -> Response { let settings = state.settings.read().unwrap().clone(); - let response = match api_server_settings(&settings) { - Ok(response) => response, + // Stage 6.6 TODO: replace this with an explicit allow-list DTO that + // reads directly from the v2 tree and redacts env-sourced values via + // `InterpString` provenance. For now we serialize the full v2 + // `SettingsFile` as JSON so the web UI still has a response body -- + // the legacy `ServerSettings` OpenAPI schema will be rewritten in + // 6.6 alongside the fabro-web DTO updates. + let mut value = match serde_json::to_value(&settings) { + Ok(value) => value, Err(err) => { return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()) .into_response(); } }; - (StatusCode::OK, Json(response)).into_response() -} - -fn api_server_settings(settings: &SettingsFile) -> anyhow::Result { - // Temporary shim: reuse the legacy flat Settings shape via the v2 bridge - // so the existing `/api/v1/settings` DTO keeps working. Stage 6.6 replaces - // this with an explicit allow-list DTO built directly from the v2 tree. - let legacy = bridge_to_old(settings); - let mut value = serde_json::to_value(&legacy)?; strip_nulls(&mut value); - serde_json::from_value(value).map_err(Into::into) + (StatusCode::OK, Json(value)).into_response() } fn strip_nulls(value: &mut serde_json::Value) { diff --git a/lib/crates/fabro-server/src/web_auth.rs b/lib/crates/fabro-server/src/web_auth.rs index c503cd9dd..909f121c6 100644 --- a/lib/crates/fabro-server/src/web_auth.rs +++ b/lib/crates/fabro-server/src/web_auth.rs @@ -6,10 +6,7 @@ use axum::http::{HeaderMap, HeaderValue, StatusCode, header}; use axum::response::{IntoResponse, Redirect, Response}; use axum::{Json, Router, routing::get, routing::post}; use cookie::{Cookie, CookieJar, Expiration, Key, SameSite, time::Duration}; -use fabro_types::Settings; -use fabro_types::settings::v2::SettingsFile; -use fabro_types::settings::v2::bridge::bridge_to_old; -use fabro_types::settings::{ApiAuthStrategy, GitProvider, GitSettings}; +use fabro_types::settings::v2::{InterpString, SettingsFile}; use serde::{Deserialize, Serialize}; use serde_json::json; use tracing::{debug, error, info, warn}; @@ -149,42 +146,43 @@ fn json_response(status: StatusCode, body: serde_json::Value) -> Response { (status, Json(body)).into_response() } -fn features_json(settings: &Settings) -> serde_json::Value { - let features = settings.features.clone().unwrap_or_default(); +fn features_json(settings: &SettingsFile) -> serde_json::Value { + let features = settings.features.as_ref(); + let session_sandboxes = features.and_then(|f| f.session_sandboxes).unwrap_or(false); + // Retros in v2 live under `run.execution.retros` (positive form) rather + // than the top-level features stanza. + let retros = settings + .run_execution() + .and_then(|e| e.retros) + .unwrap_or(false); json!({ - "session_sandboxes": features.session_sandboxes, - "retros": features.retros, + "session_sandboxes": session_sandboxes, + "retros": retros, }) } -/// Temporary helper used during the v2 consumer migration. Bridges a -/// `SettingsFile` down to the legacy flat `Settings` shape so web_auth's -/// oauth/git flows can keep reading flat fields until they're migrated -/// directly (Stage 6.6 alongside the `/api/v1/settings` DTO rewrite). -fn bridged(settings_file: &SettingsFile) -> Settings { - bridge_to_old(settings_file) -} - async fn login_github(State(state): State>) -> Response { - let settings = bridged( - &state - .settings - .read() - .expect("settings lock poisoned") - .clone(), - ); - let Some(client_id) = settings.client_id().map(str::to_string) else { + let settings = state + .settings + .read() + .expect("settings lock poisoned") + .clone(); + let Some(client_id) = settings.github_client_id_str() else { warn!("OAuth login failed: client_id not configured"); return json_response( StatusCode::CONFLICT, json!({"error": "GitHub App client_id is not configured"}), ); }; - let Some(web_url) = settings.web.as_ref().map(|web| web.url.clone()) else { - warn!("OAuth login failed: web.url not configured"); + let Some(web_url) = settings + .server_web() + .and_then(|w| w.url.as_ref()) + .map(InterpString::as_source) + else { + warn!("OAuth login failed: server.web.url not configured"); return json_response( StatusCode::CONFLICT, - json!({"error": "web.url is not configured"}), + json!({"error": "server.web.url is not configured"}), ); }; @@ -228,13 +226,11 @@ async fn callback_github( json!({"error": "SESSION_SECRET is not configured"}), ); }; - let settings = bridged( - &state - .settings - .read() - .expect("settings lock poisoned") - .clone(), - ); + let settings = state + .settings + .read() + .expect("settings lock poisoned") + .clone(); let cookie_jar = parse_cookie_header(&headers); let stored_state = cookie_jar.get(OAUTH_STATE_COOKIE_NAME).map(Cookie::value); if stored_state != Some(params.state.as_str()) { @@ -242,7 +238,7 @@ async fn callback_github( return Redirect::to("/login").into_response(); } - let Some(client_id) = settings.client_id().map(str::to_string) else { + let Some(client_id) = settings.github_client_id_str() else { error!("OAuth callback failed: client_id not configured"); return json_response( StatusCode::CONFLICT, @@ -256,10 +252,13 @@ async fn callback_github( json!({"error": "GITHUB_APP_CLIENT_SECRET is not configured"}), ); }; - let web_url = settings.web.as_ref().map_or_else( - || "http://localhost:3000".to_string(), - |web| web.url.clone(), - ); + let web_url = settings + .server_web() + .and_then(|w| w.url.as_ref()) + .map_or_else( + || "http://localhost:3000".to_string(), + InterpString::as_source, + ); let http = reqwest::Client::new(); let token = match http @@ -358,9 +357,11 @@ async fn callback_github( }; let allowed_usernames = settings - .web + .server .as_ref() - .map(|web| web.auth.allowed_usernames.clone()) + .and_then(|s| s.auth.as_ref()) + .and_then(|a| a.web.as_ref()) + .map(|w| w.allowed_usernames.clone()) .unwrap_or_default(); if !allowed_usernames.is_empty() && !allowed_usernames.iter().any(|user| user == &profile.login) { @@ -445,13 +446,11 @@ async fn auth_me(State(state): State>, headers: HeaderMap) -> Resp return json_response(StatusCode::UNAUTHORIZED, json!({"error": "Unauthorized"})); }; - let settings = bridged( - &state - .settings - .read() - .expect("settings lock poisoned") - .clone(), - ); + let settings = state + .settings + .read() + .expect("settings lock poisoned") + .clone(); let demo_mode = parse_cookie_header(&headers) .get("fabro-demo") .is_some_and(|cookie| cookie.value() == "1"); @@ -471,17 +470,12 @@ async fn auth_me(State(state): State>, headers: HeaderMap) -> Resp } async fn setup_status(State(state): State>) -> Response { - let settings = bridged( - &state - .settings - .read() - .expect("settings lock poisoned") - .clone(), - ); - let configured = settings - .git - .as_ref() - .is_some_and(|git| git.client_id.is_some()); + let settings = state + .settings + .read() + .expect("settings lock poisoned") + .clone(); + let configured = settings.github_client_id_str().is_some(); Json(SetupStatusResponse { configured }).into_response() } @@ -565,26 +559,10 @@ async fn setup_register( let settings_path = state.config_path.clone(); - // Bridge the v2 in-memory state down to the legacy flat shape so the - // existing register flow can continue to mutate it and write legacy - // TOML. Stage 6.6 rewrites this to produce v2 TOML directly. - let settings_file = state - .settings - .read() - .expect("settings lock poisoned") - .clone(); - let mut settings = bridged(&settings_file); - let mut git = settings.git.clone().unwrap_or_default(); - git.provider = GitProvider::Github; - git.app_id = Some(data.id.to_string()); - git.client_id = Some(data.client_id.clone()); - git.slug = Some(data.slug.clone()); - settings.git = Some(git.clone()); - if let Some(ref origin) = origin { - let web = settings.web.get_or_insert_default(); - web.url.clone_from(origin); - } - + // Build a v2 settings_path edit in place. This used to bridge back to + // the legacy flat shape and emit v1 TOML; the v2 parser hard-rejects + // the v1 top-level keys, so this was already broken. Write v2 TOML + // using `merge_settings_keys` against the raw TOML document. if let Some(parent) = settings_path.parent() { let _ = std::fs::create_dir_all(parent); } @@ -603,7 +581,7 @@ async fn setup_register( } } }; - if let Err(err) = merge_settings_keys(&mut doc, &settings, &git, origin.as_deref()) { + if let Err(err) = merge_settings_keys(&mut doc, &data, origin.as_deref()) { error!(error = %err, "Setup register failed: could not merge settings"); return json_response( StatusCode::INTERNAL_SERVER_ERROR, @@ -644,13 +622,14 @@ async fn setup_register( } } - // Stage 6.6 TODO: re-parse the freshly-written `settings_path` via - // `ConfigLayer::load` and swap it into `state.settings`. For now, leave - // the in-memory state unchanged -- subsequent server restarts will - // re-read the file. The `settings` binding above mutates a bridged - // copy that only feeds the TOML merge output; dropping it here is - // intentional. - drop(settings); + // Re-parse the freshly-written settings file and swap it into the + // in-memory state. Stage 6.6 may split this differently when the web + // setup flow is reworked, but for now a round-trip through + // `ConfigLayer::load` keeps the live state consistent with disk. + if let Ok(reloaded) = fabro_config::ConfigLayer::load(&settings_path) { + let mut shared = state.settings.write().expect("settings lock poisoned"); + *shared = reloaded.into(); + } info!(slug = %data.slug, app_id = %data.id, "GitHub App registered successfully"); Json(json!({"ok": true})).into_response() @@ -671,152 +650,88 @@ fn ensure_table<'a>(table: &'a mut toml::Table, key: &str) -> anyhow::Result<&'a fn merge_settings_keys( doc: &mut toml::Value, - settings: &Settings, - git: &GitSettings, + data: &GitHubManifestConversion, origin: Option<&str>, ) -> anyhow::Result<()> { - let web_url = origin - .map(str::to_string) - .or_else(|| settings.web.as_ref().map(|web| web.url.clone())) - .unwrap_or_else(|| "http://localhost:3000".to_string()); - let allowed = settings - .web - .as_ref() - .map(|web| web.auth.allowed_usernames.clone()) - .unwrap_or_default(); - let api = settings.api.clone().unwrap_or_default(); + let web_url = origin.map_or_else(|| "http://localhost:3000".to_string(), str::to_string); let root = root_table_mut(doc)?; - let web = ensure_table(root, "web")?; - web.insert("url".to_string(), toml::Value::String(web_url.clone())); - let auth = ensure_table(web, "auth")?; - auth.insert( - "provider".to_string(), - toml::Value::String("github".to_string()), - ); - auth.insert( - "allowed_usernames".to_string(), - toml::Value::Array(allowed.into_iter().map(toml::Value::String).collect()), - ); + // Make sure the freshly-written file is a valid v2 file. + root.insert("_version".to_string(), toml::Value::Integer(1)); - let base_url = format!("{web_url}/api/v1"); - let api_table = ensure_table(root, "api")?; - api_table.insert("base_url".to_string(), toml::Value::String(base_url)); - api_table.insert( - "authentication_strategies".to_string(), - toml::Value::Array( - api.authentication_strategies - .iter() - .map(|strategy| match strategy { - ApiAuthStrategy::Jwt => "jwt", - ApiAuthStrategy::Mtls => "mtls", - }) - .map(|value| toml::Value::String(value.to_string())) - .collect(), - ), - ); + let server = ensure_table(root, "server")?; + let web = ensure_table(server, "web")?; + web.insert("enabled".to_string(), toml::Value::Boolean(true)); + web.insert("url".to_string(), toml::Value::String(web_url)); - let git_table = ensure_table(root, "git")?; - git_table.insert( - "provider".to_string(), - toml::Value::String("github".to_string()), - ); - git_table.insert( + let auth = ensure_table(server, "auth")?; + let auth_web = ensure_table(auth, "web")?; + let _ = auth_web; + let auth_api = ensure_table(auth, "api")?; + let _jwt = ensure_table(auth_api, "jwt")?; + + let integrations = ensure_table(server, "integrations")?; + let github = ensure_table(integrations, "github")?; + github.insert( "app_id".to_string(), - toml::Value::String(git.app_id.clone().unwrap_or_default()), + toml::Value::String(data.id.to_string()), ); - git_table.insert( + github.insert( "client_id".to_string(), - toml::Value::String(git.client_id.clone().unwrap_or_default()), - ); - git_table.insert( - "slug".to_string(), - toml::Value::String(git.slug.clone().unwrap_or_default()), + toml::Value::String(data.client_id.clone()), ); + github.insert("slug".to_string(), toml::Value::String(data.slug.clone())); Ok(()) } #[cfg(test)] mod tests { - use super::merge_settings_keys; - use fabro_types::Settings; + use super::{GitHubManifestConversion, merge_settings_keys}; - #[test] - fn merge_settings_keys_preserves_unrelated_git_nested_keys() { - let mut doc: toml::Value = toml::from_str( - r#" -[git] -provider = "github" - -[git.author] -name = "fabro" -email = "fabro@example.com" - -[git.webhooks] -strategy = "tailscale_funnel" -"#, - ) - .unwrap(); - - let mut settings = Settings::default(); - settings.web.get_or_insert_default().auth.allowed_usernames = vec!["alice".to_string()]; - settings.git.get_or_insert_default().provider = fabro_config::server::GitProvider::Github; - settings.git.get_or_insert_default().app_id = Some("123".to_string()); - settings.git.get_or_insert_default().client_id = Some("abc".to_string()); - settings.git.get_or_insert_default().slug = Some("fabro".to_string()); - - merge_settings_keys(&mut doc, &settings, settings.git.as_ref().unwrap(), None).unwrap(); - - let git = doc.get("git").and_then(toml::Value::as_table).unwrap(); - assert_eq!(git.get("app_id").and_then(toml::Value::as_str), Some("123")); - let author = git.get("author").and_then(toml::Value::as_table).unwrap(); - assert_eq!( - author.get("name").and_then(toml::Value::as_str), - Some("fabro") - ); - let webhooks = git.get("webhooks").and_then(toml::Value::as_table).unwrap(); - assert_eq!( - webhooks.get("strategy").and_then(toml::Value::as_str), - Some("tailscale_funnel") - ); + fn sample_conversion() -> GitHubManifestConversion { + GitHubManifestConversion { + id: 123, + slug: "fabro".to_string(), + client_id: "abc".to_string(), + client_secret: "shh".to_string(), + pem: String::new(), + webhook_secret: None, + } } #[test] - fn merge_settings_keys_preserves_unrelated_top_level_sections() { - let mut doc: toml::Value = toml::from_str( - r#" -[exec] -provider = "anthropic" - -[server] -target = "https://fabro.example.com/api/v1" -"#, - ) - .unwrap(); - - let mut settings = Settings::default(); - settings.web.get_or_insert_default().auth.allowed_usernames = vec!["alice".to_string()]; - settings.git.get_or_insert_default().provider = fabro_config::server::GitProvider::Github; - settings.git.get_or_insert_default().app_id = Some("123".to_string()); - settings.git.get_or_insert_default().client_id = Some("abc".to_string()); - settings.git.get_or_insert_default().slug = Some("fabro".to_string()); - - merge_settings_keys(&mut doc, &settings, settings.git.as_ref().unwrap(), None).unwrap(); + fn merge_settings_keys_writes_v2_server_integrations_github() { + let mut doc: toml::Value = + toml::from_str("_version = 1\n").expect("empty v2 doc should parse"); + merge_settings_keys(&mut doc, &sample_conversion(), Some("https://example.test")).unwrap(); + let github = doc + .get("server") + .and_then(toml::Value::as_table) + .and_then(|s| s.get("integrations")) + .and_then(toml::Value::as_table) + .and_then(|i| i.get("github")) + .and_then(toml::Value::as_table) + .expect("server.integrations.github should exist"); assert_eq!( - doc.get("exec") - .and_then(toml::Value::as_table) - .and_then(|exec| exec.get("provider")) - .and_then(toml::Value::as_str), - Some("anthropic") + github.get("app_id").and_then(toml::Value::as_str), + Some("123") ); assert_eq!( - doc.get("server") - .and_then(toml::Value::as_table) - .and_then(|server| server.get("target")) - .and_then(toml::Value::as_str), - Some("https://fabro.example.com/api/v1") + github.get("slug").and_then(toml::Value::as_str), + Some("fabro") + ); + + let web = doc + .get("server") + .and_then(toml::Value::as_table) + .and_then(|s| s.get("web")) + .and_then(toml::Value::as_table) + .expect("server.web should exist"); + assert_eq!( + web.get("url").and_then(toml::Value::as_str), + Some("https://example.test") ); } } diff --git a/lib/crates/fabro-server/tests/it/api/settings.rs b/lib/crates/fabro-server/tests/it/api/settings.rs index bad6937c4..51c61b467 100644 --- a/lib/crates/fabro-server/tests/it/api/settings.rs +++ b/lib/crates/fabro-server/tests/it/api/settings.rs @@ -43,8 +43,10 @@ server_only = "1" assert_eq!(response.status(), StatusCode::OK); let body = body_json(response.into_body()).await; - assert_eq!(body["storage_dir"], "/srv/fabro"); - assert_eq!(body["max_concurrent_runs"], 9); - assert_eq!(body["verbose"], true); - assert_eq!(body["vars"]["server_only"], "1"); + // `/api/v1/settings` emits the v2 SettingsFile shape directly now. + // Stage 6.6 will replace this with an explicit allow-list DTO. + assert_eq!(body["server"]["storage"]["root"], "/srv/fabro"); + assert_eq!(body["server"]["scheduler"]["max_concurrent_runs"], 9); + assert_eq!(body["cli"]["output"]["verbosity"], "verbose"); + assert_eq!(body["run"]["inputs"]["server_only"], "1"); } diff --git a/lib/crates/fabro-types/src/settings/mod.rs b/lib/crates/fabro-types/src/settings/mod.rs index 3545ab41a..949f53153 100644 --- a/lib/crates/fabro-types/src/settings/mod.rs +++ b/lib/crates/fabro-types/src/settings/mod.rs @@ -6,16 +6,17 @@ //! there. //! //! The flat [`Settings`] type and its submodules (`hook`, `mcp`, `project`, -//! `run`, `sandbox`, `server`, `user`) are the **resolved** shape that -//! current consumers still read. `fabro_config::ConfigLayer::resolve` walks -//! the v2 tree through [`v2::bridge::bridge_to_old`] to produce this flat -//! shape, so every consumer that touches `settings.llm`, `settings.vars`, -//! `settings.sandbox`, etc. keeps working. +//! `run`, `sandbox`, `server`, `user`) are the **runtime shapes** that +//! downstream crates (fabro-workflow, fabro-sandbox, fabro-mcp, +//! fabro-hooks) still consume at execution time. Stage 6.1 deleted the +//! `Settings` parse path; Stage 6.2 deleted the `bridge_to_old` +//! catch-all converter. Narrow v2→runtime helpers live in +//! [`v2::to_runtime`] and build these runtime shapes from specific v2 +//! subtrees on demand. //! -//! Full deletion of the flat shape (including the bridge) is scheduled for -//! a follow-up PR that migrates every consumer call site to read from -//! [`v2::SettingsFile`] directly. This module deliberately stays as a -//! transitional seam until then. +//! Stage 6.3 deletes these runtime types entirely in favor of v2-native +//! replacements, at which point this module and the helper modules +//! around it go away too. use std::collections::HashMap; use std::path::PathBuf; diff --git a/lib/crates/fabro-types/src/settings/v2/bridge.rs b/lib/crates/fabro-types/src/settings/v2/bridge.rs deleted file mode 100644 index d40f106b0..000000000 --- a/lib/crates/fabro-types/src/settings/v2/bridge.rs +++ /dev/null @@ -1,836 +0,0 @@ -//! Temporary bridge from the v2 parse tree to the old flat [`Settings`] shape. -//! -//! This module exists only to keep consumers compiling while Stages 3 and 4 -//! migrate parsers and consumers across the workspace. Field mappings are -//! best-effort and deliberately lossy for anything the old shape does not -//! have a slot for. **This entire module is deleted in Stage 6.** -//! -//! Env var interpolation is not performed here; `${env.NAME}` tokens are -//! emitted verbatim via [`InterpString::as_source`]. The post-layering -//! interpolation pass runs in `fabro-config` during Stage 3, after layering -//! is already complete. - -use std::collections::HashMap; - -use super::cli::{CliExecLayer, CliLayer, CliOutputLayer, CliTargetLayer, OutputVerbosity}; -use super::interp::InterpString; -use super::project::ProjectLayer; -use super::run::{ - AgentPermissions as V2AgentPermissions, ApprovalMode, HookEntry as V2HookEntry, - HookEvent as V2HookEvent, McpEntryLayer, MergeStrategy as V2MergeStrategy, ModelRefOrSplice, - RunLayer, RunMode, WorktreeMode as V2WorktreeMode, -}; -use super::server::{ - ObjectStoreProvider, ServerArtifactsLayer, ServerIntegrationsLayer, ServerLayer, - ServerSchedulerLayer, ServerStorageLayer, ServerWebLayer, -}; -use super::tree::SettingsFile; -use super::workflow::WorkflowLayer; -use crate::settings::Settings; -use crate::settings::hook::{ - HookDefinition, HookEvent as OldHookEvent, HookType as OldHookType, TlsMode as OldTlsMode, -}; -use crate::settings::mcp::{McpServerEntry, McpTransport}; -use crate::settings::project::ProjectSettings; -use crate::settings::run::{ - ArtifactsSettings, CheckpointSettings, LlmSettings, MergeStrategy as OldMergeStrategy, - PullRequestSettings, SetupSettings, -}; -use crate::settings::sandbox::{ - DaytonaNetwork, DaytonaSettings, DaytonaSnapshotSettings, DockerfileSource, - LocalSandboxSettings, SandboxSettings, WorktreeMode as OldWorktreeMode, -}; -use crate::settings::server::{ - ApiAuthStrategy, ApiSettings, ArtifactStorageBackend, ArtifactStorageSettings, AuthProvider, - AuthSettings, FeaturesSettings, GitAuthorSettings, GitProvider, GitSettings, LogSettings, - SlackSettings, WebSettings, -}; -use crate::settings::user::{ - ExecSettings, OutputFormat, PermissionLevel, ServerSettings as UserServer, -}; - -/// Convert a v2 `SettingsFile` into the legacy flat [`Settings`] shape. -/// -/// This is a temporary seam. All v2 fields that do not map cleanly are -/// dropped; callers that need those should read the v2 tree directly. -#[must_use] -pub fn bridge_to_old(file: &SettingsFile) -> Settings { - let mut out = Settings { - version: file.version, - ..Settings::default() - }; - - if let Some(project) = &file.project { - bridge_project(project, &mut out); - } - if let Some(workflow) = &file.workflow { - bridge_workflow(workflow, &mut out); - } - if let Some(run) = &file.run { - bridge_run(run, &mut out); - } - if let Some(cli) = &file.cli { - bridge_cli(cli, &mut out); - } - if let Some(server) = &file.server { - bridge_server(server, &mut out); - } - if let Some(features) = &file.features { - out.features = Some(FeaturesSettings { - session_sandboxes: features.session_sandboxes.unwrap_or(false), - retros: false, // v2 moves retros to run.execution.retros - }); - } - - out -} - -fn bridge_project(project: &ProjectLayer, out: &mut Settings) { - if let Some(directory) = &project.directory { - out.fabro = Some(ProjectSettings { - root: directory.clone(), - }); - } - if !project.metadata.is_empty() { - merge_labels(&mut out.labels, &project.metadata); - } -} - -fn bridge_workflow(workflow: &WorkflowLayer, out: &mut Settings) { - if let Some(graph) = &workflow.graph { - out.graph = Some(graph.clone()); - } - if !workflow.metadata.is_empty() { - merge_labels(&mut out.labels, &workflow.metadata); - } -} - -fn bridge_run(run: &RunLayer, out: &mut Settings) { - if let Some(goal) = &run.goal { - out.goal = Some(interp_to_string(goal)); - } - if let Some(wd) = &run.working_dir { - out.work_dir = Some(interp_to_string(wd)); - } - if !run.metadata.is_empty() { - merge_labels(&mut out.labels, &run.metadata); - } - - if let Some(inputs) = &run.inputs { - let mut vars: HashMap = HashMap::new(); - for (k, v) in inputs { - vars.insert(k.clone(), toml_value_to_string(v)); - } - out.vars = Some(vars); - } - - if let Some(model) = &run.model { - let mut llm = LlmSettings::default(); - if let Some(p) = &model.provider { - llm.provider = Some(interp_to_string(p)); - } - if let Some(n) = &model.name { - llm.model = Some(interp_to_string(n)); - } - if !model.fallbacks.is_empty() { - let mut fallbacks_by_provider: HashMap> = HashMap::new(); - for entry in &model.fallbacks { - match entry { - ModelRefOrSplice::ModelRef(model_ref) => { - let s = model_ref.to_string(); - fallbacks_by_provider - .entry(String::new()) - .or_default() - .push(s); - } - ModelRefOrSplice::Splice => {} - } - } - if !fallbacks_by_provider.is_empty() { - llm.fallbacks = Some(fallbacks_by_provider); - } - } - out.llm = Some(llm); - } - - if let Some(prepare) = &run.prepare { - let commands: Vec = prepare - .steps - .iter() - .filter_map(|step| { - if let Some(script) = &step.script { - Some(interp_to_string(script)) - } else { - step.command.as_ref().map(|argv| { - argv.iter() - .map(interp_to_string) - .collect::>() - .join(" ") - }) - } - }) - .collect(); - let timeout_ms = prepare - .timeout - .map(|d| u64::try_from(d.as_std().as_millis()).unwrap_or(u64::MAX)); - out.setup = Some(SetupSettings { - commands, - timeout_ms, - }); - } - - if let Some(execution) = &run.execution { - out.dry_run = match execution.mode { - Some(RunMode::DryRun) => Some(true), - Some(RunMode::Normal) => Some(false), - None => None, - }; - out.auto_approve = match execution.approval { - Some(ApprovalMode::Auto) => Some(true), - Some(ApprovalMode::Prompt) => Some(false), - None => None, - }; - out.no_retro = execution.retros.map(|r| !r); - } - - if let Some(cp) = &run.checkpoint { - out.checkpoint = CheckpointSettings { - exclude_globs: cp.exclude_globs.clone(), - }; - } - - if let Some(sb) = &run.sandbox { - out.sandbox = Some(bridge_sandbox(sb)); - } - - if let Some(agent) = &run.agent { - let map = bridge_mcps(&agent.mcps); - if !map.is_empty() { - out.mcp_servers = map; - } - } - - if !run.hooks.is_empty() { - out.hooks = run.hooks.iter().map(bridge_hook).collect(); - } - - if let Some(pr) = &run.pull_request { - out.pull_request = Some(PullRequestSettings { - enabled: pr.enabled.unwrap_or(false), - draft: pr.draft.unwrap_or(true), - auto_merge: pr.auto_merge.unwrap_or(false), - merge_strategy: pr - .merge_strategy - .map(bridge_merge_strategy) - .unwrap_or_default(), - }); - } - - if let Some(art) = &run.artifacts { - out.artifacts = Some(ArtifactsSettings { - include: art.include.clone(), - }); - } - - // Slack notifications feed the old flat SlackSettings.default_channel. - for route in run.notifications.values() { - if let Some(slack) = &route.slack { - if let Some(channel) = &slack.channel { - out.slack - .get_or_insert_with(SlackSettings::default) - .default_channel = Some(interp_to_string(channel)); - break; - } - } - } - - // Git author from run.git - if let Some(git) = &run.git { - if let Some(author) = &git.author { - let git_settings = out.git.get_or_insert_with(GitSettings::default); - git_settings.author = GitAuthorSettings { - name: author.name.as_ref().map(interp_to_string), - email: author.email.as_ref().map(interp_to_string), - }; - } - } -} - -pub fn bridge_sandbox(sb: &super::run::RunSandboxLayer) -> SandboxSettings { - SandboxSettings { - provider: sb.provider.clone(), - preserve: sb.preserve, - devcontainer: sb.devcontainer, - local: sb.local.as_ref().map(|local| LocalSandboxSettings { - worktree_mode: local - .worktree_mode - .map(bridge_worktree_mode) - .unwrap_or_default(), - }), - daytona: sb.daytona.as_ref().map(|d| DaytonaSettings { - auto_stop_interval: d.auto_stop_interval, - labels: if d.labels.is_empty() { - None - } else { - Some(d.labels.clone()) - }, - snapshot: d.snapshot.as_ref().and_then(|s| { - s.name.as_ref().map(|name| DaytonaSnapshotSettings { - name: name.clone(), - cpu: s.cpu, - memory: s.memory.map(|sz| size_to_gb_i32(sz.as_bytes())), - disk: s.disk.map(|sz| size_to_gb_i32(sz.as_bytes())), - dockerfile: s.dockerfile.as_ref().map(|d| match d { - super::run::DaytonaDockerfileLayer::Inline(text) => { - DockerfileSource::Inline(text.clone()) - } - super::run::DaytonaDockerfileLayer::Path { path } => { - DockerfileSource::Path { path: path.clone() } - } - }), - }) - }), - network: d.network.as_ref().map(|n| match n { - super::run::DaytonaNetworkLayer::Block => DaytonaNetwork::Block, - super::run::DaytonaNetworkLayer::AllowAll => DaytonaNetwork::AllowAll, - super::run::DaytonaNetworkLayer::AllowList { allow_list } => { - DaytonaNetwork::AllowList(allow_list.clone()) - } - }), - skip_clone: d.skip_clone.unwrap_or(false), - }), - env: if sb.env.is_empty() { - None - } else { - Some( - sb.env - .iter() - .map(|(k, v)| (k.clone(), interp_to_string(v))) - .collect(), - ) - }, - } -} - -pub fn bridge_worktree_mode(m: V2WorktreeMode) -> OldWorktreeMode { - match m { - V2WorktreeMode::Always => OldWorktreeMode::Always, - V2WorktreeMode::Clean => OldWorktreeMode::Clean, - V2WorktreeMode::Dirty => OldWorktreeMode::Dirty, - V2WorktreeMode::Never => OldWorktreeMode::Never, - } -} - -pub fn bridge_merge_strategy(m: V2MergeStrategy) -> OldMergeStrategy { - match m { - V2MergeStrategy::Squash => OldMergeStrategy::Squash, - V2MergeStrategy::Merge => OldMergeStrategy::Merge, - V2MergeStrategy::Rebase => OldMergeStrategy::Rebase, - } -} - -pub fn bridge_pull_request(pr: &super::run::RunPullRequestLayer) -> PullRequestSettings { - PullRequestSettings { - enabled: pr.enabled.unwrap_or(false), - draft: pr.draft.unwrap_or(true), - auto_merge: pr.auto_merge.unwrap_or(false), - merge_strategy: pr - .merge_strategy - .map(bridge_merge_strategy) - .unwrap_or_default(), - } -} - -pub fn bridge_run_artifacts(artifacts: &super::run::RunArtifactsLayer) -> ArtifactsSettings { - ArtifactsSettings { - include: artifacts.include.clone(), - } -} - -pub fn bridge_mcps(mcps: &HashMap) -> HashMap { - mcps.iter() - .map(|(name, entry)| (name.clone(), bridge_mcp_entry(entry))) - .collect() -} - -pub fn bridge_mcp_entry(entry: &McpEntryLayer) -> McpServerEntry { - let transport = match entry { - McpEntryLayer::Stdio { - script, - command, - env, - .. - } => { - let command_vec: Vec = if let Some(script) = script { - vec!["sh".into(), "-c".into(), interp_to_string(script)] - } else if let Some(command) = command { - command.iter().map(interp_to_string).collect() - } else { - Vec::new() - }; - McpTransport::Stdio { - command: command_vec, - env: env - .iter() - .map(|(k, v)| (k.clone(), interp_to_string(v))) - .collect(), - } - } - McpEntryLayer::Http { url, headers, .. } => McpTransport::Http { - url: interp_to_string(url), - headers: headers - .iter() - .map(|(k, v)| (k.clone(), interp_to_string(v))) - .collect(), - }, - McpEntryLayer::Sandbox { - script, - command, - port, - env, - .. - } => { - let command_vec: Vec = if let Some(script) = script { - vec!["sh".into(), "-c".into(), interp_to_string(script)] - } else if let Some(command) = command { - command.iter().map(interp_to_string).collect() - } else { - Vec::new() - }; - McpTransport::Sandbox { - command: command_vec, - port: *port, - env: env - .iter() - .map(|(k, v)| (k.clone(), interp_to_string(v))) - .collect(), - } - } - }; - - let (startup_secs, tool_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, |d| d.as_std().as_secs()), - tool_timeout.map_or(60, |d| d.as_std().as_secs()), - ), - }; - - McpServerEntry { - transport, - startup_timeout_secs: startup_secs, - tool_timeout_secs: tool_secs, - } -} - -pub fn bridge_hook(hook: &V2HookEntry) -> HookDefinition { - let hook_type = resolve_hook_type(hook); - // If the hook is a script/command form, emit via the shorthand so the - // old HookDefinition.command field holds the full command and - // HookDefinition.hook_type stays None. This avoids the duplicate - // `command` key that would otherwise appear under `#[serde(flatten)]`. - let command = if let Some(script) = &hook.script { - Some(interp_to_string(script)) - } else { - hook.command.as_ref().map(|command| { - command - .iter() - .map(interp_to_string) - .collect::>() - .join(" ") - }) - }; - HookDefinition { - name: hook.name.clone().or_else(|| hook.id.clone()), - event: bridge_hook_event(hook.event), - command, - hook_type, - matcher: hook.matcher.clone(), - blocking: hook.blocking, - timeout_ms: hook - .timeout - .map(|d| u64::try_from(d.as_std().as_millis()).unwrap_or(u64::MAX)), - sandbox: hook.sandbox, - } -} - -fn resolve_hook_type(hook: &V2HookEntry) -> Option { - // Script/command-shorthand hooks are emitted via the top-level - // HookDefinition.command field in bridge_hook, not here, to avoid - // the `#[serde(flatten)]` duplicate-field collision between the - // outer HookDefinition.command shorthand and the inner - // HookType::Command.command in the legacy old Settings shape. - if hook.script.is_some() || hook.command.is_some() { - return None; - } - if let Some(url) = &hook.url { - let headers = if hook.headers.is_empty() { - None - } else { - Some( - hook.headers - .iter() - .map(|(k, v)| (k.clone(), interp_to_string(v))) - .collect(), - ) - }; - let tls = match hook.tls { - Some(super::run::HookTlsMode::Verify) => OldTlsMode::Verify, - Some(super::run::HookTlsMode::NoVerify) => OldTlsMode::NoVerify, - Some(super::run::HookTlsMode::Off) => OldTlsMode::Off, - None => OldTlsMode::default(), - }; - return Some(OldHookType::Http { - url: interp_to_string(url), - headers, - allowed_env_vars: hook.allowed_env_vars.clone(), - tls, - }); - } - if hook.agent.is_some() { - return Some(OldHookType::Agent { - prompt: hook - .prompt - .as_ref() - .map(interp_to_string) - .unwrap_or_default(), - model: hook.model.as_ref().map(interp_to_string), - max_tool_rounds: hook.max_tool_rounds, - }); - } - hook.prompt.as_ref().map(|prompt| OldHookType::Prompt { - prompt: interp_to_string(prompt), - model: hook.model.as_ref().map(interp_to_string), - }) -} - -fn bridge_hook_event(event: V2HookEvent) -> OldHookEvent { - match event { - V2HookEvent::RunStart => OldHookEvent::RunStart, - V2HookEvent::RunComplete => OldHookEvent::RunComplete, - V2HookEvent::RunFailed => OldHookEvent::RunFailed, - V2HookEvent::StageStart => OldHookEvent::StageStart, - V2HookEvent::StageComplete => OldHookEvent::StageComplete, - V2HookEvent::StageFailed => OldHookEvent::StageFailed, - V2HookEvent::StageRetrying => OldHookEvent::StageRetrying, - V2HookEvent::EdgeSelected => OldHookEvent::EdgeSelected, - V2HookEvent::ParallelStart => OldHookEvent::ParallelStart, - V2HookEvent::ParallelComplete => OldHookEvent::ParallelComplete, - V2HookEvent::SandboxReady => OldHookEvent::SandboxReady, - V2HookEvent::SandboxCleanup => OldHookEvent::SandboxCleanup, - V2HookEvent::CheckpointSaved => OldHookEvent::CheckpointSaved, - V2HookEvent::PreToolUse => OldHookEvent::PreToolUse, - V2HookEvent::PostToolUse => OldHookEvent::PostToolUse, - V2HookEvent::PostToolUseFailure => OldHookEvent::PostToolUseFailure, - } -} - -fn bridge_cli(cli: &CliLayer, out: &mut Settings) { - if let Some(target) = &cli.target { - let target_str = match target { - CliTargetLayer::Http { url, .. } => url.as_ref().map(interp_to_string), - CliTargetLayer::Unix { path } => path.as_ref().map(interp_to_string), - }; - if target_str.is_some() { - out.server = Some(UserServer { - target: target_str, - tls: None, - }); - } - } - - if let Some(exec) = &cli.exec { - out.exec = Some(bridge_exec(exec)); - if let Some(idle) = exec.prevent_idle_sleep { - out.prevent_idle_sleep = Some(idle); - } - } - - if let Some(output) = &cli.output { - bridge_cli_output(output, out); - } - - if let Some(updates) = &cli.updates { - out.upgrade_check = updates.check; - } -} - -pub fn bridge_exec(exec: &CliExecLayer) -> ExecSettings { - ExecSettings { - provider: exec - .model - .as_ref() - .and_then(|m| m.provider.as_ref()) - .map(interp_to_string), - model: exec - .model - .as_ref() - .and_then(|m| m.name.as_ref()) - .map(interp_to_string), - permissions: exec.agent.as_ref().and_then(|a| { - a.permissions.map(|p| match p { - V2AgentPermissions::ReadOnly => PermissionLevel::ReadOnly, - V2AgentPermissions::ReadWrite => PermissionLevel::ReadWrite, - V2AgentPermissions::Full => PermissionLevel::Full, - }) - }), - output_format: None, - } -} - -fn bridge_cli_output(output: &CliOutputLayer, out: &mut Settings) { - if let Some(format) = output.format { - let fmt = match format { - super::cli::OutputFormat::Text => OutputFormat::Text, - super::cli::OutputFormat::Json => OutputFormat::Json, - }; - out.exec - .get_or_insert_with(ExecSettings::default) - .output_format = Some(fmt); - } - if let Some(verbosity) = output.verbosity { - out.verbose = Some(matches!(verbosity, OutputVerbosity::Verbose)); - } -} - -fn bridge_server(server: &ServerLayer, out: &mut Settings) { - if let Some(storage) = &server.storage { - bridge_storage(storage, out); - } - if let Some(scheduler) = &server.scheduler { - bridge_scheduler(scheduler, out); - } - if let Some(artifacts) = &server.artifacts { - out.artifact_storage = Some(bridge_artifacts(artifacts)); - } - if let Some(web) = &server.web { - out.web = Some(bridge_web(web)); - } - if let Some(api) = &server.api { - out.api = Some(ApiSettings { - base_url: api.url.as_ref().map_or_else( - || "http://localhost:3000/api/v1".to_string(), - interp_to_string, - ), - authentication_strategies: bridge_api_auth_strategies(server.auth.as_ref()), - tls: None, - }); - } - if let Some(logging) = &server.logging { - out.log = Some(LogSettings { - level: logging.level.clone(), - }); - } - if let Some(integrations) = &server.integrations { - bridge_integrations(integrations, out); - } -} - -fn bridge_storage(storage: &ServerStorageLayer, out: &mut Settings) { - if let Some(root) = &storage.root { - out.storage_dir = Some(std::path::PathBuf::from(interp_to_string(root))); - } -} - -fn bridge_scheduler(scheduler: &ServerSchedulerLayer, out: &mut Settings) { - out.max_concurrent_runs = scheduler.max_concurrent_runs; -} - -fn bridge_artifacts(a: &ServerArtifactsLayer) -> ArtifactStorageSettings { - let backend = match a.provider { - Some(ObjectStoreProvider::Local) | None => ArtifactStorageBackend::Local, - Some(ObjectStoreProvider::S3) => ArtifactStorageBackend::S3, - }; - let prefix = a - .prefix - .as_ref() - .map_or_else(|| "artifacts".to_string(), interp_to_string); - let (bucket, region, endpoint, path_style) = - a.s3.as_ref().map_or((None, None, None, None), |s3| { - ( - s3.bucket.as_ref().map(interp_to_string), - s3.region.as_ref().map(interp_to_string), - s3.endpoint.as_ref().map(interp_to_string), - s3.path_style, - ) - }); - ArtifactStorageSettings { - backend, - prefix, - bucket, - region, - endpoint, - path_style, - } -} - -fn bridge_web(web: &ServerWebLayer) -> WebSettings { - WebSettings { - enabled: web.enabled.unwrap_or(true), - url: web - .url - .as_ref() - .map_or_else(|| "http://localhost:3000".to_string(), interp_to_string), - auth: AuthSettings { - provider: AuthProvider::Github, - allowed_usernames: Vec::new(), - }, - } -} - -fn bridge_api_auth_strategies( - auth: Option<&super::server::ServerAuthLayer>, -) -> Vec { - let Some(auth) = auth else { - return Vec::new(); - }; - let Some(api) = &auth.api else { - return Vec::new(); - }; - let mut out = Vec::new(); - if let Some(jwt) = &api.jwt { - if jwt.enabled.unwrap_or(true) { - out.push(ApiAuthStrategy::Jwt); - } - } - if let Some(mtls) = &api.mtls { - if mtls.enabled.unwrap_or(true) { - out.push(ApiAuthStrategy::Mtls); - } - } - out -} - -fn bridge_integrations(integrations: &ServerIntegrationsLayer, out: &mut Settings) { - if let Some(github) = &integrations.github { - let git_settings = out.git.get_or_insert_with(|| GitSettings { - provider: GitProvider::Github, - ..GitSettings::default() - }); - if let Some(id) = &github.app_id { - git_settings.app_id = Some(interp_to_string(id)); - } - if let Some(cid) = &github.client_id { - git_settings.client_id = Some(interp_to_string(cid)); - } - if let Some(slug) = &github.slug { - git_settings.slug = Some(interp_to_string(slug)); - } - } - if let Some(slack) = &integrations.slack { - let slack_settings = out.slack.get_or_insert_with(SlackSettings::default); - if let Some(channel) = &slack.default_channel { - slack_settings.default_channel = Some(interp_to_string(channel)); - } - } -} - -// ------------------- shared helpers ------------------- - -fn merge_labels(out: &mut HashMap, src: &HashMap) { - for (k, v) in src { - out.insert(k.clone(), v.clone()); - } -} - -fn interp_to_string(value: &InterpString) -> String { - value.as_source() -} - -fn toml_value_to_string(value: &toml::Value) -> String { - match value { - toml::Value::String(s) => s.clone(), - other => other.to_string(), - } -} - -fn size_to_gb_i32(bytes: u64) -> i32 { - let gb = bytes / 1_000_000_000; - i32::try_from(gb).unwrap_or(i32::MAX) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn empty_file_bridges_to_empty_settings() { - let file = SettingsFile::default(); - let old = bridge_to_old(&file); - assert_eq!(old.goal, None); - assert_eq!(old.vars, None); - } - - #[test] - fn run_goal_bridges_to_old_goal() { - let file = SettingsFile { - run: Some(RunLayer { - goal: Some(InterpString::parse("Implement OAuth")), - ..RunLayer::default() - }), - ..SettingsFile::default() - }; - let old = bridge_to_old(&file); - assert_eq!(old.goal.as_deref(), Some("Implement OAuth")); - } - - #[test] - fn project_directory_bridges_to_old_fabro_root() { - let file = SettingsFile { - project: Some(ProjectLayer { - directory: Some("fabro/".into()), - ..ProjectLayer::default() - }), - ..SettingsFile::default() - }; - let old = bridge_to_old(&file); - assert_eq!(old.fabro.as_ref().map(|f| f.root.as_str()), Some("fabro/")); - } - - #[test] - fn run_execution_dry_run_bridges_to_old_dry_run_true() { - use super::super::run::{RunExecutionLayer, RunMode}; - let file = SettingsFile { - run: Some(RunLayer { - execution: Some(RunExecutionLayer { - mode: Some(RunMode::DryRun), - ..RunExecutionLayer::default() - }), - ..RunLayer::default() - }), - ..SettingsFile::default() - }; - let old = bridge_to_old(&file); - assert_eq!(old.dry_run, Some(true)); - } - - #[test] - fn run_execution_retros_true_bridges_to_old_no_retro_false() { - use super::super::run::RunExecutionLayer; - let file = SettingsFile { - run: Some(RunLayer { - execution: Some(RunExecutionLayer { - retros: Some(true), - ..RunExecutionLayer::default() - }), - ..RunLayer::default() - }), - ..SettingsFile::default() - }; - let old = bridge_to_old(&file); - assert_eq!(old.no_retro, Some(false)); - } -} diff --git a/lib/crates/fabro-types/src/settings/v2/mod.rs b/lib/crates/fabro-types/src/settings/v2/mod.rs index 0d1baa2e6..9304b4082 100644 --- a/lib/crates/fabro-types/src/settings/v2/mod.rs +++ b/lib/crates/fabro-types/src/settings/v2/mod.rs @@ -7,7 +7,6 @@ //! model references, env interpolation, and splice-capable arrays. pub mod accessors; -pub mod bridge; pub mod cli; pub mod duration; pub mod features; @@ -18,12 +17,11 @@ pub mod run; pub mod server; pub mod size; pub mod splice_array; +pub mod to_runtime; pub mod tree; pub mod version; pub mod workflow; -pub use bridge::bridge_to_old; - pub use cli::CliLayer; pub use duration::{Duration, ParseDurationError}; pub use features::FeaturesLayer; diff --git a/lib/crates/fabro-types/src/settings/v2/to_runtime.rs b/lib/crates/fabro-types/src/settings/v2/to_runtime.rs new file mode 100644 index 000000000..5ba403cba --- /dev/null +++ b/lib/crates/fabro-types/src/settings/v2/to_runtime.rs @@ -0,0 +1,325 @@ +//! v2 → runtime-type conversion helpers. +//! +//! The runtime types in `fabro_types::settings::{hook,mcp,run,sandbox}` are +//! the shapes that downstream crates (fabro-workflow, fabro-mcp, +//! fabro-sandbox, fabro-hooks) still consume at runtime. Each helper here +//! reads the v2 parse tree and builds the equivalent runtime value. +//! +//! These helpers replace the deleted `bridge_to_old` seam from Stage 6.2. +//! They are narrower: each builds a single runtime type from a single v2 +//! subtree, rather than assembling a full legacy [`Settings`] struct. +//! +//! Stage 6.3 deletes the legacy runtime types themselves. At that point +//! these helpers either disappear or get rewritten against the v2-native +//! replacements. + +use std::collections::HashMap; + +use super::interp::InterpString; +use super::run::{ + HookEntry as V2HookEntry, HookEvent as V2HookEvent, McpEntryLayer, + MergeStrategy as V2MergeStrategy, RunArtifactsLayer, RunPullRequestLayer, RunSandboxLayer, + WorktreeMode as V2WorktreeMode, +}; +use crate::settings::hook::{ + HookDefinition, HookEvent as OldHookEvent, HookType as OldHookType, TlsMode as OldTlsMode, +}; +use crate::settings::mcp::{McpServerEntry, McpTransport}; +use crate::settings::run::{ + ArtifactsSettings, MergeStrategy as OldMergeStrategy, PullRequestSettings, +}; +use crate::settings::sandbox::{ + DaytonaNetwork, DaytonaSettings, DaytonaSnapshotSettings, DockerfileSource, + LocalSandboxSettings, SandboxSettings, WorktreeMode as OldWorktreeMode, +}; + +pub fn bridge_sandbox(sb: &RunSandboxLayer) -> SandboxSettings { + SandboxSettings { + provider: sb.provider.clone(), + preserve: sb.preserve, + devcontainer: sb.devcontainer, + local: sb.local.as_ref().map(|local| LocalSandboxSettings { + worktree_mode: local + .worktree_mode + .map(bridge_worktree_mode) + .unwrap_or_default(), + }), + daytona: sb.daytona.as_ref().map(|d| DaytonaSettings { + auto_stop_interval: d.auto_stop_interval, + labels: if d.labels.is_empty() { + None + } else { + Some(d.labels.clone()) + }, + snapshot: d.snapshot.as_ref().and_then(|s| { + s.name.as_ref().map(|name| DaytonaSnapshotSettings { + name: name.clone(), + cpu: s.cpu, + memory: s.memory.map(|sz| size_to_gb_i32(sz.as_bytes())), + disk: s.disk.map(|sz| size_to_gb_i32(sz.as_bytes())), + dockerfile: s.dockerfile.as_ref().map(|d| match d { + super::run::DaytonaDockerfileLayer::Inline(text) => { + DockerfileSource::Inline(text.clone()) + } + super::run::DaytonaDockerfileLayer::Path { path } => { + DockerfileSource::Path { path: path.clone() } + } + }), + }) + }), + network: d.network.as_ref().map(|n| match n { + super::run::DaytonaNetworkLayer::Block => DaytonaNetwork::Block, + super::run::DaytonaNetworkLayer::AllowAll => DaytonaNetwork::AllowAll, + super::run::DaytonaNetworkLayer::AllowList { allow_list } => { + DaytonaNetwork::AllowList(allow_list.clone()) + } + }), + skip_clone: d.skip_clone.unwrap_or(false), + }), + env: if sb.env.is_empty() { + None + } else { + Some( + sb.env + .iter() + .map(|(k, v)| (k.clone(), interp_to_string(v))) + .collect(), + ) + }, + } +} + +pub fn bridge_worktree_mode(m: V2WorktreeMode) -> OldWorktreeMode { + match m { + V2WorktreeMode::Always => OldWorktreeMode::Always, + V2WorktreeMode::Clean => OldWorktreeMode::Clean, + V2WorktreeMode::Dirty => OldWorktreeMode::Dirty, + V2WorktreeMode::Never => OldWorktreeMode::Never, + } +} + +pub fn bridge_merge_strategy(m: V2MergeStrategy) -> OldMergeStrategy { + match m { + V2MergeStrategy::Squash => OldMergeStrategy::Squash, + V2MergeStrategy::Merge => OldMergeStrategy::Merge, + V2MergeStrategy::Rebase => OldMergeStrategy::Rebase, + } +} + +pub fn bridge_pull_request(pr: &RunPullRequestLayer) -> PullRequestSettings { + PullRequestSettings { + enabled: pr.enabled.unwrap_or(false), + draft: pr.draft.unwrap_or(true), + auto_merge: pr.auto_merge.unwrap_or(false), + merge_strategy: pr + .merge_strategy + .map(bridge_merge_strategy) + .unwrap_or_default(), + } +} + +pub fn bridge_run_artifacts(artifacts: &RunArtifactsLayer) -> ArtifactsSettings { + ArtifactsSettings { + include: artifacts.include.clone(), + } +} + +pub fn bridge_mcps(mcps: &HashMap) -> HashMap { + mcps.iter() + .map(|(name, entry)| (name.clone(), bridge_mcp_entry(entry))) + .collect() +} + +pub fn bridge_mcp_entry(entry: &McpEntryLayer) -> McpServerEntry { + let transport = match entry { + McpEntryLayer::Stdio { + script, + command, + env, + .. + } => { + let command_vec: Vec = if let Some(script) = script { + vec!["sh".into(), "-c".into(), interp_to_string(script)] + } else if let Some(command) = command { + command.iter().map(interp_to_string).collect() + } else { + Vec::new() + }; + McpTransport::Stdio { + command: command_vec, + env: env + .iter() + .map(|(k, v)| (k.clone(), interp_to_string(v))) + .collect(), + } + } + McpEntryLayer::Http { url, headers, .. } => McpTransport::Http { + url: interp_to_string(url), + headers: headers + .iter() + .map(|(k, v)| (k.clone(), interp_to_string(v))) + .collect(), + }, + McpEntryLayer::Sandbox { + script, + command, + port, + env, + .. + } => { + let command_vec: Vec = if let Some(script) = script { + vec!["sh".into(), "-c".into(), interp_to_string(script)] + } else if let Some(command) = command { + command.iter().map(interp_to_string).collect() + } else { + Vec::new() + }; + McpTransport::Sandbox { + command: command_vec, + port: *port, + env: env + .iter() + .map(|(k, v)| (k.clone(), interp_to_string(v))) + .collect(), + } + } + }; + + let (startup_secs, tool_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, |d| d.as_std().as_secs()), + tool_timeout.map_or(60, |d| d.as_std().as_secs()), + ), + }; + + McpServerEntry { + transport, + startup_timeout_secs: startup_secs, + tool_timeout_secs: tool_secs, + } +} + +pub fn bridge_hook(hook: &V2HookEntry) -> HookDefinition { + let hook_type = resolve_hook_type(hook); + // If the hook is a script/command form, emit via the shorthand so the + // old HookDefinition.command field holds the full command and + // HookDefinition.hook_type stays None. This avoids the duplicate + // `command` key that would otherwise appear under `#[serde(flatten)]`. + let command = if let Some(script) = &hook.script { + Some(interp_to_string(script)) + } else { + hook.command.as_ref().map(|command| { + command + .iter() + .map(interp_to_string) + .collect::>() + .join(" ") + }) + }; + HookDefinition { + name: hook.name.clone().or_else(|| hook.id.clone()), + event: bridge_hook_event(hook.event), + command, + hook_type, + matcher: hook.matcher.clone(), + blocking: hook.blocking, + timeout_ms: hook + .timeout + .map(|d| u64::try_from(d.as_std().as_millis()).unwrap_or(u64::MAX)), + sandbox: hook.sandbox, + } +} + +fn resolve_hook_type(hook: &V2HookEntry) -> Option { + // Script/command-shorthand hooks are emitted via the top-level + // HookDefinition.command field in bridge_hook, not here, to avoid + // the `#[serde(flatten)]` duplicate-field collision between the + // outer HookDefinition.command shorthand and the inner + // HookType::Command.command in the legacy old Settings shape. + if hook.script.is_some() || hook.command.is_some() { + return None; + } + if let Some(url) = &hook.url { + let headers = if hook.headers.is_empty() { + None + } else { + Some( + hook.headers + .iter() + .map(|(k, v)| (k.clone(), interp_to_string(v))) + .collect(), + ) + }; + let tls = match hook.tls { + Some(super::run::HookTlsMode::Verify) => OldTlsMode::Verify, + Some(super::run::HookTlsMode::NoVerify) => OldTlsMode::NoVerify, + Some(super::run::HookTlsMode::Off) => OldTlsMode::Off, + None => OldTlsMode::default(), + }; + return Some(OldHookType::Http { + url: interp_to_string(url), + headers, + allowed_env_vars: hook.allowed_env_vars.clone(), + tls, + }); + } + if hook.agent.is_some() { + return Some(OldHookType::Agent { + prompt: hook + .prompt + .as_ref() + .map(interp_to_string) + .unwrap_or_default(), + model: hook.model.as_ref().map(interp_to_string), + max_tool_rounds: hook.max_tool_rounds, + }); + } + hook.prompt.as_ref().map(|prompt| OldHookType::Prompt { + prompt: interp_to_string(prompt), + model: hook.model.as_ref().map(interp_to_string), + }) +} + +fn bridge_hook_event(event: V2HookEvent) -> OldHookEvent { + match event { + V2HookEvent::RunStart => OldHookEvent::RunStart, + V2HookEvent::RunComplete => OldHookEvent::RunComplete, + V2HookEvent::RunFailed => OldHookEvent::RunFailed, + V2HookEvent::StageStart => OldHookEvent::StageStart, + V2HookEvent::StageComplete => OldHookEvent::StageComplete, + V2HookEvent::StageFailed => OldHookEvent::StageFailed, + V2HookEvent::StageRetrying => OldHookEvent::StageRetrying, + V2HookEvent::EdgeSelected => OldHookEvent::EdgeSelected, + V2HookEvent::ParallelStart => OldHookEvent::ParallelStart, + V2HookEvent::ParallelComplete => OldHookEvent::ParallelComplete, + V2HookEvent::SandboxReady => OldHookEvent::SandboxReady, + V2HookEvent::SandboxCleanup => OldHookEvent::SandboxCleanup, + V2HookEvent::CheckpointSaved => OldHookEvent::CheckpointSaved, + V2HookEvent::PreToolUse => OldHookEvent::PreToolUse, + V2HookEvent::PostToolUse => OldHookEvent::PostToolUse, + V2HookEvent::PostToolUseFailure => OldHookEvent::PostToolUseFailure, + } +} + +fn interp_to_string(value: &InterpString) -> String { + value.as_source() +} + +fn size_to_gb_i32(bytes: u64) -> i32 { + let gb = bytes / 1_000_000_000; + i32::try_from(gb).unwrap_or(i32::MAX) +} diff --git a/lib/crates/fabro-workflow/src/operations/start.rs b/lib/crates/fabro-workflow/src/operations/start.rs index fdb2d56b7..2a0d8e43f 100644 --- a/lib/crates/fabro-workflow/src/operations/start.rs +++ b/lib/crates/fabro-workflow/src/operations/start.rs @@ -10,10 +10,10 @@ use fabro_interview::{AutoApproveInterviewer, Interviewer}; use fabro_model::{Catalog, FallbackTarget, Provider}; use fabro_sandbox::{SandboxProvider, SandboxSpec}; use fabro_types::RunId; -use fabro_types::settings::v2::bridge::{ +use fabro_types::settings::v2::run::ModelRefOrSplice; +use fabro_types::settings::v2::to_runtime::{ bridge_hook, bridge_mcp_entry, bridge_pull_request, bridge_sandbox, bridge_worktree_mode, }; -use fabro_types::settings::v2::run::ModelRefOrSplice; use fabro_types::settings::v2::{InterpString, SettingsFile}; use crate::artifact_upload::ArtifactSink; From 34a481cd4495083eca932a5cbbbe2c52ad8805d1 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 9 Apr 2026 16:20:26 -0400 Subject: [PATCH 21/47] refactor(settings): stage 6.3 delete dead Settings helpers + v2 install TOML Stage 6.3 closes out the dead code that Stage 6.1 left behind: fabro-types - Delete the inherent helpers on the legacy flat `Settings` struct (`app_id`, `slug`, `client_id`, `git_author`, `sandbox_settings`, `setup_settings`, `setup_commands`, `setup_timeout_ms`, `preserve_sandbox_enabled`, `github_permissions`, `mcp_server_entries`, `verbose_enabled`, `prevent_idle_sleep_enabled`, `upgrade_check_enabled`, `dry_run_enabled`, `auto_approve_enabled`, `no_retro_enabled`, `storage_dir`, `slack_settings`). Nothing reads them anymore -- consumers now use `SettingsFile` accessors (`github_app_id_str()`, `run_sandbox()`, `dry_run_enabled()`, `storage_dir()`, etc.). The `Settings` struct itself stays alive for the remaining legacy OpenAPI response path and a handful of demo-route payloads; Stage 6.6 finishes the deletion alongside the OpenAPI spec rewrite. - Delete the `#[cfg(test)] mod tests` block that only covered the deleted `storage_dir()` helper. fabro-cli/commands/install.rs - `merge_server_settings` now writes a v2 TOML file (with `[server.{api,listen.tls,web,auth.api.{jwt,mtls},auth.web}]` stanzas) instead of the legacy v1 top-level `[web]`/`[api]`/`[git]` shape. The generated file previously failed to parse as v2 on next startup; now it round-trips through `ConfigLayer::parse`. - Tests rewritten to parse the generated TOML through `fabro_config::ConfigLayer::parse` and assert against the v2 tree (`server.auth.web.allowed_usernames`, `server.auth.api.{jwt,mtls}.enabled`, `server.listen.tls.{cert,key,ca}`). The `merge_server_settings_preserves_existing_*` tests collapsed into a single `preserves_existing_top_level_sections` test since the old tests were asserting v1 `[git]` / `[api]` keys that no longer make sense. Build, clippy, fmt, and tests all green: 3756 / 3756 pass. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/crates/fabro-cli/src/commands/install.rs | 209 ++++++++++--------- lib/crates/fabro-types/src/settings/mod.rs | 105 +--------- 2 files changed, 118 insertions(+), 196 deletions(-) diff --git a/lib/crates/fabro-cli/src/commands/install.rs b/lib/crates/fabro-cli/src/commands/install.rs index a425daacc..a0e3843f0 100644 --- a/lib/crates/fabro-cli/src/commands/install.rs +++ b/lib/crates/fabro-cli/src/commands/install.rs @@ -204,50 +204,53 @@ fn ensure_table<'a>(table: &'a mut toml::Table, key: &str) -> Result<&'a mut tom fn merge_server_settings(doc: &mut toml::Value, username: &str) -> Result<()> { let root = root_table_mut(doc)?; - let web = ensure_table(root, "web")?; + root.insert("_version".to_string(), toml::Value::Integer(1)); + + let server = ensure_table(root, "server")?; + + let api = ensure_table(server, "api")?; + api.insert( + "url".to_string(), + toml::Value::String("https://localhost:3000/api/v1".to_string()), + ); + + let listen = ensure_table(server, "listen")?; + listen.insert("type".to_string(), toml::Value::String("tcp".to_string())); + let listen_tls = ensure_table(listen, "tls")?; + let certs_dir = fabro_util::Home::from_env().certs_dir(); + listen_tls.insert( + "cert".to_string(), + toml::Value::String(certs_dir.join("server.crt").to_string_lossy().to_string()), + ); + listen_tls.insert( + "key".to_string(), + toml::Value::String(certs_dir.join("server.key").to_string_lossy().to_string()), + ); + listen_tls.insert( + "ca".to_string(), + toml::Value::String(certs_dir.join("ca.crt").to_string_lossy().to_string()), + ); + + let web = ensure_table(server, "web")?; + web.insert("enabled".to_string(), toml::Value::Boolean(true)); web.insert( "url".to_string(), toml::Value::String("http://localhost:3000".to_string()), ); - let auth = ensure_table(web, "auth")?; - auth.insert( - "provider".to_string(), - toml::Value::String("github".to_string()), - ); - auth.insert( + let auth = ensure_table(server, "auth")?; + let auth_api = ensure_table(auth, "api")?; + let jwt = ensure_table(auth_api, "jwt")?; + jwt.insert("enabled".to_string(), toml::Value::Boolean(true)); + let mtls = ensure_table(auth_api, "mtls")?; + mtls.insert("enabled".to_string(), toml::Value::Boolean(true)); + + let auth_web = ensure_table(auth, "web")?; + auth_web.insert( "allowed_usernames".to_string(), toml::Value::Array(vec![toml::Value::String(username.to_string())]), ); - let api = ensure_table(root, "api")?; - api.insert( - "base_url".to_string(), - toml::Value::String("https://localhost:3000/api/v1".to_string()), - ); - api.insert( - "authentication_strategies".to_string(), - toml::Value::Array(vec![ - toml::Value::String("jwt".to_string()), - toml::Value::String("mtls".to_string()), - ]), - ); - - let tls = ensure_table(api, "tls")?; - let certs_dir = fabro_util::Home::from_env().certs_dir(); - tls.insert( - "cert".to_string(), - toml::Value::String(certs_dir.join("server.crt").to_string_lossy().to_string()), - ); - tls.insert( - "key".to_string(), - toml::Value::String(certs_dir.join("server.key").to_string_lossy().to_string()), - ); - tls.insert( - "ca".to_string(), - toml::Value::String(certs_dir.join("ca.crt").to_string_lossy().to_string()), - ); - Ok(()) } @@ -1021,102 +1024,114 @@ mod tests { #[test] fn config_toml_roundtrips() { + use fabro_types::settings::v2::SettingsFile; let toml_str = format_config_toml("brynary"); - let settings: fabro_types::Settings = - toml::from_str(&toml_str).expect("config should parse"); - assert_eq!( - settings.web.unwrap().auth.allowed_usernames, - vec!["brynary"] - ); + let cfg: SettingsFile = fabro_config::ConfigLayer::parse(&toml_str) + .expect("generated config should parse as v2") + .into(); + let allowed = cfg + .server + .as_ref() + .and_then(|s| s.auth.as_ref()) + .and_then(|a| a.web.as_ref()) + .map(|w| w.allowed_usernames.clone()) + .expect("server.auth.web.allowed_usernames should be set"); + assert_eq!(allowed, vec!["brynary".to_string()]); } #[test] fn config_toml_has_auth_strategies() { + use fabro_types::settings::v2::SettingsFile; let toml_str = format_config_toml("alice"); - let settings: fabro_types::Settings = toml::from_str(&toml_str).unwrap(); - assert_eq!( - settings.api.unwrap().authentication_strategies, - vec![ - fabro_config::server::ApiAuthStrategy::Jwt, - fabro_config::server::ApiAuthStrategy::Mtls, - ] + let cfg: SettingsFile = fabro_config::ConfigLayer::parse(&toml_str).unwrap().into(); + let auth_api = cfg + .server + .as_ref() + .and_then(|s| s.auth.as_ref()) + .and_then(|a| a.api.as_ref()) + .expect("server.auth.api should be set"); + assert!( + auth_api + .jwt + .as_ref() + .is_some_and(|jwt| jwt.enabled.unwrap_or(false)) + ); + assert!( + auth_api + .mtls + .as_ref() + .is_some_and(|mtls| mtls.enabled.unwrap_or(false)) ); } #[test] fn config_toml_has_tls_paths() { + use fabro_types::settings::v2::SettingsFile; + use fabro_types::settings::v2::server::ServerListenLayer; let toml_str = format_config_toml("bob"); - let settings: fabro_types::Settings = toml::from_str(&toml_str).unwrap(); - let tls = settings.api.unwrap().tls.expect("tls should be set"); + let cfg: SettingsFile = fabro_config::ConfigLayer::parse(&toml_str).unwrap().into(); + let listen = cfg + .server + .as_ref() + .and_then(|s| s.listen.as_ref()) + .expect("server.listen should be set"); + let tls = match listen { + ServerListenLayer::Tcp { tls, .. } => tls.as_ref().expect("server.listen.tls"), + ServerListenLayer::Unix { .. } => panic!("expected tcp listen"), + }; let certs_dir = fabro_util::Home::from_env().certs_dir(); - assert_eq!(tls.cert, certs_dir.join("server.crt")); - assert_eq!(tls.key, certs_dir.join("server.key")); - assert_eq!(tls.ca, certs_dir.join("ca.crt")); + assert_eq!( + tls.cert.as_ref().map(|c| c.as_source()), + Some(certs_dir.join("server.crt").to_string_lossy().into_owned()) + ); + assert_eq!( + tls.key.as_ref().map(|c| c.as_source()), + Some(certs_dir.join("server.key").to_string_lossy().into_owned()) + ); + assert_eq!( + tls.ca.as_ref().map(|c| c.as_source()), + Some(certs_dir.join("ca.crt").to_string_lossy().into_owned()) + ); } #[test] - fn merge_server_settings_preserves_existing_git_table() { + fn merge_server_settings_preserves_existing_top_level_sections() { let mut doc: toml::Value = toml::from_str( r#" -[git] -app_id = "123" +_version = 1 -[git.author] -name = "fabro" -email = "fabro@example.com" +[project] +name = "custom" "#, ) .unwrap(); merge_server_settings(&mut doc, "alice").unwrap(); - let git = doc.get("git").and_then(toml::Value::as_table).unwrap(); - assert_eq!(git.get("app_id").and_then(toml::Value::as_str), Some("123")); - let author = git.get("author").and_then(toml::Value::as_table).unwrap(); + // Existing top-level [project] stays. assert_eq!( - author.get("name").and_then(toml::Value::as_str), - Some("fabro") - ); - assert_eq!( - author.get("email").and_then(toml::Value::as_str), - Some("fabro@example.com") - ); - assert_eq!( - doc.get("web") + doc.get("project") .and_then(toml::Value::as_table) - .and_then(|web| web.get("auth")) + .and_then(|p| p.get("name")) + .and_then(toml::Value::as_str), + Some("custom") + ); + // New server.auth.web.allowed_usernames is added. + assert_eq!( + doc.get("server") .and_then(toml::Value::as_table) - .and_then(|auth| auth.get("allowed_usernames")) + .and_then(|s| s.get("auth")) + .and_then(toml::Value::as_table) + .and_then(|a| a.get("web")) + .and_then(toml::Value::as_table) + .and_then(|w| w.get("allowed_usernames")) .and_then(toml::Value::as_array) - .and_then(|allowed| allowed.first()) + .and_then(|u| u.first()) .and_then(toml::Value::as_str), Some("alice") ); } - #[test] - fn merge_server_settings_preserves_existing_api_nested_keys() { - let mut doc: toml::Value = toml::from_str( - r#" -[api] -base_url = "https://example.com/api/v1" - -[api.extra] -mode = "keep-me" -"#, - ) - .unwrap(); - - merge_server_settings(&mut doc, "alice").unwrap(); - - let api = doc.get("api").and_then(toml::Value::as_table).unwrap(); - let extra = api.get("extra").and_then(toml::Value::as_table).unwrap(); - assert_eq!( - extra.get("mode").and_then(toml::Value::as_str), - Some("keep-me") - ); - } - // -- GitHub App manifest -- #[test] diff --git a/lib/crates/fabro-types/src/settings/mod.rs b/lib/crates/fabro-types/src/settings/mod.rs index 949f53153..4787e9b12 100644 --- a/lib/crates/fabro-types/src/settings/mod.rs +++ b/lib/crates/fabro-types/src/settings/mod.rs @@ -129,102 +129,9 @@ pub struct Settings { pub fabro: Option, } -impl Settings { - pub fn app_id(&self) -> Option<&str> { - self.git.as_ref().and_then(|g| g.app_id.as_deref()) - } - - pub fn slug(&self) -> Option<&str> { - self.git.as_ref().and_then(|g| g.slug.as_deref()) - } - - pub fn client_id(&self) -> Option<&str> { - self.git.as_ref().and_then(|g| g.client_id.as_deref()) - } - - pub fn git_author(&self) -> Option<&GitAuthorSettings> { - self.git.as_ref().map(|g| &g.author) - } - - pub fn sandbox_settings(&self) -> Option<&SandboxSettings> { - self.sandbox.as_ref() - } - - pub fn setup_settings(&self) -> Option<&SetupSettings> { - self.setup.as_ref() - } - - pub fn setup_commands(&self) -> &[String] { - self.setup - .as_ref() - .map_or(&[], |setup| setup.commands.as_slice()) - } - - pub fn setup_timeout_ms(&self) -> Option { - self.setup.as_ref().and_then(|setup| setup.timeout_ms) - } - - pub fn preserve_sandbox_enabled(&self) -> bool { - self.sandbox - .as_ref() - .and_then(|sandbox| sandbox.preserve) - .unwrap_or(false) - } - - pub fn github_permissions(&self) -> Option<&HashMap> { - self.github - .as_ref() - .and_then(|github| (!github.permissions.is_empty()).then_some(&github.permissions)) - } - - pub fn mcp_server_entries(&self) -> &HashMap { - &self.mcp_servers - } - - pub fn verbose_enabled(&self) -> bool { - self.verbose.unwrap_or(false) - } - - pub fn prevent_idle_sleep_enabled(&self) -> bool { - self.prevent_idle_sleep.unwrap_or(false) - } - - pub fn upgrade_check_enabled(&self) -> bool { - self.upgrade_check.unwrap_or(true) - } - - pub fn dry_run_enabled(&self) -> bool { - self.dry_run.unwrap_or(false) - } - - pub fn auto_approve_enabled(&self) -> bool { - self.auto_approve.unwrap_or(false) - } - - pub fn no_retro_enabled(&self) -> bool { - self.no_retro.unwrap_or(false) - } - - pub fn storage_dir(&self) -> PathBuf { - self.storage_dir - .clone() - .unwrap_or_else(|| fabro_util::Home::from_env().storage_dir()) - } - - pub fn slack_settings(&self) -> Option<&SlackSettings> { - self.slack.as_ref() - } -} - -#[cfg(test)] -mod tests { - use super::Settings; - - #[test] - fn storage_dir_defaults_to_home_storage_subdir() { - assert_eq!( - Settings::default().storage_dir(), - fabro_util::Home::from_env().storage_dir() - ); - } -} +// All inherent helpers on `Settings` are gone -- the v2 `SettingsFile` +// accessors in `settings::v2::accessors` are the single source of truth +// for reading merged configuration. The flat `Settings` struct itself +// lingers for the OpenAPI legacy `ServerSettings` response shape and a +// handful of demo-route payloads; Stage 6.6 finishes the deletion once +// the OpenAPI spec is rewritten to return v2 DTOs. From a3fd3b002b334100cca1e3956c7a2bb6f0a5dad4 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 9 Apr 2026 16:30:38 -0400 Subject: [PATCH 22/47] refactor(config): stage 6.4 delete fabro-config re-export shims fabro-config no longer carries the legacy pass-through shims that forwarded type re-exports from `fabro_types::settings::{hook,mcp,sandbox, server,user,run}`. Consumers now import the runtime types directly from `fabro_types::settings::*`, which is the only definitional location. Deleted files: - `fabro-config/src/hook.rs` (1 LOC glob re-export) - `fabro-config/src/mcp.rs` (1 LOC glob re-export) - `fabro-config/src/sandbox.rs` (~8 LOC re-export list) - `fabro-config/src/server.rs` (re-exports + `resolve_storage_dir`; the `resolve_storage_dir` helper moved to `fabro_config`'s crate root and takes `&SettingsFile` directly) Shrunk files: - `fabro-config/src/run.rs` lost the `ArtifactsSettings` / `CheckpointSettings` / `GitHubSettings` / `LlmSettings` / `MergeStrategy` / `PullRequestSettings` / `SetupSettings` re-export block and the unused `resolve_env_refs` helper. What remains is just the workflow TOML loader helpers (`parse_run_config`, `load_run_config`, `resolve_graph_path`). - `fabro-config/src/user.rs` lost the `ClientTlsSettings` / `ExecSettings` / `OutputFormat` / `PermissionLevel` / `ServerSettings` re-export block. The settings-path helpers and legacy-config warning logic stay. `fabro-cli/src/user_config.rs` now imports `ClientTlsSettings` directly from fabro_types. Callers updated to use the canonical paths: - `fabro-agent/src/cli.rs` imports `{OutputFormat, PermissionLevel}` from `fabro_types::settings::user`; added `fabro-types` dep. - `fabro-hooks/src/{config,types}.rs` re-export from `fabro_types::settings::hook`. - `fabro-mcp/src/config.rs` re-exports from `fabro_types::settings::mcp`. - `fabro-sandbox/src/daytona/mod.rs` re-exports from `fabro_types::settings::sandbox`. - `fabro-server/src/{lib,jwt_auth,tls,serve,demo}.rs` + `tests/it/openapi_conformance.rs` import server types from `fabro_types::settings::server` and call `fabro_config::resolve_storage_dir` from the crate root. - `fabro-workflow/src/{operations/start,pipeline/types,pipeline/pull_request}.rs` import sandbox / pull_request types from `fabro_types::settings::*`. Build, clippy, fmt, and 3756 / 3756 tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 1 + lib/crates/fabro-agent/Cargo.toml | 1 + lib/crates/fabro-agent/src/cli.rs | 2 +- lib/crates/fabro-cli/src/user_config.rs | 1 + lib/crates/fabro-config/src/hook.rs | 1 - lib/crates/fabro-config/src/lib.rs | 13 ++++--- lib/crates/fabro-config/src/mcp.rs | 1 - lib/crates/fabro-config/src/run.rs | 36 +++---------------- lib/crates/fabro-config/src/sandbox.rs | 10 ------ lib/crates/fabro-config/src/server.rs | 23 ------------ lib/crates/fabro-config/src/user.rs | 10 ++---- lib/crates/fabro-hooks/src/config.rs | 2 +- lib/crates/fabro-hooks/src/types.rs | 2 +- lib/crates/fabro-mcp/src/config.rs | 2 +- lib/crates/fabro-sandbox/src/daytona/mod.rs | 2 +- lib/crates/fabro-server/src/demo/mod.rs | 12 +++---- lib/crates/fabro-server/src/jwt_auth.rs | 4 +-- lib/crates/fabro-server/src/lib.rs | 2 +- lib/crates/fabro-server/src/serve.rs | 5 +-- lib/crates/fabro-server/src/tls.rs | 2 +- .../tests/it/openapi_conformance.rs | 12 +++---- .../fabro-workflow/src/operations/start.rs | 6 ++-- .../src/pipeline/pull_request.rs | 2 +- .../fabro-workflow/src/pipeline/types.rs | 4 +-- 24 files changed, 49 insertions(+), 107 deletions(-) delete mode 100644 lib/crates/fabro-config/src/hook.rs delete mode 100644 lib/crates/fabro-config/src/mcp.rs delete mode 100644 lib/crates/fabro-config/src/sandbox.rs delete mode 100644 lib/crates/fabro-config/src/server.rs diff --git a/Cargo.lock b/Cargo.lock index d538cf560..0a51eac92 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1487,6 +1487,7 @@ dependencies = [ "fabro-model", "fabro-sandbox", "fabro-test", + "fabro-types", "fabro-util", "futures", "glob", diff --git a/lib/crates/fabro-agent/Cargo.toml b/lib/crates/fabro-agent/Cargo.toml index 2657bd3f3..917cd93d8 100644 --- a/lib/crates/fabro-agent/Cargo.toml +++ b/lib/crates/fabro-agent/Cargo.toml @@ -25,6 +25,7 @@ workspace = true clap.workspace = true anyhow.workspace = true fabro-config = { path = "../fabro-config", features = ["clap"] } +fabro-types = { path = "../fabro-types" } fabro-llm = { path = "../fabro-llm" } fabro-model = { path = "../fabro-model" } fabro-mcp = { path = "../fabro-mcp" } diff --git a/lib/crates/fabro-agent/src/cli.rs b/lib/crates/fabro-agent/src/cli.rs index 6e754b173..a7aa862c6 100644 --- a/lib/crates/fabro-agent/src/cli.rs +++ b/lib/crates/fabro-agent/src/cli.rs @@ -68,7 +68,7 @@ struct Cli { args: AgentArgs, } -pub use fabro_config::user::{OutputFormat, PermissionLevel}; +pub use fabro_types::settings::user::{OutputFormat, PermissionLevel}; impl AgentArgs { /// Fill `None` fields from settings.toml values, then hardcoded defaults. diff --git a/lib/crates/fabro-cli/src/user_config.rs b/lib/crates/fabro-cli/src/user_config.rs index 98384ef4e..7a9d6d483 100644 --- a/lib/crates/fabro-cli/src/user_config.rs +++ b/lib/crates/fabro-cli/src/user_config.rs @@ -1,6 +1,7 @@ use std::path::{Path, PathBuf}; pub(crate) use fabro_config::user::*; +pub(crate) use fabro_types::settings::user::ClientTlsSettings; use anyhow::{Result, bail}; use fabro_config::ConfigLayer; diff --git a/lib/crates/fabro-config/src/hook.rs b/lib/crates/fabro-config/src/hook.rs deleted file mode 100644 index 806e3a995..000000000 --- a/lib/crates/fabro-config/src/hook.rs +++ /dev/null @@ -1 +0,0 @@ -pub use fabro_types::settings::hook::*; diff --git a/lib/crates/fabro-config/src/lib.rs b/lib/crates/fabro-config/src/lib.rs index 526545afd..14f79d8c9 100644 --- a/lib/crates/fabro-config/src/lib.rs +++ b/lib/crates/fabro-config/src/lib.rs @@ -3,14 +3,10 @@ extern crate self as fabro_config; pub mod config; pub mod effective_settings; pub mod home; -pub mod hook; pub mod legacy_env; -pub mod mcp; pub mod merge; pub mod project; pub mod run; -pub mod sandbox; -pub mod server; pub mod storage; pub mod user; @@ -19,10 +15,17 @@ pub use fabro_util::path::expand_tilde; pub use home::Home; pub use storage::{RunScratch, ServerState, Storage}; -use std::path::Path; +use std::path::{Path, PathBuf}; +use fabro_types::settings::v2::SettingsFile; use serde::de::DeserializeOwned; +/// Resolve the storage directory: v2 `server.storage.root` > home default. +#[must_use] +pub fn resolve_storage_dir(settings: &SettingsFile) -> PathBuf { + settings.storage_dir() +} + /// Load a TOML config from an explicit path or `~/.fabro/{filename}`. /// /// Returns `T::default()` when no explicit path is given and the default file diff --git a/lib/crates/fabro-config/src/mcp.rs b/lib/crates/fabro-config/src/mcp.rs deleted file mode 100644 index 26d5aa144..000000000 --- a/lib/crates/fabro-config/src/mcp.rs +++ /dev/null @@ -1 +0,0 @@ -pub use fabro_types::settings::mcp::*; diff --git a/lib/crates/fabro-config/src/run.rs b/lib/crates/fabro-config/src/run.rs index 3e5c61ee8..a5c015173 100644 --- a/lib/crates/fabro-config/src/run.rs +++ b/lib/crates/fabro-config/src/run.rs @@ -1,42 +1,16 @@ -//! Re-export shim for run-side settings types. +//! Workflow / run config loading helpers. //! -//! Stage 3 replaced the parse-time types previously defined here with the -//! v2 parse tree in `fabro_types::settings::v2`. This module stays alive as -//! a pass-through for crates that still import resolved run types via the -//! legacy `fabro_config::run` path; Stage 6 deletes it. +//! Thin wrappers around `ConfigLayer::parse` / `ConfigLayer::load` 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. -use std::collections::HashMap; use std::path::{Path, PathBuf}; use anyhow::Context; use crate::config::ConfigLayer; -pub use fabro_types::settings::run::{ - ArtifactsSettings, CheckpointSettings, GitHubSettings, LlmSettings, MergeStrategy, - PullRequestSettings, SetupSettings, -}; - -/// Expand `${env.NAME}` whole-value references inside a string map. -/// -/// Leaves entries that don't match the whole-value form untouched. Missing -/// host variables produce an error. This is the minimal resolver legacy -/// consumers still call while they are being migrated off `Settings`; the -/// full v2 interpolation pass lives in `fabro_types::settings::v2::interp`. -pub fn resolve_env_refs(env: &mut HashMap) -> anyhow::Result<()> { - for (key, value) in env.iter_mut() { - if let Some(var_name) = value - .strip_prefix("${env.") - .and_then(|s| s.strip_suffix('}')) - { - *value = std::env::var(var_name).with_context(|| { - format!("sandbox.env.{key}: host environment variable {var_name:?} is not set") - })?; - } - } - Ok(()) -} - /// Load and parse a run config from a TOML file. pub fn parse_run_config(contents: &str) -> anyhow::Result { ConfigLayer::parse(contents).context("Failed to parse run config TOML") diff --git a/lib/crates/fabro-config/src/sandbox.rs b/lib/crates/fabro-config/src/sandbox.rs deleted file mode 100644 index 2c247031a..000000000 --- a/lib/crates/fabro-config/src/sandbox.rs +++ /dev/null @@ -1,10 +0,0 @@ -//! Re-export shim for sandbox settings types. -//! -//! Stage 3 removed the parse-time `SandboxConfig`/`DaytonaConfig` types; -//! callers that still import resolved sandbox types via this module use the -//! re-exports below. Stage 6 deletes this file. - -pub use fabro_types::settings::sandbox::{ - DaytonaNetwork, DaytonaSettings, DaytonaSnapshotSettings, DockerfileSource, - LocalSandboxSettings, SandboxSettings, WorktreeMode, -}; diff --git a/lib/crates/fabro-config/src/server.rs b/lib/crates/fabro-config/src/server.rs deleted file mode 100644 index a874bfe1b..000000000 --- a/lib/crates/fabro-config/src/server.rs +++ /dev/null @@ -1,23 +0,0 @@ -//! Re-export shim for server settings types. -//! -//! Stage 3 removed the parse-time `*Config` types (`ApiConfig`, `GitConfig`, -//! etc.) in favor of the v2 parse tree in `fabro_types::settings::v2::server`. -//! This module stays alive as a pass-through for crates that still import -//! resolved server types via the legacy `fabro_config::server` path; -//! Stage 6.4 deletes it. - -use std::path::PathBuf; - -use fabro_types::settings::v2::SettingsFile; - -pub use fabro_types::settings::server::{ - ApiAuthStrategy, ApiSettings, ArtifactStorageBackend, ArtifactStorageSettings, AuthProvider, - AuthSettings, FeaturesSettings, GitAuthorSettings, GitProvider, GitSettings, LogSettings, - SlackSettings, TlsSettings, WebSettings, WebhookSettings, WebhookStrategy, -}; - -/// Resolve the storage directory: config value > default `~/.fabro`. -#[must_use] -pub fn resolve_storage_dir(settings: &SettingsFile) -> PathBuf { - settings.storage_dir() -} diff --git a/lib/crates/fabro-config/src/user.rs b/lib/crates/fabro-config/src/user.rs index f72d3e0f3..a979cf93d 100644 --- a/lib/crates/fabro-config/src/user.rs +++ b/lib/crates/fabro-config/src/user.rs @@ -1,8 +1,8 @@ //! User config loading. //! -//! Stage 3 removed the parse-time `ClientTlsConfig`/`ServerConfig`/`ExecConfig` -//! types; this module now only exposes machine-level settings loading plus -//! path helpers and a re-export of the resolved user-facing types. +//! Exposes machine-level settings loading plus path helpers for the +//! `~/.fabro/settings.toml` file. Runtime types that used to be +//! re-exported from here live in `fabro_types::settings::user` now. use std::collections::HashSet; use std::path::{Path, PathBuf}; @@ -11,10 +11,6 @@ use std::sync::{Mutex, OnceLock}; use crate::config::ConfigLayer; use crate::home::Home; -pub use fabro_types::settings::user::{ - ClientTlsSettings, ExecSettings, OutputFormat, PermissionLevel, ServerSettings, -}; - pub const SETTINGS_CONFIG_FILENAME: &str = "settings.toml"; pub const LEGACY_USER_CONFIG_FILENAME: &str = "cli.toml"; pub const LEGACY_OLD_USER_CONFIG_FILENAME: &str = "user.toml"; diff --git a/lib/crates/fabro-hooks/src/config.rs b/lib/crates/fabro-hooks/src/config.rs index 72f52ca45..8d055984f 100644 --- a/lib/crates/fabro-hooks/src/config.rs +++ b/lib/crates/fabro-hooks/src/config.rs @@ -1 +1 @@ -pub use fabro_config::hook::{HookDefinition, HookEvent, HookSettings, HookType, TlsMode}; +pub use fabro_types::settings::hook::{HookDefinition, HookEvent, HookSettings, HookType, TlsMode}; diff --git a/lib/crates/fabro-hooks/src/types.rs b/lib/crates/fabro-hooks/src/types.rs index a976bbe84..ca7eeaa30 100644 --- a/lib/crates/fabro-hooks/src/types.rs +++ b/lib/crates/fabro-hooks/src/types.rs @@ -1,4 +1,4 @@ -pub use fabro_config::hook::HookEvent; +pub use fabro_types::settings::hook::HookEvent; use fabro_types::RunId; use serde::{Deserialize, Serialize}; diff --git a/lib/crates/fabro-mcp/src/config.rs b/lib/crates/fabro-mcp/src/config.rs index 38da06fcb..feed3623f 100644 --- a/lib/crates/fabro-mcp/src/config.rs +++ b/lib/crates/fabro-mcp/src/config.rs @@ -1,4 +1,4 @@ -pub use fabro_config::mcp::{ +pub use fabro_types::settings::mcp::{ McpServerEntry, McpServerSettings, McpTransport, default_startup_timeout_secs, default_tool_timeout_secs, }; diff --git a/lib/crates/fabro-sandbox/src/daytona/mod.rs b/lib/crates/fabro-sandbox/src/daytona/mod.rs index 802fb91e4..1d58f8da3 100644 --- a/lib/crates/fabro-sandbox/src/daytona/mod.rs +++ b/lib/crates/fabro-sandbox/src/daytona/mod.rs @@ -22,7 +22,7 @@ use tokio_util::sync::CancellationToken; const WORKING_DIRECTORY: &str = "/home/daytona/workspace"; const DEFAULT_SNAPSHOT: &str = "daytona-medium"; -pub use fabro_config::sandbox::{ +pub use fabro_types::settings::sandbox::{ DaytonaNetwork, DaytonaSettings as DaytonaConfig, DaytonaSnapshotSettings as DaytonaSnapshotConfig, DockerfileSource, }; diff --git a/lib/crates/fabro-server/src/demo/mod.rs b/lib/crates/fabro-server/src/demo/mod.rs index 60ff9c54a..024a5244c 100644 --- a/lib/crates/fabro-server/src/demo/mod.rs +++ b/lib/crates/fabro-server/src/demo/mod.rs @@ -1330,16 +1330,16 @@ mod runs { goal: Some("Add rate limiting to auth endpoints".into()), graph: Some("implement.fabro".into()), work_dir: Some("/workspace/api-server".into()), - llm: Some(fabro_config::run::LlmSettings { + llm: Some(fabro_types::settings::run::LlmSettings { model: Some("claude-opus-4-6".into()), provider: Some("anthropic".into()), fallbacks: None, }), - setup: Some(fabro_config::run::SetupSettings { + setup: Some(fabro_types::settings::run::SetupSettings { commands: vec!["bun install".into(), "bun run typecheck".into()], timeout_ms: Some(120_000), }), - sandbox: Some(fabro_config::sandbox::SandboxSettings { + sandbox: Some(fabro_types::settings::sandbox::SandboxSettings { provider: Some("daytona".into()), preserve: None, devcontainer: None, @@ -1489,8 +1489,8 @@ mod insights { } mod settings { - use fabro_config::server::*; use fabro_types::Settings; + use fabro_types::settings::server::*; pub(super) fn server_settings() -> serde_json::Value { serde_json::to_value(Settings { @@ -1522,13 +1522,13 @@ mod settings { retros: false, }), log: Default::default(), - llm: Some(fabro_config::run::LlmSettings { + llm: Some(fabro_types::settings::run::LlmSettings { model: Some("claude-sonnet".into()), provider: Some("anthropic".into()), fallbacks: None, }), setup: None, - sandbox: Some(fabro_config::sandbox::SandboxSettings { + sandbox: Some(fabro_types::settings::sandbox::SandboxSettings { provider: Some("daytona".into()), preserve: None, devcontainer: None, diff --git a/lib/crates/fabro-server/src/jwt_auth.rs b/lib/crates/fabro-server/src/jwt_auth.rs index 79adaa30e..698b1a06d 100644 --- a/lib/crates/fabro-server/src/jwt_auth.rs +++ b/lib/crates/fabro-server/src/jwt_auth.rs @@ -10,8 +10,8 @@ use tracing::warn; use crate::error::ApiError; use crate::web_auth::SessionCookie; -use fabro_config::server::ApiSettings; use fabro_types::RunAuthMethod; +use fabro_types::settings::server::ApiSettings; /// JWT claims for service-to-service authentication. #[derive(Debug, Deserialize)] @@ -86,7 +86,7 @@ pub fn resolve_auth_mode_with_lookup( where F: Fn(&str) -> Option, { - use fabro_config::server::ApiAuthStrategy; + use fabro_types::settings::server::ApiAuthStrategy; if api_settings.authentication_strategies.is_empty() && std::env::var("FABRO_LOCAL_NO_AUTH").ok().as_deref() == Some("1") diff --git a/lib/crates/fabro-server/src/lib.rs b/lib/crates/fabro-server/src/lib.rs index 98cf30988..85e49717a 100644 --- a/lib/crates/fabro-server/src/lib.rs +++ b/lib/crates/fabro-server/src/lib.rs @@ -16,8 +16,8 @@ pub mod serve; pub mod server; pub mod static_files; pub mod server_config { - pub use fabro_config::server::*; pub use fabro_types::Settings; + pub use fabro_types::settings::server::*; } pub mod tls; pub mod web_auth; diff --git a/lib/crates/fabro-server/src/serve.rs b/lib/crates/fabro-server/src/serve.rs index 6d690201e..df6d75322 100644 --- a/lib/crates/fabro-server/src/serve.rs +++ b/lib/crates/fabro-server/src/serve.rs @@ -3,8 +3,9 @@ use std::sync::{Arc, RwLock}; use std::time::Duration; use fabro_config::Storage; -use fabro_config::server::{ApiSettings, resolve_storage_dir}; +use fabro_config::resolve_storage_dir; use fabro_config::user::{active_settings_path, load_settings_config}; +use fabro_types::settings::server::ApiSettings; use fabro_util::terminal::Styles; use object_store::ObjectStore; use object_store::aws::AmazonS3Builder; @@ -89,7 +90,7 @@ fn load_settings(path: Option<&Path>) -> anyhow::Result { /// v2 tree. Stage 6.6 replaces this with a v2-aware auth resolver and drops /// the legacy `ApiSettings` type entirely. fn build_legacy_api_settings(file: &SettingsFile) -> ApiSettings { - use fabro_config::server::{ApiAuthStrategy, TlsSettings}; + use fabro_types::settings::server::{ApiAuthStrategy, TlsSettings}; use fabro_types::settings::v2::interp::InterpString; use fabro_types::settings::v2::server::ServerListenLayer; diff --git a/lib/crates/fabro-server/src/tls.rs b/lib/crates/fabro-server/src/tls.rs index 05733d5d9..0009253ca 100644 --- a/lib/crates/fabro-server/src/tls.rs +++ b/lib/crates/fabro-server/src/tls.rs @@ -8,7 +8,7 @@ use rustls_pki_types::{CertificateDer, PrivateKeyDer}; use tokio::net::TcpListener; use tracing::error; -use fabro_config::server::TlsSettings; +use fabro_types::settings::server::TlsSettings; use crate::jwt_auth::PeerCertificates; diff --git a/lib/crates/fabro-server/tests/it/openapi_conformance.rs b/lib/crates/fabro-server/tests/it/openapi_conformance.rs index 33fc5e158..2c41c72d2 100644 --- a/lib/crates/fabro-server/tests/it/openapi_conformance.rs +++ b/lib/crates/fabro-server/tests/it/openapi_conformance.rs @@ -12,13 +12,13 @@ use std::collections::BTreeSet; use axum::body::Body; use axum::http::{Method, Request, StatusCode}; -use fabro_config::run::*; -use fabro_config::sandbox::SandboxSettings; use fabro_hooks::*; use fabro_sandbox::daytona::*; use fabro_server::jwt_auth::AuthMode; use fabro_server::server::build_router; use fabro_server::server_config::*; +use fabro_types::settings::run::*; +use fabro_types::settings::sandbox::SandboxSettings; use fabro_types::settings::{ ClientTlsSettings, ExecSettings, OutputFormat, PermissionLevel, ProjectSettings, ServerSettings as UserServerSettings, @@ -398,13 +398,13 @@ fn fully_populated_server_config() -> Settings { ], mcp_servers: std::collections::HashMap::from([( "test".into(), - fabro_config::mcp::McpServerEntry { - transport: fabro_config::mcp::McpTransport::Stdio { + fabro_types::settings::mcp::McpServerEntry { + transport: fabro_types::settings::mcp::McpTransport::Stdio { command: vec!["echo".into()], env: Default::default(), }, - startup_timeout_secs: fabro_config::mcp::default_startup_timeout_secs(), - tool_timeout_secs: fabro_config::mcp::default_tool_timeout_secs(), + startup_timeout_secs: fabro_types::settings::mcp::default_startup_timeout_secs(), + tool_timeout_secs: fabro_types::settings::mcp::default_tool_timeout_secs(), }, )]), github: Some(GitHubSettings { diff --git a/lib/crates/fabro-workflow/src/operations/start.rs b/lib/crates/fabro-workflow/src/operations/start.rs index 2a0d8e43f..7944fb576 100644 --- a/lib/crates/fabro-workflow/src/operations/start.rs +++ b/lib/crates/fabro-workflow/src/operations/start.rs @@ -4,12 +4,12 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; -use fabro_config::sandbox::WorktreeMode; -use fabro_config::{project as project_config, sandbox as sandbox_config}; +use fabro_config::project as project_config; use fabro_interview::{AutoApproveInterviewer, Interviewer}; use fabro_model::{Catalog, FallbackTarget, Provider}; use fabro_sandbox::{SandboxProvider, SandboxSpec}; use fabro_types::RunId; +use fabro_types::settings::sandbox::{self as sandbox_config, WorktreeMode}; use fabro_types::settings::v2::run::ModelRefOrSplice; use fabro_types::settings::v2::to_runtime::{ bridge_hook, bridge_mcp_entry, bridge_pull_request, bridge_sandbox, bridge_worktree_mode, @@ -36,10 +36,10 @@ use crate::run_options::{GitCheckpointOptions, LifecycleOptions, RunOptions}; use crate::run_status::{RunStatus, StatusReason}; use crate::runtime_store::RunStoreHandle; use crate::workflow_bundle::{RunDefinition, WorkflowBundle}; -use fabro_config::run::PullRequestSettings; use fabro_retro::retro::Retro; use fabro_sandbox::daytona::DaytonaConfig; use fabro_sandbox::daytona::detect_repo_info; +use fabro_types::settings::run::PullRequestSettings; use tokio::runtime::Handle; struct RunSession { diff --git a/lib/crates/fabro-workflow/src/pipeline/pull_request.rs b/lib/crates/fabro-workflow/src/pipeline/pull_request.rs index e02cdf255..21eb8d84d 100644 --- a/lib/crates/fabro-workflow/src/pipeline/pull_request.rs +++ b/lib/crates/fabro-workflow/src/pipeline/pull_request.rs @@ -1,6 +1,6 @@ -use fabro_config::run::MergeStrategy; use fabro_store::RunProjection; use fabro_types::PullRequestRecord; +use fabro_types::settings::run::MergeStrategy; use tracing::{debug, info}; use fabro_github::{self as github_app, GitHubAppCredentials, ssh_url_to_https}; diff --git a/lib/crates/fabro-workflow/src/pipeline/types.rs b/lib/crates/fabro-workflow/src/pipeline/types.rs index c2ae831aa..d070fd0db 100644 --- a/lib/crates/fabro-workflow/src/pipeline/types.rs +++ b/lib/crates/fabro-workflow/src/pipeline/types.rs @@ -3,7 +3,6 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use fabro_agent::Sandbox; -use fabro_config::sandbox::WorktreeMode; use fabro_graphviz::graph::Graph; use fabro_hooks::HookRunner; use fabro_interview::Interviewer; @@ -12,6 +11,7 @@ use fabro_mcp::config::McpServerSettings; use fabro_model::FallbackTarget; use fabro_sandbox::SandboxSpec; use fabro_types::RunId; +use fabro_types::settings::sandbox::WorktreeMode; use fabro_validate::Diagnostic; use crate::artifact_upload::ArtifactSink; @@ -27,9 +27,9 @@ use crate::run_options::{GitCheckpointOptions, LifecycleOptions, RunOptions}; use crate::runtime_store::RunStoreHandle; use crate::transforms::Transform; use crate::workflow_bundle::WorkflowBundle; -use fabro_config::run::PullRequestSettings; use fabro_llm::client::Client; use fabro_retro::retro::Retro; +use fabro_types::settings::run::PullRequestSettings; use fabro_validate::Severity; /// Output of the PARSE phase. From ace24c410fbf6dd01bb93d07fbfd7f68b6106829 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 9 Apr 2026 16:33:41 -0400 Subject: [PATCH 23/47] refactor(types): stage 6.5 promote v2 types to settings top level Stage 6.5 can't flatten the `settings::v2::*` module tree onto `settings::*` files wholesale because the v2 submodules (`project.rs`, `run.rs`, `server.rs`) share filenames with the legacy flat type modules that are still required by the OpenAPI legacy `ServerSettings` response path (Stage 6.3 / 6.6 deletes them). As the feasible piece of Stage 6.5 work: - Re-export the v2 top-level type aliases from `fabro_types::settings` so consumers can write `fabro_types::settings::SettingsFile`, `fabro_types::settings::InterpString`, `fabro_types::settings::Duration`, etc. without the `::v2::` prefix. - The re-export covers the whole public v2 surface: `{CURRENT_VERSION, CliLayer, Duration, FeaturesLayer, InterpString, ModelRef, ParseDurationError, ParseError, ParseModelRefError, ParseSizeError, ProjectLayer, Provenance, ResolveEnvError, Resolved, ResolvedModelRef, RunLayer, SchemaVersion, ServerLayer, SettingsFile, Size, SpliceArray, SpliceArrayError, VersionError, WorkflowLayer, parse_settings_file, validate_version}`. The `v2` module itself stays in place to host the submodule tree (accessors, to_runtime, run::*, cli::*, server::*, interp, etc.) until Stage 6.3 finishes deleting the conflicting legacy files, at which point the v2/ directory can be promoted to replace them. Build, clippy, fmt, and 3756 / 3756 tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/crates/fabro-types/src/settings/mod.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/lib/crates/fabro-types/src/settings/mod.rs b/lib/crates/fabro-types/src/settings/mod.rs index 4787e9b12..b8438e881 100644 --- a/lib/crates/fabro-types/src/settings/mod.rs +++ b/lib/crates/fabro-types/src/settings/mod.rs @@ -53,6 +53,22 @@ pub use server::{ }; pub use user::{ClientTlsSettings, ExecSettings, OutputFormat, PermissionLevel, ServerSettings}; +// v2 top-level re-exports. Stage 6.5 of the settings TOML redesign +// promoted the v2 namespaced parse tree to be the primary API surface; +// consumers can now write `fabro_types::settings::SettingsFile` / +// `fabro_types::settings::InterpString` / `fabro_types::settings::Duration` +// without the `::v2::` prefix. The `v2` module itself stays until the +// remaining legacy files under `settings/{project,run,server,...}.rs` +// are deleted in Stage 6.3, because the v2 submodules and the legacy +// submodules share those file names. +pub use v2::{ + CURRENT_VERSION, CliLayer, Duration, FeaturesLayer, InterpString, ModelRef, ParseDurationError, + ParseError, ParseModelRefError, ParseSizeError, ProjectLayer, Provenance, ResolveEnvError, + Resolved, ResolvedModelRef, RunLayer, SchemaVersion, ServerLayer, SettingsFile, Size, + SpliceArray, SpliceArrayError, VersionError, WorkflowLayer, parse_settings_file, + validate_version, +}; + fn is_default_checkpoint(c: &CheckpointSettings) -> bool { c.exclude_globs.is_empty() } From 7c8448ece83ea83a6e2b253cd8e311fd4626bfbf Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 9 Apr 2026 17:04:33 -0400 Subject: [PATCH 24/47] refactor(api): stage 6.6 collapse settings DTOs to freeform v2 shape Replaces the legacy flat `ServerSettings` / `RunSettings` schemas in `docs/api-reference/fabro-api.yaml` and 20+ supporting nested type schemas (LlmSettings, SandboxSettings, HookDefinition, WebSettings, ApiSettings, GitSettings, McpServerEntry, etc.) with two simple `type: object, additionalProperties: true` schemas that declare the wire shape as the v2 `SettingsFile` tree with secret-bearing subtrees dropped before serialization. Regenerates the Rust progenitor and TypeScript Axios clients against the new spec. The progenitor generates `RunSettings` / `ServerSettings` as `#[serde(transparent)]` newtypes over `serde_json::Map`; the openapi-generator emits `{ [key: string]: any; }` inlined into the API method signatures and no longer exports named model types. Updates fabro-web to define local `type ServerSettings = Record` / `type RunSettings = Record` aliases since the generated client no longer exports them. The UI only `JSON.stringify`s these payloads into a CollapsibleFile, so the opaque shape is fine. All 3,756 workspace tests remain green. The OpenAPI conformance test `server_settings_keys_match_openapi_spec` still passes because `compare_schema` short-circuits on pure-map schemas (no `properties`); it becomes a no-op that will be removed entirely when Stage 6.3b deletes the legacy flat `Settings` struct it still builds. Unblocks the server handler + CLI migration in the next commits of Stage 6.6. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/fabro-web/app/lib/workflow-api.ts | 10 +- apps/fabro-web/app/routes/run-settings.tsx | 3 +- apps/fabro-web/app/routes/settings.tsx | 8 +- apps/fabro-web/app/routes/workflow-detail.tsx | 3 +- docs/api-reference/fabro-api.yaml | 607 +----------------- .../src/.openapi-generator/FILES | 29 - .../src/api/run-internals-api.ts | 15 +- .../fabro-api-client/src/api/settings-api.ts | 6 +- .../src/models/api-configuration.ts | 42 -- .../src/models/api-question.ts | 12 + .../src/models/api-settings.ts | 42 -- .../src/models/artifact-batch-upload-entry.ts | 3 +- .../models/artifact-batch-upload-manifest.ts | 3 +- .../src/models/artifacts-configuration.ts | 26 - .../src/models/artifacts-settings.ts | 26 - .../src/models/auth-configuration.ts | 38 -- .../src/models/auth-settings.ts | 38 -- .../src/models/checkpoint-configuration.ts | 26 - .../src/models/checkpoint-settings.ts | 26 - .../src/models/create-run-request.ts | 42 -- .../daytona-configuration-network-one-of.ts | 23 - .../models/daytona-configuration-network.ts | 26 - .../src/models/daytona-configuration.ts | 38 -- .../models/daytona-settings-network-one-of.ts | 23 - .../src/models/daytona-settings-network.ts | 26 - .../src/models/daytona-settings.ts | 42 -- .../models/daytona-snapshot-configuration.ts | 42 -- .../src/models/daytona-snapshot-settings.ts | 42 -- .../src/models/exe-configuration.ts | 26 - .../src/models/exe-settings.ts | 26 - .../fabro-api-client/src/models/features.ts | 30 - .../src/models/git-author-configuration.ts | 30 - .../src/models/git-author-settings.ts | 30 - .../src/models/git-configuration.ts | 53 -- .../src/models/git-hub-configuration.ts | 26 - .../src/models/git-hub-settings.ts | 26 - .../src/models/git-settings.ts | 53 -- .../src/models/hook-definition.ts | 107 --- .../fabro-api-client/src/models/index.ts | 29 - .../src/models/llm-configuration.ts | 34 - .../src/models/llm-settings.ts | 34 - .../src/models/local-sandbox-configuration.ts | 36 -- .../src/models/local-sandbox-settings.ts | 36 -- .../src/models/log-configuration.ts | 26 - .../src/models/log-settings.ts | 26 - .../src/models/mcp-server-entry.ts | 50 -- .../src/models/pull-request-configuration.ts | 30 - .../src/models/pull-request-settings.ts | 47 -- .../src/models/run-configuration.ts | 57 -- .../src/models/run-settings.ts | 58 -- .../src/models/sandbox-configuration.ts | 54 -- .../src/models/sandbox-settings.ts | 46 -- .../src/models/server-configuration.ts | 96 --- .../src/models/server-settings-exec.ts | 50 -- .../src/models/server-settings-fabro.ts | 23 - .../src/models/server-settings-server-tls.ts | 31 - .../src/models/server-settings-server.ts | 27 - .../src/models/server-settings.ts | 153 ----- .../src/models/setup-configuration.ts | 30 - .../src/models/setup-settings.ts | 30 - .../src/models/ssh-configuration.ts | 34 - .../src/models/ssh-settings.ts | 34 - .../src/models/tls-configuration.ts | 34 - .../src/models/tls-settings.ts | 34 - .../src/models/web-configuration.ts | 30 - .../src/models/web-settings.ts | 30 - .../src/models/webhook-configuration.ts | 33 - .../src/models/webhook-settings.ts | 33 - 68 files changed, 73 insertions(+), 2866 deletions(-) delete mode 100644 lib/packages/fabro-api-client/src/models/api-configuration.ts delete mode 100644 lib/packages/fabro-api-client/src/models/api-settings.ts delete mode 100644 lib/packages/fabro-api-client/src/models/artifacts-configuration.ts delete mode 100644 lib/packages/fabro-api-client/src/models/artifacts-settings.ts delete mode 100644 lib/packages/fabro-api-client/src/models/auth-configuration.ts delete mode 100644 lib/packages/fabro-api-client/src/models/auth-settings.ts delete mode 100644 lib/packages/fabro-api-client/src/models/checkpoint-configuration.ts delete mode 100644 lib/packages/fabro-api-client/src/models/checkpoint-settings.ts delete mode 100644 lib/packages/fabro-api-client/src/models/create-run-request.ts delete mode 100644 lib/packages/fabro-api-client/src/models/daytona-configuration-network-one-of.ts delete mode 100644 lib/packages/fabro-api-client/src/models/daytona-configuration-network.ts delete mode 100644 lib/packages/fabro-api-client/src/models/daytona-configuration.ts delete mode 100644 lib/packages/fabro-api-client/src/models/daytona-settings-network-one-of.ts delete mode 100644 lib/packages/fabro-api-client/src/models/daytona-settings-network.ts delete mode 100644 lib/packages/fabro-api-client/src/models/daytona-settings.ts delete mode 100644 lib/packages/fabro-api-client/src/models/daytona-snapshot-configuration.ts delete mode 100644 lib/packages/fabro-api-client/src/models/daytona-snapshot-settings.ts delete mode 100644 lib/packages/fabro-api-client/src/models/exe-configuration.ts delete mode 100644 lib/packages/fabro-api-client/src/models/exe-settings.ts delete mode 100644 lib/packages/fabro-api-client/src/models/features.ts delete mode 100644 lib/packages/fabro-api-client/src/models/git-author-configuration.ts delete mode 100644 lib/packages/fabro-api-client/src/models/git-author-settings.ts delete mode 100644 lib/packages/fabro-api-client/src/models/git-configuration.ts delete mode 100644 lib/packages/fabro-api-client/src/models/git-hub-configuration.ts delete mode 100644 lib/packages/fabro-api-client/src/models/git-hub-settings.ts delete mode 100644 lib/packages/fabro-api-client/src/models/git-settings.ts delete mode 100644 lib/packages/fabro-api-client/src/models/hook-definition.ts delete mode 100644 lib/packages/fabro-api-client/src/models/llm-configuration.ts delete mode 100644 lib/packages/fabro-api-client/src/models/llm-settings.ts delete mode 100644 lib/packages/fabro-api-client/src/models/local-sandbox-configuration.ts delete mode 100644 lib/packages/fabro-api-client/src/models/local-sandbox-settings.ts delete mode 100644 lib/packages/fabro-api-client/src/models/log-configuration.ts delete mode 100644 lib/packages/fabro-api-client/src/models/log-settings.ts delete mode 100644 lib/packages/fabro-api-client/src/models/mcp-server-entry.ts delete mode 100644 lib/packages/fabro-api-client/src/models/pull-request-configuration.ts delete mode 100644 lib/packages/fabro-api-client/src/models/pull-request-settings.ts delete mode 100644 lib/packages/fabro-api-client/src/models/run-configuration.ts delete mode 100644 lib/packages/fabro-api-client/src/models/run-settings.ts delete mode 100644 lib/packages/fabro-api-client/src/models/sandbox-configuration.ts delete mode 100644 lib/packages/fabro-api-client/src/models/sandbox-settings.ts delete mode 100644 lib/packages/fabro-api-client/src/models/server-configuration.ts delete mode 100644 lib/packages/fabro-api-client/src/models/server-settings-exec.ts delete mode 100644 lib/packages/fabro-api-client/src/models/server-settings-fabro.ts delete mode 100644 lib/packages/fabro-api-client/src/models/server-settings-server-tls.ts delete mode 100644 lib/packages/fabro-api-client/src/models/server-settings-server.ts delete mode 100644 lib/packages/fabro-api-client/src/models/server-settings.ts delete mode 100644 lib/packages/fabro-api-client/src/models/setup-configuration.ts delete mode 100644 lib/packages/fabro-api-client/src/models/setup-settings.ts delete mode 100644 lib/packages/fabro-api-client/src/models/ssh-configuration.ts delete mode 100644 lib/packages/fabro-api-client/src/models/ssh-settings.ts delete mode 100644 lib/packages/fabro-api-client/src/models/tls-configuration.ts delete mode 100644 lib/packages/fabro-api-client/src/models/tls-settings.ts delete mode 100644 lib/packages/fabro-api-client/src/models/web-configuration.ts delete mode 100644 lib/packages/fabro-api-client/src/models/web-settings.ts delete mode 100644 lib/packages/fabro-api-client/src/models/webhook-configuration.ts delete mode 100644 lib/packages/fabro-api-client/src/models/webhook-settings.ts diff --git a/apps/fabro-web/app/lib/workflow-api.ts b/apps/fabro-web/app/lib/workflow-api.ts index abad9388a..e73d79ed0 100644 --- a/apps/fabro-web/app/lib/workflow-api.ts +++ b/apps/fabro-web/app/lib/workflow-api.ts @@ -1,4 +1,12 @@ -import type { PaginationMeta, RunSettings } from "@qltysh/fabro-api-client"; +import type { PaginationMeta } from "@qltysh/fabro-api-client"; + +/** + * Opaque settings payload returned by `/api/v1/runs/:id/settings`. Mirrors the + * v2 `SettingsFile` shape in `lib/crates/fabro-types/src/settings/tree.rs`, + * with secret-bearing subtrees dropped before serialization. Treated as a + * loose JSON object on the web side — consumers only render it. + */ +export type RunSettings = Record; export interface WorkflowScheduleSummary { expression: string; diff --git a/apps/fabro-web/app/routes/run-settings.tsx b/apps/fabro-web/app/routes/run-settings.tsx index b29928e04..59d5d0f7b 100644 --- a/apps/fabro-web/app/routes/run-settings.tsx +++ b/apps/fabro-web/app/routes/run-settings.tsx @@ -4,7 +4,8 @@ import { DocumentTextIcon, MapIcon } from "@heroicons/react/24/outline"; import { CollapsibleFile } from "../components/collapsible-file"; import { apiJson } from "../api"; import { formatDurationSecs } from "../lib/format"; -import type { PaginatedRunStageList, RunSettings } from "@qltysh/fabro-api-client"; +import type { PaginatedRunStageList } from "@qltysh/fabro-api-client"; +import type { RunSettings } from "../lib/workflow-api"; export const handle = { wide: true }; diff --git a/apps/fabro-web/app/routes/settings.tsx b/apps/fabro-web/app/routes/settings.tsx index 4323e6c60..9feecd60a 100644 --- a/apps/fabro-web/app/routes/settings.tsx +++ b/apps/fabro-web/app/routes/settings.tsx @@ -1,6 +1,12 @@ import { apiJson } from "../api"; import { CollapsibleFile } from "../components/collapsible-file"; -import type { ServerSettings } from "@qltysh/fabro-api-client"; + +/** + * Opaque server settings payload returned by `/api/v1/settings`. Mirrors the + * v2 `SettingsFile` shape with secret-bearing subtrees dropped before + * serialization. The UI only renders it as JSON. + */ +type ServerSettings = Record; export function meta({}: any) { return [{ title: "Settings — Fabro" }]; diff --git a/apps/fabro-web/app/routes/workflow-detail.tsx b/apps/fabro-web/app/routes/workflow-detail.tsx index 735a7ca23..691a9ada6 100644 --- a/apps/fabro-web/app/routes/workflow-detail.tsx +++ b/apps/fabro-web/app/routes/workflow-detail.tsx @@ -1,8 +1,7 @@ import { ChevronRightIcon } from "@heroicons/react/20/solid"; import { Link, Outlet, useLocation, useParams } from "react-router"; import { apiJson } from "../api"; -import type { RunSettings } from "@qltysh/fabro-api-client"; -import type { WorkflowDetailResponse as ApiWorkflowDetail } from "../lib/workflow-api"; +import type { RunSettings, WorkflowDetailResponse as ApiWorkflowDetail } from "../lib/workflow-api"; export interface WorkflowEntry { name: string; diff --git a/docs/api-reference/fabro-api.yaml b/docs/api-reference/fabro-api.yaml index 76cbc8ffe..3df507c1f 100644 --- a/docs/api-reference/fabro-api.yaml +++ b/docs/api-reference/fabro-api.yaml @@ -3992,376 +3992,37 @@ components: # ── Settings Schemas ───────────────────────────────────────────────── - RunSettings: - description: Structured run settings mirroring fabro_types::Settings. - type: object - required: - - version - - graph - properties: - version: - type: integer - description: Settings schema version. - example: 1 - goal: - type: string - description: Goal description for the run. - example: Diagnose and fix CI build failures - graph: - type: string - description: Graphviz graph filename. - example: fix_build.fabro - work_dir: - type: string - description: Working directory for the run. - llm: - $ref: "#/components/schemas/LlmSettings" - setup: - $ref: "#/components/schemas/SetupSettings" - sandbox: - $ref: "#/components/schemas/SandboxSettings" - vars: - type: object - additionalProperties: - type: string - description: Variable map for template expansion. - hooks: - type: array - items: - $ref: "#/components/schemas/HookDefinition" - - LlmSettings: - description: LLM provider and model settings. - type: object - properties: - model: - type: string - description: Model identifier. - example: claude-sonnet - provider: - type: string - description: Provider name. - example: anthropic - fallbacks: - type: object - additionalProperties: - type: array - items: - type: string - description: Provider fallback chains. - - SetupSettings: - description: Setup commands run before the workflow. - type: object - required: - - commands - properties: - commands: - type: array - items: - type: string - description: Shell commands to execute. - timeout_ms: - type: integer - description: Timeout per command in milliseconds. - - SandboxSettings: - description: Sandbox execution environment settings. - type: object - properties: - provider: - type: string - description: Sandbox provider name. - example: daytona - preserve: - type: boolean - description: Whether to preserve the sandbox after the run. - devcontainer: - type: boolean - description: Whether to use a devcontainer for the sandbox. - daytona: - $ref: "#/components/schemas/DaytonaSettings" - local: - $ref: "#/components/schemas/LocalSandboxSettings" - env: - type: object - additionalProperties: - type: string - description: Environment variables injected into the sandbox. - - LocalSandboxSettings: - description: Local sandbox settings. - type: object - properties: - worktree_mode: - type: string - description: Git worktree mode for local sandbox. - enum: [always, clean, dirty, never] - default: clean - - DaytonaSettings: - description: Daytona-specific sandbox settings. - type: object - properties: - auto_stop_interval: - type: integer - description: Auto-stop interval in seconds. - labels: - type: object - additionalProperties: - type: string - description: Labels applied to the sandbox. - snapshot: - $ref: "#/components/schemas/DaytonaSnapshotSettings" - network: - description: "Network access mode: \"block\", \"allow_all\", or {\"allow_list\": [...]}." - oneOf: - - type: string - enum: - - block - - allow_all - - type: object - required: - - allow_list - properties: - allow_list: - type: array - items: - type: string - description: CIDR allowlist for network access. - skip_clone: - type: boolean - default: false - description: Skip git repo detection and cloning during initialization. - - DaytonaSnapshotSettings: - description: Snapshot configuration for Daytona sandboxes. - type: object - required: - - name - properties: - name: - type: string - description: Snapshot name. - cpu: - type: integer - description: CPU cores. - memory: - type: integer - description: Memory in GB. - disk: - type: integer - description: Disk in GB. - dockerfile: - type: string - description: Dockerfile content for snapshot creation. - - HookDefinition: - description: | - A single hook definition. The type discriminator and variant fields are flattened into this object. - - Field-to-type mapping: - - `command`: requires `command` - - `http`: requires `url`; optional `headers`, `allowed_env_vars`, `tls` - - `prompt`: requires `prompt`; optional `model` - - `agent`: requires `prompt`; optional `model`, `max_tool_rounds` - - Top-level `command` without `type` is shorthand for type=command. - type: object - required: - - event - properties: - name: - type: string - description: Human-readable hook name. - event: - type: string - description: Event that triggers this hook. - enum: - - run_start - - run_complete - - stage_start - - stage_complete - command: - type: string - description: Shell command (shorthand for type=command). - type: - type: string - description: Hook execution type. - enum: - - command - - http - - prompt - - agent - url: - type: string - description: URL for HTTP hooks. - headers: - type: object - additionalProperties: - type: string - description: Headers for HTTP hooks. - allowed_env_vars: - type: array - items: - type: string - description: Environment variables allowed in HTTP hook headers. - tls: - type: string - description: TLS verification mode for HTTP hooks. - enum: - - verify - - no_verify - - "off" - prompt: - type: string - description: Prompt text for prompt/agent hooks. - model: - type: string - description: Model for prompt/agent hooks. - max_tool_rounds: - type: integer - description: Max tool rounds for agent hooks. - matcher: - type: string - description: Regex matched against node_id or handler_type. - blocking: - type: boolean - description: Whether this hook blocks execution. - timeout_ms: - type: integer - description: Timeout in milliseconds. - sandbox: - type: boolean - description: Whether hook runs in sandbox. - ServerSettings: - description: Structured server settings mirroring fabro_types::Settings. + description: | + Non-secret view of the server's effective v2 settings. + + Wire shape mirrors `fabro_types::settings::SettingsFile` with the + secret-bearing subtrees dropped before serialization: + + - `server.listen.*` (bind address, TLS key material) + - `server.auth.api.{jwt,mtls}` internals + - `server.artifacts.s3` / `server.slatedb.s3` credentials + - `server.integrations.github.webhooks`, Slack/Discord/Teams tokens + - Every `${env.NAME}` InterpString is serialized in its unresolved + template form, never the resolved secret value. + + The top-level object keys follow the v2 schema: `_version`, `project`, + `workflow`, `run`, `cli`, `server`, `features`. + + See `lib/crates/fabro-types/src/settings/tree.rs` for the full type. type: object - properties: - version: - type: integer - description: Settings schema version. - goal: - type: string - description: Default goal description. - goal_file: - type: string - description: Path to a goal file. - graph: - type: string - description: Default Graphviz graph path. - labels: - type: object - additionalProperties: - type: string - description: Default label map. - server: - type: object - properties: - target: - type: string - description: Default server target for CLI commands. - tls: - type: object - properties: - cert: - type: string - description: Client certificate path. - key: - type: string - description: Client key path. - ca: - type: string - description: Certificate authority path. - exec: - type: object - properties: - provider: - type: string - description: Default exec provider. - model: - type: string - description: Default exec model. - permissions: - type: string - enum: [read-only, read-write, full] - description: Exec permission level. - output_format: - type: string - enum: [text, json] - description: Exec output format. - prevent_idle_sleep: - type: boolean - description: Prevent system idle sleep while running. - verbose: - type: boolean - description: Enable verbose output by default. - upgrade_check: - type: boolean - description: Whether upgrade checks are enabled. - dry_run: - type: boolean - description: Default dry-run mode. - auto_approve: - type: boolean - description: Default auto-approve mode. - no_retro: - type: boolean - description: Skip retro generation by default. - storage_dir: - type: string - description: Storage directory path. - max_concurrent_runs: - type: integer - description: Maximum concurrent runs. - web: - $ref: "#/components/schemas/WebSettings" - api: - $ref: "#/components/schemas/ApiSettings" - git: - $ref: "#/components/schemas/GitSettings" - features: - $ref: "#/components/schemas/Features" - log: - $ref: "#/components/schemas/LogSettings" - work_dir: - type: string - description: Default working directory. - llm: - $ref: "#/components/schemas/LlmSettings" - setup: - $ref: "#/components/schemas/SetupSettings" - sandbox: - $ref: "#/components/schemas/SandboxSettings" - vars: - type: object - additionalProperties: - type: string - description: Default variable map. - checkpoint: - $ref: "#/components/schemas/CheckpointSettings" - pull_request: - $ref: "#/components/schemas/PullRequestSettings" - hooks: - type: array - items: - $ref: "#/components/schemas/HookDefinition" - artifacts: - $ref: "#/components/schemas/ArtifactsSettings" - mcp_servers: - type: object - additionalProperties: - $ref: "#/components/schemas/McpServerEntry" - description: Default MCP server configurations. - github: - $ref: "#/components/schemas/GitHubSettings" - fabro: - type: object - properties: - root: - type: string - description: Project fabro root directory. + additionalProperties: true + + RunSettings: + description: | + The merged, persisted v2 `[run]` subtree for a specific run, serialized + as the wrapping `SettingsFile` shape (so `settings.run.*` holds the run + config). Matches `fabro_types::settings::SettingsFile` minus secret + subtrees, identical to ServerSettings' redaction rules. + + See `lib/crates/fabro-types/src/settings/run.rs` for the full type. + type: object + additionalProperties: true SystemInfoResponse: description: Runtime information for the active Fabro server process. @@ -4561,216 +4222,6 @@ components: format: int64 description: Bytes used by the run scratch directory. - GitHubSettings: - description: GitHub App token injection configuration. - type: object - properties: - permissions: - type: object - additionalProperties: - type: string - description: GitHub API permissions to request (e.g. contents = write). - - McpServerEntry: - description: MCP server connection entry. - type: object - properties: - type: - type: string - description: Transport type (stdio or http). - command: - type: array - items: - type: string - description: Command and arguments for stdio transport. - env: - type: object - additionalProperties: - type: string - description: Environment variables for stdio transport. - url: - type: string - description: URL for http transport. - headers: - type: object - additionalProperties: - type: string - description: HTTP headers for http transport. - startup_timeout_secs: - type: integer - description: Startup timeout in seconds. - tool_timeout_secs: - type: integer - description: Tool call timeout in seconds. - - ArtifactsSettings: - description: Artifact collection configuration. - type: object - properties: - include: - type: array - items: - type: string - description: Glob patterns for files to collect as run artifacts. - - LogSettings: - description: Logging configuration. - type: object - properties: - level: - type: string - description: Log level (e.g. trace, debug, info). - - CheckpointSettings: - description: Checkpoint configuration for file exclusion. - type: object - properties: - exclude_globs: - type: array - items: - type: string - description: Glob patterns to exclude from checkpoints. - - PullRequestSettings: - description: Pull request creation configuration. - type: object - properties: - enabled: - type: boolean - description: Whether to create a pull request after a successful run. - draft: - type: boolean - description: Whether to create the pull request as a draft. - auto_merge: - type: boolean - description: Whether to enable GitHub auto-merge on the created PR. Implies draft = false. - merge_strategy: - type: string - enum: [squash, merge, rebase] - description: Merge strategy for auto-merge. - - WebSettings: - description: Web UI configuration. - type: object - properties: - enabled: - type: boolean - description: Whether the embedded web UI and browser-oriented routes are enabled. - url: - type: string - description: Web UI URL. - auth: - $ref: "#/components/schemas/AuthSettings" - - AuthSettings: - description: Authentication configuration. - type: object - properties: - provider: - type: string - description: Auth provider. - enum: - - github - - insecure_disabled - allowed_usernames: - type: array - items: - type: string - description: Allowed usernames. - - ApiSettings: - description: API server configuration. - type: object - properties: - base_url: - type: string - description: API base URL. - authentication_strategies: - type: array - items: - type: string - enum: - - jwt - - mtls - description: Authentication strategies. - tls: - $ref: "#/components/schemas/TlsSettings" - - TlsSettings: - description: TLS certificate configuration. - type: object - required: - - cert - - key - - ca - properties: - cert: - type: string - description: Certificate file path. - key: - type: string - description: Key file path. - ca: - type: string - description: CA certificate file path. - - GitSettings: - description: Git provider configuration. - type: object - properties: - provider: - type: string - description: Git provider. - enum: - - github - app_id: - type: string - description: GitHub App ID. - client_id: - type: string - description: GitHub App Client ID. - slug: - type: string - description: GitHub App slug. - author: - $ref: "#/components/schemas/GitAuthorSettings" - webhooks: - $ref: "#/components/schemas/WebhookSettings" - - GitAuthorSettings: - description: Git commit author configuration. - type: object - properties: - name: - type: string - description: Author name for commits. - email: - type: string - description: Author email for commits. - - WebhookSettings: - description: Webhook delivery configuration. - type: object - required: - - strategy - properties: - strategy: - type: string - description: Webhook delivery strategy. - enum: - - tailscale_funnel - - Features: - description: Feature flags. - type: object - properties: - session_sandboxes: - type: boolean - description: Enable session sandboxes. - retros: - type: boolean - description: "Experimental: enable automatic retro generation after workflow runs." - # ── Discovery Schemas ──────────────────────────────────────────────── RootResponseUrls: diff --git a/lib/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES index f894e9b11..0a5741173 100644 --- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES +++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES @@ -20,22 +20,18 @@ models/aggregate-billing-totals.ts models/aggregate-billing.ts models/api-question-option.ts models/api-question.ts -models/api-settings.ts models/append-event-response.ts models/artifact-batch-upload-entry.ts models/artifact-batch-upload-manifest.ts models/artifact-entry.ts models/artifact-list-response.ts -models/artifacts-settings.ts models/assistant-stage-turn.ts -models/auth-settings.ts models/billed-token-counts.ts models/billing-by-model.ts models/billing-stage-ref.ts models/board-column.ts models/check-run-status.ts models/check-run.ts -models/checkpoint-settings.ts models/code-location.ts models/completion-content-part.ts models/completion-message.ts @@ -44,10 +40,6 @@ models/completion-tool-choice.ts models/completion-tool-definition.ts models/completion-usage.ts models/create-completion-request.ts -models/daytona-settings-network-one-of.ts -models/daytona-settings-network.ts -models/daytona-settings.ts -models/daytona-snapshot-settings.ts models/diagnostics-check.ts models/diagnostics-detail.ts models/diagnostics-report.ts @@ -63,21 +55,13 @@ models/event-envelope.ts models/execute-query-request.ts models/execute-query-response-rows-inner-inner.ts models/execute-query-response.ts -models/features.ts models/file-checkpoint.ts models/file-diff.ts -models/git-author-settings.ts -models/git-hub-settings.ts -models/git-settings.ts models/health-response.ts models/history-entry.ts -models/hook-definition.ts models/index.ts models/internal-run-status.ts models/internal-stage-status.ts -models/llm-settings.ts -models/local-sandbox-settings.ts -models/log-settings.ts models/manifest-args.ts models/manifest-config.ts models/manifest-file-entry.ts @@ -87,7 +71,6 @@ models/manifest-goal.ts models/manifest-target.ts models/manifest-workflow-config.ts models/manifest-workflow.ts -models/mcp-server-entry.ts models/model-costs.ts models/model-features.ts models/model-limits.ts @@ -118,7 +101,6 @@ models/preview-url-response.ts models/prune-run-entry.ts models/prune-runs-request.ts models/prune-runs-response.ts -models/pull-request-settings.ts models/question-type.ts models/render-workflow-graph-direction.ts models/render-workflow-graph-format.ts @@ -145,7 +127,6 @@ models/run-pull-request.ts models/run-question.ts models/run-reference.ts models/run-sandbox.ts -models/run-settings.ts models/run-stage.ts models/run-status-record.ts models/run-status-response.ts @@ -154,18 +135,11 @@ models/run-timings.ts models/sandbox-file-entry.ts models/sandbox-file-list-response.ts models/sandbox-resources.ts -models/sandbox-settings.ts models/save-query-request.ts models/saved-query.ts models/secret-list-response.ts models/secret-metadata.ts -models/server-settings-exec.ts -models/server-settings-fabro.ts -models/server-settings-server-tls.ts -models/server-settings-server.ts -models/server-settings.ts models/set-secret-request.ts -models/setup-settings.ts models/ssh-access-request.ts models/ssh-access-response.ts models/stage-status.ts @@ -177,12 +151,9 @@ models/submit-answer-request.ts models/system-info-response.ts models/system-run-counts.ts models/system-stage-turn.ts -models/tls-settings.ts models/tool-stage-turn.ts models/tool-use.ts models/user-response.ts -models/web-settings.ts -models/webhook-settings.ts models/workflow-diagnostic.ts models/workflow-reference.ts models/write-blob-response.ts diff --git a/lib/packages/fabro-api-client/src/api/run-internals-api.ts b/lib/packages/fabro-api-client/src/api/run-internals-api.ts index 12509d35c..d26751ead 100644 --- a/lib/packages/fabro-api-client/src/api/run-internals-api.ts +++ b/lib/packages/fabro-api-client/src/api/run-internals-api.ts @@ -42,8 +42,6 @@ import type { RunEvent } from '../models'; // @ts-ignore import type { RunProjection } from '../models'; // @ts-ignore -import type { RunSettings } from '../models'; -// @ts-ignore import type { WriteBlobResponse } from '../models'; // @ts-ignore import type { WriteRunBlobRequest } from '../models'; @@ -481,7 +479,7 @@ export const RunInternalsApiAxiosParamCreator = function (configuration?: Config }; }, /** - * Uploads one or more artifacts for a stage. Intended for trusted internal callers. The server accepts both: - `application/octet-stream` for single-file uploads with the `filename` query parameter - strict manifest-first `multipart/form-data` uploads documented by `ArtifactBatchUploadManifest` The generated Rust client currently exposes the octet-stream variant because the OpenAPI code generator in this repo does not support multiple request media types on one operation. + * Uploads one or more artifacts for a stage. Intended for trusted internal callers. The server accepts both: - `application/octet-stream` for single-file uploads with the `filename` query parameter - strict manifest-first `multipart/form-data` uploads documented by `ArtifactBatchUploadManifest` The generated Rust client currently exposes the octet-stream variant because the OpenAPI code generator in this repo does not support multiple request media types on one operation. * @summary Put Stage Artifact * @param {string} id Unique run identifier (ULID). * @param {string} stageId Identifier of a stage within a run\'s workflow graph, serialized as `node_id@visit`. @@ -847,7 +845,7 @@ export const RunInternalsApiFp = function(configuration?: Configuration) { return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, /** - * Uploads one or more artifacts for a stage. Intended for trusted internal callers. The server accepts both: - `application/octet-stream` for single-file uploads with the `filename` query parameter - strict manifest-first `multipart/form-data` uploads documented by `ArtifactBatchUploadManifest` The generated Rust client currently exposes the octet-stream variant because the OpenAPI code generator in this repo does not support multiple request media types on one operation. + * Uploads one or more artifacts for a stage. Intended for trusted internal callers. The server accepts both: - `application/octet-stream` for single-file uploads with the `filename` query parameter - strict manifest-first `multipart/form-data` uploads documented by `ArtifactBatchUploadManifest` The generated Rust client currently exposes the octet-stream variant because the OpenAPI code generator in this repo does not support multiple request media types on one operation. * @summary Put Stage Artifact * @param {string} id Unique run identifier (ULID). * @param {string} stageId Identifier of a stage within a run\'s workflow graph, serialized as `node_id@visit`. @@ -896,7 +894,7 @@ export const RunInternalsApiFp = function(configuration?: Configuration) { * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async retrieveRunSettings(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + async retrieveRunSettings(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<{ [key: string]: any; }>> { const localVarAxiosArgs = await localVarAxiosParamCreator.retrieveRunSettings(id, options); const localVarOperationServerIndex = configuration?.serverIndex ?? 0; const localVarOperationServerBasePath = operationServerMap['RunInternalsApi.retrieveRunSettings']?.[localVarOperationServerIndex]?.url; @@ -1028,7 +1026,7 @@ export const RunInternalsApiFactory = function (configuration?: Configuration, b return localVarFp.listStageTurns(id, stageId, pageLimit, pageOffset, options).then((request) => request(axios, basePath)); }, /** - * Uploads one or more artifacts for a stage. Intended for trusted internal callers. The server accepts both: - `application/octet-stream` for single-file uploads with the `filename` query parameter - strict manifest-first `multipart/form-data` uploads documented by `ArtifactBatchUploadManifest` The generated Rust client currently exposes the octet-stream variant because the OpenAPI code generator in this repo does not support multiple request media types on one operation. + * Uploads one or more artifacts for a stage. Intended for trusted internal callers. The server accepts both: - `application/octet-stream` for single-file uploads with the `filename` query parameter - strict manifest-first `multipart/form-data` uploads documented by `ArtifactBatchUploadManifest` The generated Rust client currently exposes the octet-stream variant because the OpenAPI code generator in this repo does not support multiple request media types on one operation. * @summary Put Stage Artifact * @param {string} id Unique run identifier (ULID). * @param {string} stageId Identifier of a stage within a run\'s workflow graph, serialized as `node_id@visit`. @@ -1068,7 +1066,7 @@ export const RunInternalsApiFactory = function (configuration?: Configuration, b * @param {*} [options] Override http request option. * @throws {RequiredError} */ - retrieveRunSettings(id: string, options?: RawAxiosRequestConfig): AxiosPromise { + retrieveRunSettings(id: string, options?: RawAxiosRequestConfig): AxiosPromise<{ [key: string]: any; }> { return localVarFp.retrieveRunSettings(id, options).then((request) => request(axios, basePath)); }, /** @@ -1201,7 +1199,7 @@ export class RunInternalsApi extends BaseAPI { } /** - * Uploads one or more artifacts for a stage. Intended for trusted internal callers. The server accepts both: - `application/octet-stream` for single-file uploads with the `filename` query parameter - strict manifest-first `multipart/form-data` uploads documented by `ArtifactBatchUploadManifest` The generated Rust client currently exposes the octet-stream variant because the OpenAPI code generator in this repo does not support multiple request media types on one operation. + * Uploads one or more artifacts for a stage. Intended for trusted internal callers. The server accepts both: - `application/octet-stream` for single-file uploads with the `filename` query parameter - strict manifest-first `multipart/form-data` uploads documented by `ArtifactBatchUploadManifest` The generated Rust client currently exposes the octet-stream variant because the OpenAPI code generator in this repo does not support multiple request media types on one operation. * @summary Put Stage Artifact * @param {string} id Unique run identifier (ULID). * @param {string} stageId Identifier of a stage within a run\'s workflow graph, serialized as `node_id@visit`. @@ -1260,3 +1258,4 @@ export class RunInternalsApi extends BaseAPI { return RunInternalsApiFp(this.configuration).writeRunBlob(id, body, options).then((request) => request(this.axios, this.basePath)); } } + diff --git a/lib/packages/fabro-api-client/src/api/settings-api.ts b/lib/packages/fabro-api-client/src/api/settings-api.ts index a54b400fd..6b648b707 100644 --- a/lib/packages/fabro-api-client/src/api/settings-api.ts +++ b/lib/packages/fabro-api-client/src/api/settings-api.ts @@ -21,8 +21,6 @@ import globalAxios from 'axios'; import { DUMMY_BASE_URL, assertParamExists, setApiKeyToObject, setBasicAuthToObject, setBearerAuthToObject, setOAuthToObject, setSearchParams, serializeDataIfNeeded, toPathString, createRequestFunction, replaceWithSerializableTypeIfNeeded } from '../common'; // @ts-ignore import { BASE_PATH, COLLECTION_FORMATS, type RequestArgs, BaseAPI, RequiredError, operationServerMap } from '../base'; -// @ts-ignore -import type { ServerSettings } from '../models'; /** * SettingsApi - axios parameter creator */ @@ -80,7 +78,7 @@ export const SettingsApiFp = function(configuration?: Configuration) { * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async retrieveServerSettings(options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + async retrieveServerSettings(options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<{ [key: string]: any; }>> { const localVarAxiosArgs = await localVarAxiosParamCreator.retrieveServerSettings(options); const localVarOperationServerIndex = configuration?.serverIndex ?? 0; const localVarOperationServerBasePath = operationServerMap['SettingsApi.retrieveServerSettings']?.[localVarOperationServerIndex]?.url; @@ -101,7 +99,7 @@ export const SettingsApiFactory = function (configuration?: Configuration, baseP * @param {*} [options] Override http request option. * @throws {RequiredError} */ - retrieveServerSettings(options?: RawAxiosRequestConfig): AxiosPromise { + retrieveServerSettings(options?: RawAxiosRequestConfig): AxiosPromise<{ [key: string]: any; }> { return localVarFp.retrieveServerSettings(options).then((request) => request(axios, basePath)); }, }; diff --git a/lib/packages/fabro-api-client/src/models/api-configuration.ts b/lib/packages/fabro-api-client/src/models/api-configuration.ts deleted file mode 100644 index fea66e476..000000000 --- a/lib/packages/fabro-api-client/src/models/api-configuration.ts +++ /dev/null @@ -1,42 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - -// May contain unused imports in some cases -// @ts-ignore -import type { TlsSettings } from './tls-configuration'; - -/** - * API server configuration. - */ -export interface ApiSettings { - /** - * API base URL. - */ - 'base_url'?: string; - /** - * Authentication strategies. - */ - 'authentication_strategies'?: Array; - 'tls'?: TlsSettings; -} - -export const ApiSettingsAuthenticationStrategiesEnum = { - JWT: 'jwt', - MTLS: 'mtls' -} as const; - -export type ApiSettingsAuthenticationStrategiesEnum = typeof ApiSettingsAuthenticationStrategiesEnum[keyof typeof ApiSettingsAuthenticationStrategiesEnum]; - - diff --git a/lib/packages/fabro-api-client/src/models/api-question.ts b/lib/packages/fabro-api-client/src/models/api-question.ts index fa10acb0f..78ea79cd3 100644 --- a/lib/packages/fabro-api-client/src/models/api-question.ts +++ b/lib/packages/fabro-api-client/src/models/api-question.ts @@ -32,6 +32,10 @@ export interface ApiQuestion { * The question text displayed to the user. */ 'text': string; + /** + * Workflow stage identifier that produced the question. + */ + 'stage': string; 'question_type': QuestionType; /** * Available options for selection-based questions. Empty for freeform questions. @@ -41,6 +45,14 @@ export interface ApiQuestion { * Whether the user may provide freeform text in addition to selecting options. */ 'allow_freeform': boolean; + /** + * Timeout for the question when configured by the workflow. + */ + 'timeout_seconds'?: number; + /** + * Optional contextual text shown alongside the question. + */ + 'context_display'?: string; } diff --git a/lib/packages/fabro-api-client/src/models/api-settings.ts b/lib/packages/fabro-api-client/src/models/api-settings.ts deleted file mode 100644 index ed5b34002..000000000 --- a/lib/packages/fabro-api-client/src/models/api-settings.ts +++ /dev/null @@ -1,42 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - -// May contain unused imports in some cases -// @ts-ignore -import type { TlsSettings } from './tls-settings'; - -/** - * API server configuration. - */ -export interface ApiSettings { - /** - * API base URL. - */ - 'base_url'?: string; - /** - * Authentication strategies. - */ - 'authentication_strategies'?: Array; - 'tls'?: TlsSettings; -} - -export const ApiSettingsAuthenticationStrategiesEnum = { - JWT: 'jwt', - MTLS: 'mtls' -} as const; - -export type ApiSettingsAuthenticationStrategiesEnum = typeof ApiSettingsAuthenticationStrategiesEnum[keyof typeof ApiSettingsAuthenticationStrategiesEnum]; - - diff --git a/lib/packages/fabro-api-client/src/models/artifact-batch-upload-entry.ts b/lib/packages/fabro-api-client/src/models/artifact-batch-upload-entry.ts index 8a42ae05a..3a4545f4e 100644 --- a/lib/packages/fabro-api-client/src/models/artifact-batch-upload-entry.ts +++ b/lib/packages/fabro-api-client/src/models/artifact-batch-upload-entry.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -39,3 +39,4 @@ export interface ArtifactBatchUploadEntry { */ 'content_type'?: string; } + diff --git a/lib/packages/fabro-api-client/src/models/artifact-batch-upload-manifest.ts b/lib/packages/fabro-api-client/src/models/artifact-batch-upload-manifest.ts index ad483a824..8b0d60046 100644 --- a/lib/packages/fabro-api-client/src/models/artifact-batch-upload-manifest.ts +++ b/lib/packages/fabro-api-client/src/models/artifact-batch-upload-manifest.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -23,3 +23,4 @@ import type { ArtifactBatchUploadEntry } from './artifact-batch-upload-entry'; export interface ArtifactBatchUploadManifest { 'entries': Array; } + diff --git a/lib/packages/fabro-api-client/src/models/artifacts-configuration.ts b/lib/packages/fabro-api-client/src/models/artifacts-configuration.ts deleted file mode 100644 index a1acd2ae9..000000000 --- a/lib/packages/fabro-api-client/src/models/artifacts-configuration.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * Artifact collection configuration. - */ -export interface ArtifactsSettings { - /** - * Glob patterns for files to collect as run artifacts. - */ - 'include'?: Array; -} - diff --git a/lib/packages/fabro-api-client/src/models/artifacts-settings.ts b/lib/packages/fabro-api-client/src/models/artifacts-settings.ts deleted file mode 100644 index a1acd2ae9..000000000 --- a/lib/packages/fabro-api-client/src/models/artifacts-settings.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * Artifact collection configuration. - */ -export interface ArtifactsSettings { - /** - * Glob patterns for files to collect as run artifacts. - */ - 'include'?: Array; -} - diff --git a/lib/packages/fabro-api-client/src/models/auth-configuration.ts b/lib/packages/fabro-api-client/src/models/auth-configuration.ts deleted file mode 100644 index d7763c463..000000000 --- a/lib/packages/fabro-api-client/src/models/auth-configuration.ts +++ /dev/null @@ -1,38 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * Authentication configuration. - */ -export interface AuthSettings { - /** - * Auth provider. - */ - 'provider'?: AuthSettingsProviderEnum; - /** - * Allowed usernames. - */ - 'allowed_usernames'?: Array; -} - -export const AuthSettingsProviderEnum = { - GITHUB: 'github', - INSECURE_DISABLED: 'insecure_disabled' -} as const; - -export type AuthSettingsProviderEnum = typeof AuthSettingsProviderEnum[keyof typeof AuthSettingsProviderEnum]; - - diff --git a/lib/packages/fabro-api-client/src/models/auth-settings.ts b/lib/packages/fabro-api-client/src/models/auth-settings.ts deleted file mode 100644 index d7763c463..000000000 --- a/lib/packages/fabro-api-client/src/models/auth-settings.ts +++ /dev/null @@ -1,38 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * Authentication configuration. - */ -export interface AuthSettings { - /** - * Auth provider. - */ - 'provider'?: AuthSettingsProviderEnum; - /** - * Allowed usernames. - */ - 'allowed_usernames'?: Array; -} - -export const AuthSettingsProviderEnum = { - GITHUB: 'github', - INSECURE_DISABLED: 'insecure_disabled' -} as const; - -export type AuthSettingsProviderEnum = typeof AuthSettingsProviderEnum[keyof typeof AuthSettingsProviderEnum]; - - diff --git a/lib/packages/fabro-api-client/src/models/checkpoint-configuration.ts b/lib/packages/fabro-api-client/src/models/checkpoint-configuration.ts deleted file mode 100644 index b16f61b44..000000000 --- a/lib/packages/fabro-api-client/src/models/checkpoint-configuration.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * Checkpoint configuration for file exclusion. - */ -export interface CheckpointSettings { - /** - * Glob patterns to exclude from checkpoints. - */ - 'exclude_globs'?: Array; -} - diff --git a/lib/packages/fabro-api-client/src/models/checkpoint-settings.ts b/lib/packages/fabro-api-client/src/models/checkpoint-settings.ts deleted file mode 100644 index b16f61b44..000000000 --- a/lib/packages/fabro-api-client/src/models/checkpoint-settings.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * Checkpoint configuration for file exclusion. - */ -export interface CheckpointSettings { - /** - * Glob patterns to exclude from checkpoints. - */ - 'exclude_globs'?: Array; -} - diff --git a/lib/packages/fabro-api-client/src/models/create-run-request.ts b/lib/packages/fabro-api-client/src/models/create-run-request.ts deleted file mode 100644 index efd73b116..000000000 --- a/lib/packages/fabro-api-client/src/models/create-run-request.ts +++ /dev/null @@ -1,42 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * Request body for creating a new run, either from inline Graphviz source or from a local workflow path plus resolved settings. - */ -export interface CreateRunRequest { - /** - * Graphviz DOT language source defining the workflow graph. - */ - 'dot_source'?: string; - /** - * Absolute or relative path to the workflow file to load on the local machine. - */ - 'workflow_path'?: string; - /** - * Working directory used to resolve the workflow path. - */ - 'cwd'?: string; - /** - * JSON-serialized `fabro_types::Settings` payload resolved by the CLI. - */ - 'settings_json'?: string; - /** - * Optional pre-generated run ID to use instead of allocating a new ULID. - */ - 'run_id'?: string; -} - diff --git a/lib/packages/fabro-api-client/src/models/daytona-configuration-network-one-of.ts b/lib/packages/fabro-api-client/src/models/daytona-configuration-network-one-of.ts deleted file mode 100644 index c19be5122..000000000 --- a/lib/packages/fabro-api-client/src/models/daytona-configuration-network-one-of.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -export interface DaytonaSettingsNetworkOneOf { - /** - * CIDR allowlist for network access. - */ - 'allow_list': Array; -} - diff --git a/lib/packages/fabro-api-client/src/models/daytona-configuration-network.ts b/lib/packages/fabro-api-client/src/models/daytona-configuration-network.ts deleted file mode 100644 index c6a63aac3..000000000 --- a/lib/packages/fabro-api-client/src/models/daytona-configuration-network.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - -// May contain unused imports in some cases -// @ts-ignore -import type { DaytonaSettingsNetworkOneOf } from './daytona-configuration-network-one-of'; - -/** - * @type DaytonaSettingsNetwork - * Network access mode: \"block\", \"allow_all\", or {\"allow_list\": [...]}. - */ -export type DaytonaSettingsNetwork = DaytonaSettingsNetworkOneOf | string; - - diff --git a/lib/packages/fabro-api-client/src/models/daytona-configuration.ts b/lib/packages/fabro-api-client/src/models/daytona-configuration.ts deleted file mode 100644 index 88253d4a6..000000000 --- a/lib/packages/fabro-api-client/src/models/daytona-configuration.ts +++ /dev/null @@ -1,38 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - -// May contain unused imports in some cases -// @ts-ignore -import type { DaytonaSettingsNetwork } from './daytona-configuration-network'; -// May contain unused imports in some cases -// @ts-ignore -import type { DaytonaSnapshotSettings } from './daytona-snapshot-configuration'; - -/** - * Daytona-specific sandbox settings. - */ -export interface DaytonaSettings { - /** - * Auto-stop interval in seconds. - */ - 'auto_stop_interval'?: number; - /** - * Labels applied to the sandbox. - */ - 'labels'?: { [key: string]: string; }; - 'snapshot'?: DaytonaSnapshotSettings; - 'network'?: DaytonaSettingsNetwork; -} - diff --git a/lib/packages/fabro-api-client/src/models/daytona-settings-network-one-of.ts b/lib/packages/fabro-api-client/src/models/daytona-settings-network-one-of.ts deleted file mode 100644 index c19be5122..000000000 --- a/lib/packages/fabro-api-client/src/models/daytona-settings-network-one-of.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -export interface DaytonaSettingsNetworkOneOf { - /** - * CIDR allowlist for network access. - */ - 'allow_list': Array; -} - diff --git a/lib/packages/fabro-api-client/src/models/daytona-settings-network.ts b/lib/packages/fabro-api-client/src/models/daytona-settings-network.ts deleted file mode 100644 index faa31fc2c..000000000 --- a/lib/packages/fabro-api-client/src/models/daytona-settings-network.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - -// May contain unused imports in some cases -// @ts-ignore -import type { DaytonaSettingsNetworkOneOf } from './daytona-settings-network-one-of'; - -/** - * @type DaytonaSettingsNetwork - * Network access mode: \"block\", \"allow_all\", or {\"allow_list\": [...]}. - */ -export type DaytonaSettingsNetwork = DaytonaSettingsNetworkOneOf | string; - - diff --git a/lib/packages/fabro-api-client/src/models/daytona-settings.ts b/lib/packages/fabro-api-client/src/models/daytona-settings.ts deleted file mode 100644 index 5738b17fa..000000000 --- a/lib/packages/fabro-api-client/src/models/daytona-settings.ts +++ /dev/null @@ -1,42 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - -// May contain unused imports in some cases -// @ts-ignore -import type { DaytonaSettingsNetwork } from './daytona-settings-network'; -// May contain unused imports in some cases -// @ts-ignore -import type { DaytonaSnapshotSettings } from './daytona-snapshot-settings'; - -/** - * Daytona-specific sandbox settings. - */ -export interface DaytonaSettings { - /** - * Auto-stop interval in seconds. - */ - 'auto_stop_interval'?: number; - /** - * Labels applied to the sandbox. - */ - 'labels'?: { [key: string]: string; }; - 'snapshot'?: DaytonaSnapshotSettings; - 'network'?: DaytonaSettingsNetwork; - /** - * Skip git repo detection and cloning during initialization. - */ - 'skip_clone'?: boolean; -} - diff --git a/lib/packages/fabro-api-client/src/models/daytona-snapshot-configuration.ts b/lib/packages/fabro-api-client/src/models/daytona-snapshot-configuration.ts deleted file mode 100644 index afb9fc7e2..000000000 --- a/lib/packages/fabro-api-client/src/models/daytona-snapshot-configuration.ts +++ /dev/null @@ -1,42 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * Snapshot configuration for Daytona sandboxes. - */ -export interface DaytonaSnapshotSettings { - /** - * Snapshot name. - */ - 'name': string; - /** - * CPU cores. - */ - 'cpu'?: number; - /** - * Memory in GB. - */ - 'memory'?: number; - /** - * Disk in GB. - */ - 'disk'?: number; - /** - * Dockerfile content for snapshot creation. - */ - 'dockerfile'?: string; -} - diff --git a/lib/packages/fabro-api-client/src/models/daytona-snapshot-settings.ts b/lib/packages/fabro-api-client/src/models/daytona-snapshot-settings.ts deleted file mode 100644 index afb9fc7e2..000000000 --- a/lib/packages/fabro-api-client/src/models/daytona-snapshot-settings.ts +++ /dev/null @@ -1,42 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * Snapshot configuration for Daytona sandboxes. - */ -export interface DaytonaSnapshotSettings { - /** - * Snapshot name. - */ - 'name': string; - /** - * CPU cores. - */ - 'cpu'?: number; - /** - * Memory in GB. - */ - 'memory'?: number; - /** - * Disk in GB. - */ - 'disk'?: number; - /** - * Dockerfile content for snapshot creation. - */ - 'dockerfile'?: string; -} - diff --git a/lib/packages/fabro-api-client/src/models/exe-configuration.ts b/lib/packages/fabro-api-client/src/models/exe-configuration.ts deleted file mode 100644 index a214b0237..000000000 --- a/lib/packages/fabro-api-client/src/models/exe-configuration.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * exe.dev sandbox configuration. - */ -export interface ExeSettings { - /** - * VM image to use for the exe.dev sandbox. - */ - 'image'?: string; -} - diff --git a/lib/packages/fabro-api-client/src/models/exe-settings.ts b/lib/packages/fabro-api-client/src/models/exe-settings.ts deleted file mode 100644 index a214b0237..000000000 --- a/lib/packages/fabro-api-client/src/models/exe-settings.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * exe.dev sandbox configuration. - */ -export interface ExeSettings { - /** - * VM image to use for the exe.dev sandbox. - */ - 'image'?: string; -} - diff --git a/lib/packages/fabro-api-client/src/models/features.ts b/lib/packages/fabro-api-client/src/models/features.ts deleted file mode 100644 index ee3916a3e..000000000 --- a/lib/packages/fabro-api-client/src/models/features.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * Feature flags. - */ -export interface Features { - /** - * Enable session sandboxes. - */ - 'session_sandboxes'?: boolean; - /** - * Experimental: enable automatic retro generation after workflow runs. - */ - 'retros'?: boolean; -} - diff --git a/lib/packages/fabro-api-client/src/models/git-author-configuration.ts b/lib/packages/fabro-api-client/src/models/git-author-configuration.ts deleted file mode 100644 index aaae0960d..000000000 --- a/lib/packages/fabro-api-client/src/models/git-author-configuration.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * Git commit author configuration. - */ -export interface GitAuthorSettings { - /** - * Author name for commits. - */ - 'name'?: string; - /** - * Author email for commits. - */ - 'email'?: string; -} - diff --git a/lib/packages/fabro-api-client/src/models/git-author-settings.ts b/lib/packages/fabro-api-client/src/models/git-author-settings.ts deleted file mode 100644 index aaae0960d..000000000 --- a/lib/packages/fabro-api-client/src/models/git-author-settings.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * Git commit author configuration. - */ -export interface GitAuthorSettings { - /** - * Author name for commits. - */ - 'name'?: string; - /** - * Author email for commits. - */ - 'email'?: string; -} - diff --git a/lib/packages/fabro-api-client/src/models/git-configuration.ts b/lib/packages/fabro-api-client/src/models/git-configuration.ts deleted file mode 100644 index ce47af29e..000000000 --- a/lib/packages/fabro-api-client/src/models/git-configuration.ts +++ /dev/null @@ -1,53 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - -// May contain unused imports in some cases -// @ts-ignore -import type { GitAuthorSettings } from './git-author-configuration'; -// May contain unused imports in some cases -// @ts-ignore -import type { WebhookSettings } from './webhook-configuration'; - -/** - * Git provider configuration. - */ -export interface GitSettings { - /** - * Git provider. - */ - 'provider'?: GitSettingsProviderEnum; - /** - * GitHub App ID. - */ - 'app_id'?: string; - /** - * GitHub App Client ID. - */ - 'client_id'?: string; - /** - * GitHub App slug. - */ - 'slug'?: string; - 'author'?: GitAuthorSettings; - 'webhooks'?: WebhookSettings; -} - -export const GitSettingsProviderEnum = { - GITHUB: 'github' -} as const; - -export type GitSettingsProviderEnum = typeof GitSettingsProviderEnum[keyof typeof GitSettingsProviderEnum]; - - diff --git a/lib/packages/fabro-api-client/src/models/git-hub-configuration.ts b/lib/packages/fabro-api-client/src/models/git-hub-configuration.ts deleted file mode 100644 index b0f56b6c9..000000000 --- a/lib/packages/fabro-api-client/src/models/git-hub-configuration.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * GitHub App token injection configuration. - */ -export interface GitHubSettings { - /** - * GitHub API permissions to request (e.g. contents = write). - */ - 'permissions'?: { [key: string]: string; }; -} - diff --git a/lib/packages/fabro-api-client/src/models/git-hub-settings.ts b/lib/packages/fabro-api-client/src/models/git-hub-settings.ts deleted file mode 100644 index b0f56b6c9..000000000 --- a/lib/packages/fabro-api-client/src/models/git-hub-settings.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * GitHub App token injection configuration. - */ -export interface GitHubSettings { - /** - * GitHub API permissions to request (e.g. contents = write). - */ - 'permissions'?: { [key: string]: string; }; -} - diff --git a/lib/packages/fabro-api-client/src/models/git-settings.ts b/lib/packages/fabro-api-client/src/models/git-settings.ts deleted file mode 100644 index a37da07ba..000000000 --- a/lib/packages/fabro-api-client/src/models/git-settings.ts +++ /dev/null @@ -1,53 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - -// May contain unused imports in some cases -// @ts-ignore -import type { GitAuthorSettings } from './git-author-settings'; -// May contain unused imports in some cases -// @ts-ignore -import type { WebhookSettings } from './webhook-settings'; - -/** - * Git provider configuration. - */ -export interface GitSettings { - /** - * Git provider. - */ - 'provider'?: GitSettingsProviderEnum; - /** - * GitHub App ID. - */ - 'app_id'?: string; - /** - * GitHub App Client ID. - */ - 'client_id'?: string; - /** - * GitHub App slug. - */ - 'slug'?: string; - 'author'?: GitAuthorSettings; - 'webhooks'?: WebhookSettings; -} - -export const GitSettingsProviderEnum = { - GITHUB: 'github' -} as const; - -export type GitSettingsProviderEnum = typeof GitSettingsProviderEnum[keyof typeof GitSettingsProviderEnum]; - - diff --git a/lib/packages/fabro-api-client/src/models/hook-definition.ts b/lib/packages/fabro-api-client/src/models/hook-definition.ts deleted file mode 100644 index b0717999b..000000000 --- a/lib/packages/fabro-api-client/src/models/hook-definition.ts +++ /dev/null @@ -1,107 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * A single hook definition. The type discriminator and variant fields are flattened into this object. Field-to-type mapping: - `command`: requires `command` - `http`: requires `url`; optional `headers`, `allowed_env_vars`, `tls` - `prompt`: requires `prompt`; optional `model` - `agent`: requires `prompt`; optional `model`, `max_tool_rounds` Top-level `command` without `type` is shorthand for type=command. - */ -export interface HookDefinition { - /** - * Human-readable hook name. - */ - 'name'?: string; - /** - * Event that triggers this hook. - */ - 'event': HookDefinitionEventEnum; - /** - * Shell command (shorthand for type=command). - */ - 'command'?: string; - /** - * Hook execution type. - */ - 'type'?: HookDefinitionTypeEnum; - /** - * URL for HTTP hooks. - */ - 'url'?: string; - /** - * Headers for HTTP hooks. - */ - 'headers'?: { [key: string]: string; }; - /** - * Environment variables allowed in HTTP hook headers. - */ - 'allowed_env_vars'?: Array; - /** - * TLS verification mode for HTTP hooks. - */ - 'tls'?: HookDefinitionTlsEnum; - /** - * Prompt text for prompt/agent hooks. - */ - 'prompt'?: string; - /** - * Model for prompt/agent hooks. - */ - 'model'?: string; - /** - * Max tool rounds for agent hooks. - */ - 'max_tool_rounds'?: number; - /** - * Regex matched against node_id or handler_type. - */ - 'matcher'?: string; - /** - * Whether this hook blocks execution. - */ - 'blocking'?: boolean; - /** - * Timeout in milliseconds. - */ - 'timeout_ms'?: number; - /** - * Whether hook runs in sandbox. - */ - 'sandbox'?: boolean; -} - -export const HookDefinitionEventEnum = { - RUN_START: 'run_start', - RUN_COMPLETE: 'run_complete', - STAGE_START: 'stage_start', - STAGE_COMPLETE: 'stage_complete' -} as const; - -export type HookDefinitionEventEnum = typeof HookDefinitionEventEnum[keyof typeof HookDefinitionEventEnum]; -export const HookDefinitionTypeEnum = { - COMMAND: 'command', - HTTP: 'http', - PROMPT: 'prompt', - AGENT: 'agent' -} as const; - -export type HookDefinitionTypeEnum = typeof HookDefinitionTypeEnum[keyof typeof HookDefinitionTypeEnum]; -export const HookDefinitionTlsEnum = { - VERIFY: 'verify', - NO_VERIFY: 'no_verify', - OFF: 'off' -} as const; - -export type HookDefinitionTlsEnum = typeof HookDefinitionTlsEnum[keyof typeof HookDefinitionTlsEnum]; - - diff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts index aecd845cf..a2e9a9c36 100644 --- a/lib/packages/fabro-api-client/src/models/index.ts +++ b/lib/packages/fabro-api-client/src/models/index.ts @@ -2,22 +2,18 @@ export * from './aggregate-billing'; export * from './aggregate-billing-totals'; export * from './api-question'; export * from './api-question-option'; -export * from './api-settings'; export * from './append-event-response'; export * from './artifact-batch-upload-entry'; export * from './artifact-batch-upload-manifest'; export * from './artifact-entry'; export * from './artifact-list-response'; -export * from './artifacts-settings'; export * from './assistant-stage-turn'; -export * from './auth-settings'; export * from './billed-token-counts'; export * from './billing-by-model'; export * from './billing-stage-ref'; export * from './board-column'; export * from './check-run'; export * from './check-run-status'; -export * from './checkpoint-settings'; export * from './code-location'; export * from './completion-content-part'; export * from './completion-message'; @@ -26,10 +22,6 @@ export * from './completion-tool-choice'; export * from './completion-tool-definition'; export * from './completion-usage'; export * from './create-completion-request'; -export * from './daytona-settings'; -export * from './daytona-settings-network'; -export * from './daytona-settings-network-one-of'; -export * from './daytona-snapshot-settings'; export * from './diagnostics-check'; export * from './diagnostics-detail'; export * from './diagnostics-report'; @@ -45,20 +37,12 @@ export * from './event-envelope'; export * from './execute-query-request'; export * from './execute-query-response'; export * from './execute-query-response-rows-inner-inner'; -export * from './features'; export * from './file-checkpoint'; export * from './file-diff'; -export * from './git-author-settings'; -export * from './git-hub-settings'; -export * from './git-settings'; export * from './health-response'; export * from './history-entry'; -export * from './hook-definition'; export * from './internal-run-status'; export * from './internal-stage-status'; -export * from './llm-settings'; -export * from './local-sandbox-settings'; -export * from './log-settings'; export * from './manifest-args'; export * from './manifest-config'; export * from './manifest-file-entry'; @@ -68,7 +52,6 @@ export * from './manifest-goal'; export * from './manifest-target'; export * from './manifest-workflow'; export * from './manifest-workflow-config'; -export * from './mcp-server-entry'; export * from './model'; export * from './model-costs'; export * from './model-features'; @@ -99,7 +82,6 @@ export * from './preview-url-response'; export * from './prune-run-entry'; export * from './prune-runs-request'; export * from './prune-runs-response'; -export * from './pull-request-settings'; export * from './question-type'; export * from './render-workflow-graph-direction'; export * from './render-workflow-graph-format'; @@ -126,7 +108,6 @@ export * from './run-pull-request'; export * from './run-question'; export * from './run-reference'; export * from './run-sandbox'; -export * from './run-settings'; export * from './run-stage'; export * from './run-status'; export * from './run-status-record'; @@ -135,18 +116,11 @@ export * from './run-timings'; export * from './sandbox-file-entry'; export * from './sandbox-file-list-response'; export * from './sandbox-resources'; -export * from './sandbox-settings'; export * from './save-query-request'; export * from './saved-query'; export * from './secret-list-response'; export * from './secret-metadata'; -export * from './server-settings'; -export * from './server-settings-exec'; -export * from './server-settings-fabro'; -export * from './server-settings-server'; -export * from './server-settings-server-tls'; export * from './set-secret-request'; -export * from './setup-settings'; export * from './ssh-access-request'; export * from './ssh-access-response'; export * from './stage-status'; @@ -158,12 +132,9 @@ export * from './submit-answer-request'; export * from './system-info-response'; export * from './system-run-counts'; export * from './system-stage-turn'; -export * from './tls-settings'; export * from './tool-stage-turn'; export * from './tool-use'; export * from './user-response'; -export * from './web-settings'; -export * from './webhook-settings'; export * from './workflow-diagnostic'; export * from './workflow-reference'; export * from './write-blob-response'; diff --git a/lib/packages/fabro-api-client/src/models/llm-configuration.ts b/lib/packages/fabro-api-client/src/models/llm-configuration.ts deleted file mode 100644 index e2ec24400..000000000 --- a/lib/packages/fabro-api-client/src/models/llm-configuration.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * LLM provider and model settings. - */ -export interface LlmSettings { - /** - * Model identifier. - */ - 'model'?: string; - /** - * Provider name. - */ - 'provider'?: string; - /** - * Provider fallback chains. - */ - 'fallbacks'?: { [key: string]: Array; }; -} - diff --git a/lib/packages/fabro-api-client/src/models/llm-settings.ts b/lib/packages/fabro-api-client/src/models/llm-settings.ts deleted file mode 100644 index e2ec24400..000000000 --- a/lib/packages/fabro-api-client/src/models/llm-settings.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * LLM provider and model settings. - */ -export interface LlmSettings { - /** - * Model identifier. - */ - 'model'?: string; - /** - * Provider name. - */ - 'provider'?: string; - /** - * Provider fallback chains. - */ - 'fallbacks'?: { [key: string]: Array; }; -} - diff --git a/lib/packages/fabro-api-client/src/models/local-sandbox-configuration.ts b/lib/packages/fabro-api-client/src/models/local-sandbox-configuration.ts deleted file mode 100644 index dca9c61d4..000000000 --- a/lib/packages/fabro-api-client/src/models/local-sandbox-configuration.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * Local sandbox settings. - */ -export interface LocalSandboxSettings { - /** - * Git worktree mode for local sandbox. - */ - 'worktree_mode'?: LocalSandboxSettingsWorktreeModeEnum; -} - -export const LocalSandboxSettingsWorktreeModeEnum = { - ALWAYS: 'always', - CLEAN: 'clean', - DIRTY: 'dirty', - NEVER: 'never' -} as const; - -export type LocalSandboxSettingsWorktreeModeEnum = typeof LocalSandboxSettingsWorktreeModeEnum[keyof typeof LocalSandboxSettingsWorktreeModeEnum]; - - diff --git a/lib/packages/fabro-api-client/src/models/local-sandbox-settings.ts b/lib/packages/fabro-api-client/src/models/local-sandbox-settings.ts deleted file mode 100644 index dca9c61d4..000000000 --- a/lib/packages/fabro-api-client/src/models/local-sandbox-settings.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * Local sandbox settings. - */ -export interface LocalSandboxSettings { - /** - * Git worktree mode for local sandbox. - */ - 'worktree_mode'?: LocalSandboxSettingsWorktreeModeEnum; -} - -export const LocalSandboxSettingsWorktreeModeEnum = { - ALWAYS: 'always', - CLEAN: 'clean', - DIRTY: 'dirty', - NEVER: 'never' -} as const; - -export type LocalSandboxSettingsWorktreeModeEnum = typeof LocalSandboxSettingsWorktreeModeEnum[keyof typeof LocalSandboxSettingsWorktreeModeEnum]; - - diff --git a/lib/packages/fabro-api-client/src/models/log-configuration.ts b/lib/packages/fabro-api-client/src/models/log-configuration.ts deleted file mode 100644 index f973459a3..000000000 --- a/lib/packages/fabro-api-client/src/models/log-configuration.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * Logging configuration. - */ -export interface LogSettings { - /** - * Log level (e.g. trace, debug, info). - */ - 'level'?: string; -} - diff --git a/lib/packages/fabro-api-client/src/models/log-settings.ts b/lib/packages/fabro-api-client/src/models/log-settings.ts deleted file mode 100644 index f973459a3..000000000 --- a/lib/packages/fabro-api-client/src/models/log-settings.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * Logging configuration. - */ -export interface LogSettings { - /** - * Log level (e.g. trace, debug, info). - */ - 'level'?: string; -} - diff --git a/lib/packages/fabro-api-client/src/models/mcp-server-entry.ts b/lib/packages/fabro-api-client/src/models/mcp-server-entry.ts deleted file mode 100644 index f011cdf71..000000000 --- a/lib/packages/fabro-api-client/src/models/mcp-server-entry.ts +++ /dev/null @@ -1,50 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * MCP server connection entry. - */ -export interface McpServerEntry { - /** - * Transport type (stdio or http). - */ - 'type'?: string; - /** - * Command and arguments for stdio transport. - */ - 'command'?: Array; - /** - * Environment variables for stdio transport. - */ - 'env'?: { [key: string]: string; }; - /** - * URL for http transport. - */ - 'url'?: string; - /** - * HTTP headers for http transport. - */ - 'headers'?: { [key: string]: string; }; - /** - * Startup timeout in seconds. - */ - 'startup_timeout_secs'?: number; - /** - * Tool call timeout in seconds. - */ - 'tool_timeout_secs'?: number; -} - diff --git a/lib/packages/fabro-api-client/src/models/pull-request-configuration.ts b/lib/packages/fabro-api-client/src/models/pull-request-configuration.ts deleted file mode 100644 index 4d44d5718..000000000 --- a/lib/packages/fabro-api-client/src/models/pull-request-configuration.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * Pull request creation configuration. - */ -export interface PullRequestSettings { - /** - * Whether to create a pull request after a successful run. - */ - 'enabled'?: boolean; - /** - * Whether to create the pull request as a draft. - */ - 'draft'?: boolean; -} - diff --git a/lib/packages/fabro-api-client/src/models/pull-request-settings.ts b/lib/packages/fabro-api-client/src/models/pull-request-settings.ts deleted file mode 100644 index 548fa471b..000000000 --- a/lib/packages/fabro-api-client/src/models/pull-request-settings.ts +++ /dev/null @@ -1,47 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * Pull request creation configuration. - */ -export interface PullRequestSettings { - /** - * Whether to create a pull request after a successful run. - */ - 'enabled'?: boolean; - /** - * Whether to create the pull request as a draft. - */ - 'draft'?: boolean; - /** - * Whether to enable GitHub auto-merge on the created PR. Implies draft = false. - */ - 'auto_merge'?: boolean; - /** - * Merge strategy for auto-merge. - */ - 'merge_strategy'?: PullRequestSettingsMergeStrategyEnum; -} - -export const PullRequestSettingsMergeStrategyEnum = { - SQUASH: 'squash', - MERGE: 'merge', - REBASE: 'rebase' -} as const; - -export type PullRequestSettingsMergeStrategyEnum = typeof PullRequestSettingsMergeStrategyEnum[keyof typeof PullRequestSettingsMergeStrategyEnum]; - - diff --git a/lib/packages/fabro-api-client/src/models/run-configuration.ts b/lib/packages/fabro-api-client/src/models/run-configuration.ts deleted file mode 100644 index 95be5f730..000000000 --- a/lib/packages/fabro-api-client/src/models/run-configuration.ts +++ /dev/null @@ -1,57 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - -// May contain unused imports in some cases -// @ts-ignore -import type { HookDefinition } from './hook-definition'; -// May contain unused imports in some cases -// @ts-ignore -import type { LlmSettings } from './llm-configuration'; -// May contain unused imports in some cases -// @ts-ignore -import type { SandboxSettings } from './sandbox-configuration'; -// May contain unused imports in some cases -// @ts-ignore -import type { SetupSettings } from './setup-configuration'; - -/** - * Structured run settings mirroring FabroSettings. - */ -export interface RunSettings { - /** - * Settings schema version. - */ - 'version': number; - /** - * Goal description for the run. - */ - 'goal'?: string; - /** - * DOT graph filename. - */ - 'graph': string; - /** - * Working directory for the run. - */ - 'work_dir'?: string; - 'llm'?: LlmSettings; - 'setup'?: SetupSettings; - 'sandbox'?: SandboxSettings; - /** - * Variable map for template expansion. - */ - 'vars'?: { [key: string]: string; }; - 'hooks'?: Array; -} diff --git a/lib/packages/fabro-api-client/src/models/run-settings.ts b/lib/packages/fabro-api-client/src/models/run-settings.ts deleted file mode 100644 index 37e72d7a2..000000000 --- a/lib/packages/fabro-api-client/src/models/run-settings.ts +++ /dev/null @@ -1,58 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - -// May contain unused imports in some cases -// @ts-ignore -import type { HookDefinition } from './hook-definition'; -// May contain unused imports in some cases -// @ts-ignore -import type { LlmSettings } from './llm-settings'; -// May contain unused imports in some cases -// @ts-ignore -import type { SandboxSettings } from './sandbox-settings'; -// May contain unused imports in some cases -// @ts-ignore -import type { SetupSettings } from './setup-settings'; - -/** - * Structured run settings mirroring fabro_types::Settings. - */ -export interface RunSettings { - /** - * Settings schema version. - */ - 'version': number; - /** - * Goal description for the run. - */ - 'goal'?: string; - /** - * Graphviz graph filename. - */ - 'graph': string; - /** - * Working directory for the run. - */ - 'work_dir'?: string; - 'llm'?: LlmSettings; - 'setup'?: SetupSettings; - 'sandbox'?: SandboxSettings; - /** - * Variable map for template expansion. - */ - 'vars'?: { [key: string]: string; }; - 'hooks'?: Array; -} - diff --git a/lib/packages/fabro-api-client/src/models/sandbox-configuration.ts b/lib/packages/fabro-api-client/src/models/sandbox-configuration.ts deleted file mode 100644 index 89394cc02..000000000 --- a/lib/packages/fabro-api-client/src/models/sandbox-configuration.ts +++ /dev/null @@ -1,54 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - -// May contain unused imports in some cases -// @ts-ignore -import type { DaytonaSettings } from './daytona-configuration'; -// May contain unused imports in some cases -// @ts-ignore -import type { ExeSettings } from './exe-configuration'; -// May contain unused imports in some cases -// @ts-ignore -import type { LocalSandboxSettings } from './local-sandbox-configuration'; -// May contain unused imports in some cases -// @ts-ignore -import type { SshSettings } from './ssh-configuration'; - -/** - * Sandbox execution environment settings. - */ -export interface SandboxSettings { - /** - * Sandbox provider name. - */ - 'provider'?: string; - /** - * Whether to preserve the sandbox after the run. - */ - 'preserve'?: boolean; - /** - * Whether to use a devcontainer for the sandbox. - */ - 'devcontainer'?: boolean; - 'daytona'?: DaytonaSettings; - 'exe'?: ExeSettings; - 'ssh'?: SshSettings; - 'local'?: LocalSandboxSettings; - /** - * Environment variables injected into the sandbox. - */ - 'env'?: { [key: string]: string; }; -} - diff --git a/lib/packages/fabro-api-client/src/models/sandbox-settings.ts b/lib/packages/fabro-api-client/src/models/sandbox-settings.ts deleted file mode 100644 index ed50c1853..000000000 --- a/lib/packages/fabro-api-client/src/models/sandbox-settings.ts +++ /dev/null @@ -1,46 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - -// May contain unused imports in some cases -// @ts-ignore -import type { DaytonaSettings } from './daytona-settings'; -// May contain unused imports in some cases -// @ts-ignore -import type { LocalSandboxSettings } from './local-sandbox-settings'; - -/** - * Sandbox execution environment settings. - */ -export interface SandboxSettings { - /** - * Sandbox provider name. - */ - 'provider'?: string; - /** - * Whether to preserve the sandbox after the run. - */ - 'preserve'?: boolean; - /** - * Whether to use a devcontainer for the sandbox. - */ - 'devcontainer'?: boolean; - 'daytona'?: DaytonaSettings; - 'local'?: LocalSandboxSettings; - /** - * Environment variables injected into the sandbox. - */ - 'env'?: { [key: string]: string; }; -} - diff --git a/lib/packages/fabro-api-client/src/models/server-configuration.ts b/lib/packages/fabro-api-client/src/models/server-configuration.ts deleted file mode 100644 index b574c9d81..000000000 --- a/lib/packages/fabro-api-client/src/models/server-configuration.ts +++ /dev/null @@ -1,96 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - -// May contain unused imports in some cases -// @ts-ignore -import type { ApiSettings } from './api-configuration'; -// May contain unused imports in some cases -// @ts-ignore -import type { ArtifactsSettings } from './artifacts-configuration'; -// May contain unused imports in some cases -// @ts-ignore -import type { CheckpointSettings } from './checkpoint-configuration'; -// May contain unused imports in some cases -// @ts-ignore -import type { Features } from './features'; -// May contain unused imports in some cases -// @ts-ignore -import type { GitSettings } from './git-configuration'; -// May contain unused imports in some cases -// @ts-ignore -import type { GitHubSettings } from './git-hub-configuration'; -// May contain unused imports in some cases -// @ts-ignore -import type { HookDefinition } from './hook-definition'; -// May contain unused imports in some cases -// @ts-ignore -import type { LlmSettings } from './llm-configuration'; -// May contain unused imports in some cases -// @ts-ignore -import type { LogSettings } from './log-configuration'; -// May contain unused imports in some cases -// @ts-ignore -import type { McpServerEntry } from './mcp-server-entry'; -// May contain unused imports in some cases -// @ts-ignore -import type { PullRequestSettings } from './pull-request-configuration'; -// May contain unused imports in some cases -// @ts-ignore -import type { SandboxSettings } from './sandbox-configuration'; -// May contain unused imports in some cases -// @ts-ignore -import type { SetupSettings } from './setup-configuration'; -// May contain unused imports in some cases -// @ts-ignore -import type { WebSettings } from './web-configuration'; - -/** - * Structured server settings mirroring FabroSettings. - */ -export interface ServerSettings { - /** - * Data directory path. - */ - 'data_dir'?: string; - /** - * Maximum concurrent runs. - */ - 'max_concurrent_runs'?: number; - 'web'?: WebSettings; - 'api'?: ApiSettings; - 'git'?: GitSettings; - 'features'?: Features; - 'log'?: LogSettings; - /** - * Default working directory. - */ - 'work_dir'?: string; - 'llm'?: LlmSettings; - 'setup'?: SetupSettings; - 'sandbox'?: SandboxSettings; - /** - * Default variable map. - */ - 'vars'?: { [key: string]: string; }; - 'checkpoint'?: CheckpointSettings; - 'pull_request'?: PullRequestSettings; - 'hooks'?: Array; - 'artifacts'?: ArtifactsSettings; - /** - * Default MCP server configurations. - */ - 'mcp_servers'?: { [key: string]: McpServerEntry; }; - 'github'?: GitHubSettings; -} diff --git a/lib/packages/fabro-api-client/src/models/server-settings-exec.ts b/lib/packages/fabro-api-client/src/models/server-settings-exec.ts deleted file mode 100644 index 905b9864a..000000000 --- a/lib/packages/fabro-api-client/src/models/server-settings-exec.ts +++ /dev/null @@ -1,50 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -export interface ServerSettingsExec { - /** - * Default exec provider. - */ - 'provider'?: string; - /** - * Default exec model. - */ - 'model'?: string; - /** - * Exec permission level. - */ - 'permissions'?: ServerSettingsExecPermissionsEnum; - /** - * Exec output format. - */ - 'output_format'?: ServerSettingsExecOutputFormatEnum; -} - -export const ServerSettingsExecPermissionsEnum = { - READ_ONLY: 'read-only', - READ_WRITE: 'read-write', - FULL: 'full' -} as const; - -export type ServerSettingsExecPermissionsEnum = typeof ServerSettingsExecPermissionsEnum[keyof typeof ServerSettingsExecPermissionsEnum]; -export const ServerSettingsExecOutputFormatEnum = { - TEXT: 'text', - JSON: 'json' -} as const; - -export type ServerSettingsExecOutputFormatEnum = typeof ServerSettingsExecOutputFormatEnum[keyof typeof ServerSettingsExecOutputFormatEnum]; - - diff --git a/lib/packages/fabro-api-client/src/models/server-settings-fabro.ts b/lib/packages/fabro-api-client/src/models/server-settings-fabro.ts deleted file mode 100644 index 4760e3096..000000000 --- a/lib/packages/fabro-api-client/src/models/server-settings-fabro.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -export interface ServerSettingsFabro { - /** - * Project fabro root directory. - */ - 'root'?: string; -} - diff --git a/lib/packages/fabro-api-client/src/models/server-settings-server-tls.ts b/lib/packages/fabro-api-client/src/models/server-settings-server-tls.ts deleted file mode 100644 index 497356fa3..000000000 --- a/lib/packages/fabro-api-client/src/models/server-settings-server-tls.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -export interface ServerSettingsServerTls { - /** - * Client certificate path. - */ - 'cert'?: string; - /** - * Client key path. - */ - 'key'?: string; - /** - * Certificate authority path. - */ - 'ca'?: string; -} - diff --git a/lib/packages/fabro-api-client/src/models/server-settings-server.ts b/lib/packages/fabro-api-client/src/models/server-settings-server.ts deleted file mode 100644 index 8371218cb..000000000 --- a/lib/packages/fabro-api-client/src/models/server-settings-server.ts +++ /dev/null @@ -1,27 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - -// May contain unused imports in some cases -// @ts-ignore -import type { ServerSettingsServerTls } from './server-settings-server-tls'; - -export interface ServerSettingsServer { - /** - * Default server target for CLI commands. - */ - 'target'?: string; - 'tls'?: ServerSettingsServerTls; -} - diff --git a/lib/packages/fabro-api-client/src/models/server-settings.ts b/lib/packages/fabro-api-client/src/models/server-settings.ts deleted file mode 100644 index efc03d017..000000000 --- a/lib/packages/fabro-api-client/src/models/server-settings.ts +++ /dev/null @@ -1,153 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - -// May contain unused imports in some cases -// @ts-ignore -import type { ApiSettings } from './api-settings'; -// May contain unused imports in some cases -// @ts-ignore -import type { ArtifactsSettings } from './artifacts-settings'; -// May contain unused imports in some cases -// @ts-ignore -import type { CheckpointSettings } from './checkpoint-settings'; -// May contain unused imports in some cases -// @ts-ignore -import type { Features } from './features'; -// May contain unused imports in some cases -// @ts-ignore -import type { GitHubSettings } from './git-hub-settings'; -// May contain unused imports in some cases -// @ts-ignore -import type { GitSettings } from './git-settings'; -// May contain unused imports in some cases -// @ts-ignore -import type { HookDefinition } from './hook-definition'; -// May contain unused imports in some cases -// @ts-ignore -import type { LlmSettings } from './llm-settings'; -// May contain unused imports in some cases -// @ts-ignore -import type { LogSettings } from './log-settings'; -// May contain unused imports in some cases -// @ts-ignore -import type { McpServerEntry } from './mcp-server-entry'; -// May contain unused imports in some cases -// @ts-ignore -import type { PullRequestSettings } from './pull-request-settings'; -// May contain unused imports in some cases -// @ts-ignore -import type { SandboxSettings } from './sandbox-settings'; -// May contain unused imports in some cases -// @ts-ignore -import type { ServerSettingsExec } from './server-settings-exec'; -// May contain unused imports in some cases -// @ts-ignore -import type { ServerSettingsFabro } from './server-settings-fabro'; -// May contain unused imports in some cases -// @ts-ignore -import type { ServerSettingsServer } from './server-settings-server'; -// May contain unused imports in some cases -// @ts-ignore -import type { SetupSettings } from './setup-settings'; -// May contain unused imports in some cases -// @ts-ignore -import type { WebSettings } from './web-settings'; - -/** - * Structured server settings mirroring fabro_types::Settings. - */ -export interface ServerSettings { - /** - * Settings schema version. - */ - 'version'?: number; - /** - * Default goal description. - */ - 'goal'?: string; - /** - * Path to a goal file. - */ - 'goal_file'?: string; - /** - * Default Graphviz graph path. - */ - 'graph'?: string; - /** - * Default label map. - */ - 'labels'?: { [key: string]: string; }; - 'server'?: ServerSettingsServer; - 'exec'?: ServerSettingsExec; - /** - * Prevent system idle sleep while running. - */ - 'prevent_idle_sleep'?: boolean; - /** - * Enable verbose output by default. - */ - 'verbose'?: boolean; - /** - * Whether upgrade checks are enabled. - */ - 'upgrade_check'?: boolean; - /** - * Default dry-run mode. - */ - 'dry_run'?: boolean; - /** - * Default auto-approve mode. - */ - 'auto_approve'?: boolean; - /** - * Skip retro generation by default. - */ - 'no_retro'?: boolean; - /** - * Storage directory path. - */ - 'storage_dir'?: string; - /** - * Maximum concurrent runs. - */ - 'max_concurrent_runs'?: number; - 'web'?: WebSettings; - 'api'?: ApiSettings; - 'git'?: GitSettings; - 'features'?: Features; - 'log'?: LogSettings; - /** - * Default working directory. - */ - 'work_dir'?: string; - 'llm'?: LlmSettings; - 'setup'?: SetupSettings; - 'sandbox'?: SandboxSettings; - /** - * Default variable map. - */ - 'vars'?: { [key: string]: string; }; - 'checkpoint'?: CheckpointSettings; - 'pull_request'?: PullRequestSettings; - 'hooks'?: Array; - 'artifacts'?: ArtifactsSettings; - /** - * Default MCP server configurations. - */ - 'mcp_servers'?: { [key: string]: McpServerEntry; }; - 'github'?: GitHubSettings; - 'fabro'?: ServerSettingsFabro; -} - diff --git a/lib/packages/fabro-api-client/src/models/setup-configuration.ts b/lib/packages/fabro-api-client/src/models/setup-configuration.ts deleted file mode 100644 index 538cde1c0..000000000 --- a/lib/packages/fabro-api-client/src/models/setup-configuration.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * Setup commands run before the workflow. - */ -export interface SetupSettings { - /** - * Shell commands to execute. - */ - 'commands': Array; - /** - * Timeout per command in milliseconds. - */ - 'timeout_ms'?: number; -} - diff --git a/lib/packages/fabro-api-client/src/models/setup-settings.ts b/lib/packages/fabro-api-client/src/models/setup-settings.ts deleted file mode 100644 index 538cde1c0..000000000 --- a/lib/packages/fabro-api-client/src/models/setup-settings.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * Setup commands run before the workflow. - */ -export interface SetupSettings { - /** - * Shell commands to execute. - */ - 'commands': Array; - /** - * Timeout per command in milliseconds. - */ - 'timeout_ms'?: number; -} - diff --git a/lib/packages/fabro-api-client/src/models/ssh-configuration.ts b/lib/packages/fabro-api-client/src/models/ssh-configuration.ts deleted file mode 100644 index 37fef613a..000000000 --- a/lib/packages/fabro-api-client/src/models/ssh-configuration.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * SSH sandbox configuration for user-provided hosts. - */ -export interface SshSettings { - /** - * SSH destination (e.g. user@host or an SSH alias). - */ - 'destination': string; - /** - * Remote working directory. - */ - 'working_directory': string; - /** - * Optional path to a custom SSH config file. - */ - 'config_file'?: string; -} - diff --git a/lib/packages/fabro-api-client/src/models/ssh-settings.ts b/lib/packages/fabro-api-client/src/models/ssh-settings.ts deleted file mode 100644 index 37fef613a..000000000 --- a/lib/packages/fabro-api-client/src/models/ssh-settings.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * SSH sandbox configuration for user-provided hosts. - */ -export interface SshSettings { - /** - * SSH destination (e.g. user@host or an SSH alias). - */ - 'destination': string; - /** - * Remote working directory. - */ - 'working_directory': string; - /** - * Optional path to a custom SSH config file. - */ - 'config_file'?: string; -} - diff --git a/lib/packages/fabro-api-client/src/models/tls-configuration.ts b/lib/packages/fabro-api-client/src/models/tls-configuration.ts deleted file mode 100644 index 541cb1245..000000000 --- a/lib/packages/fabro-api-client/src/models/tls-configuration.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * TLS certificate configuration. - */ -export interface TlsSettings { - /** - * Certificate file path. - */ - 'cert': string; - /** - * Key file path. - */ - 'key': string; - /** - * CA certificate file path. - */ - 'ca': string; -} - diff --git a/lib/packages/fabro-api-client/src/models/tls-settings.ts b/lib/packages/fabro-api-client/src/models/tls-settings.ts deleted file mode 100644 index 541cb1245..000000000 --- a/lib/packages/fabro-api-client/src/models/tls-settings.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * TLS certificate configuration. - */ -export interface TlsSettings { - /** - * Certificate file path. - */ - 'cert': string; - /** - * Key file path. - */ - 'key': string; - /** - * CA certificate file path. - */ - 'ca': string; -} - diff --git a/lib/packages/fabro-api-client/src/models/web-configuration.ts b/lib/packages/fabro-api-client/src/models/web-configuration.ts deleted file mode 100644 index 973e4af16..000000000 --- a/lib/packages/fabro-api-client/src/models/web-configuration.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - -// May contain unused imports in some cases -// @ts-ignore -import type { AuthSettings } from './auth-configuration'; - -/** - * Web UI configuration. - */ -export interface WebSettings { - /** - * Web UI URL. - */ - 'url'?: string; - 'auth'?: AuthSettings; -} - diff --git a/lib/packages/fabro-api-client/src/models/web-settings.ts b/lib/packages/fabro-api-client/src/models/web-settings.ts deleted file mode 100644 index a1bd5dd22..000000000 --- a/lib/packages/fabro-api-client/src/models/web-settings.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - -// May contain unused imports in some cases -// @ts-ignore -import type { AuthSettings } from './auth-settings'; - -/** - * Web UI configuration. - */ -export interface WebSettings { - /** - * Web UI URL. - */ - 'url'?: string; - 'auth'?: AuthSettings; -} - diff --git a/lib/packages/fabro-api-client/src/models/webhook-configuration.ts b/lib/packages/fabro-api-client/src/models/webhook-configuration.ts deleted file mode 100644 index 8bd2e9e39..000000000 --- a/lib/packages/fabro-api-client/src/models/webhook-configuration.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * Webhook delivery configuration. - */ -export interface WebhookSettings { - /** - * Webhook delivery strategy. - */ - 'strategy': WebhookSettingsStrategyEnum; -} - -export const WebhookSettingsStrategyEnum = { - TAILSCALE_FUNNEL: 'tailscale_funnel' -} as const; - -export type WebhookSettingsStrategyEnum = typeof WebhookSettingsStrategyEnum[keyof typeof WebhookSettingsStrategyEnum]; - - diff --git a/lib/packages/fabro-api-client/src/models/webhook-settings.ts b/lib/packages/fabro-api-client/src/models/webhook-settings.ts deleted file mode 100644 index 8bd2e9e39..000000000 --- a/lib/packages/fabro-api-client/src/models/webhook-settings.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * Fabro Run API - * HTTP API for managing Fabro workflow run executions. - * - * The version of the OpenAPI document: 0.1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - - - -/** - * Webhook delivery configuration. - */ -export interface WebhookSettings { - /** - * Webhook delivery strategy. - */ - 'strategy': WebhookSettingsStrategyEnum; -} - -export const WebhookSettingsStrategyEnum = { - TAILSCALE_FUNNEL: 'tailscale_funnel' -} as const; - -export type WebhookSettingsStrategyEnum = typeof WebhookSettingsStrategyEnum[keyof typeof WebhookSettingsStrategyEnum]; - - From f5b9f82a278a344cbed57b80f6f8b5e4f124e159 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 9 Apr 2026 17:16:26 -0400 Subject: [PATCH 25/47] feat(settings): stage 6.6 wire server + CLI to v2 SettingsFile DTO MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the Stage 6.2 stopgap `strip_nulls(serde_json::to_value(full SettingsFile))` path in `get_server_settings` with an explicit redaction pass in the new `fabro_server::settings_view` module. The redaction drops the narrow set of fields that leak operational secrets or host filesystem layout: - `server.listen.*` (bind + TLS material) - `server.auth.api.jwt.{issuer, audience}` (auth topology) - `server.auth.api.mtls.ca` (filesystem path) - `server.auth.web.providers.github.client_secret` Every other field is preserved. `InterpString` values that reference `${env.NAME}` already serialize to their unresolved template form, so no additional env-provenance walk is needed in this pass. Implements the real `/api/v1/runs/:id/settings` handler — previously wired to `not_implemented` — by opening the run reader, reading the persisted `RunRecord.settings`, running it through the same redaction, and serializing. The demo route still points at `demo::get_run_settings`, unchanged. Updates `fabro-cli` to deserialize the new wire shape as `SettingsFile` directly: - `server_client::retrieve_server_settings` now returns `SettingsFile` (no longer the legacy flat `Settings`) by decoding the progenitor `types::ServerSettings` newtype map into a `serde_json::Value` and then into `SettingsFile`. - `commands/config/mod.rs::legacy_settings_to_v2` shim (TODO-1) **deleted**; `merged_config` passes the v2 file straight into `effective_settings::resolve_settings`. - The `fabro-cli` integration tests rewrite their mock `/api/v1/settings` payloads as v2 TOML via `ConfigLayer::parse` instead of hand-rolling the legacy TOML shape. All 3,761 workspace tests pass. `cargo fmt --check --all` and `cargo clippy --workspace -- -D warnings` are clean. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../fabro-cli/src/commands/config/mod.rs | 86 +------ lib/crates/fabro-cli/src/server_client.rs | 9 +- lib/crates/fabro-cli/tests/it/cmd/config.rs | 44 ++-- lib/crates/fabro-server/src/lib.rs | 1 + lib/crates/fabro-server/src/server.rs | 53 +++- lib/crates/fabro-server/src/settings_view.rs | 237 ++++++++++++++++++ 6 files changed, 304 insertions(+), 126 deletions(-) create mode 100644 lib/crates/fabro-server/src/settings_view.rs diff --git a/lib/crates/fabro-cli/src/commands/config/mod.rs b/lib/crates/fabro-cli/src/commands/config/mod.rs index 414d43ae5..f81fd3c7b 100644 --- a/lib/crates/fabro-cli/src/commands/config/mod.rs +++ b/lib/crates/fabro-cli/src/commands/config/mod.rs @@ -9,7 +9,7 @@ use fabro_config::ConfigLayer; use fabro_config::effective_settings; use fabro_config::effective_settings::{EffectiveSettingsLayers, EffectiveSettingsMode}; use fabro_config::project; -use fabro_types::settings::v2::SettingsFile; +use fabro_types::settings::SettingsFile; fn config_layers( ctx: &CommandContext, @@ -70,11 +70,7 @@ async fn merged_config(args: &SettingsArgs) -> anyhow::Result { let ctx = CommandContext::for_target(&args.target)?; let target = user_config::resolve_server_target(&args.target, ctx.machine_settings())?; - // `retrieve_server_settings` currently returns a legacy flat `Settings`; - // route it through the v2 bridge shim for the consumer-side call. - // Stage 6.6 rewrites the API client to return v2 types directly. - let legacy_server = ctx.server().await?.retrieve_server_settings().await?; - let server_settings = legacy_settings_to_v2(&legacy_server); + let server_settings = ctx.server().await?.retrieve_server_settings().await?; let mode = match target { user_config::ServerTarget::HttpUrl { .. } => EffectiveSettingsMode::RemoteServer, user_config::ServerTarget::UnixSocket(_) => EffectiveSettingsMode::LocalDaemon, @@ -83,84 +79,6 @@ async fn merged_config(args: &SettingsArgs) -> anyhow::Result { effective_settings::resolve_settings(layers, Some(&server_settings), mode) } -/// Stopgap reverse bridge from the legacy flat `Settings` to a v2 -/// `SettingsFile`. `retrieve_server_settings` still returns the legacy -/// shape across the wire; the v2 resolver needs server-settings in v2 -/// shape. This reverse-maps the fields that matter for server-side -/// defaults (storage, scheduler, integrations, verbose, run model). -/// Stage 6.6 rewrites the API client to return v2 types directly and -/// deletes this helper. -fn legacy_settings_to_v2(legacy: &fabro_types::Settings) -> SettingsFile { - use fabro_types::settings::v2::cli::{CliLayer, CliOutputLayer, OutputVerbosity}; - use fabro_types::settings::v2::interp::InterpString; - use fabro_types::settings::v2::run::{RunLayer, RunModelLayer}; - use fabro_types::settings::v2::server::{ - GithubIntegrationLayer, ServerIntegrationsLayer, ServerLayer, ServerSchedulerLayer, - ServerStorageLayer, SlackIntegrationLayer, - }; - - let mut file = SettingsFile::default(); - - if let Some(storage_dir) = legacy.storage_dir.as_ref() { - let server = file.server.get_or_insert_with(ServerLayer::default); - server.storage = Some(ServerStorageLayer { - root: Some(InterpString::parse(&storage_dir.to_string_lossy())), - }); - } - if let Some(max_concurrent) = legacy.max_concurrent_runs { - let server = file.server.get_or_insert_with(ServerLayer::default); - server.scheduler = Some(ServerSchedulerLayer { - max_concurrent_runs: Some(max_concurrent), - }); - } - if let Some(git) = legacy.git.as_ref() { - let server = file.server.get_or_insert_with(ServerLayer::default); - let integrations = server - .integrations - .get_or_insert_with(ServerIntegrationsLayer::default); - let github = integrations - .github - .get_or_insert_with(GithubIntegrationLayer::default); - github.app_id = git.app_id.as_deref().map(InterpString::parse); - github.client_id = git.client_id.as_deref().map(InterpString::parse); - github.slug = git.slug.as_deref().map(InterpString::parse); - } - if let Some(slack) = legacy.slack.as_ref() { - let server = file.server.get_or_insert_with(ServerLayer::default); - let integrations = server - .integrations - .get_or_insert_with(ServerIntegrationsLayer::default); - integrations.slack = Some(SlackIntegrationLayer { - enabled: None, - default_channel: slack.default_channel.as_deref().map(InterpString::parse), - }); - } - if let Some(llm) = legacy.llm.as_ref() { - let run = file.run.get_or_insert_with(RunLayer::default); - run.model = Some(RunModelLayer { - provider: llm.provider.as_deref().map(InterpString::parse), - name: llm.model.as_deref().map(InterpString::parse), - fallbacks: Vec::new(), - }); - } - if let Some(vars) = legacy.vars.as_ref() { - let run = file.run.get_or_insert_with(RunLayer::default); - run.inputs = Some( - vars.iter() - .map(|(k, v)| (k.clone(), toml::Value::String(v.clone()))) - .collect(), - ); - } - if let Some(true) = legacy.verbose { - let cli = file.cli.get_or_insert_with(CliLayer::default); - cli.output = Some(CliOutputLayer { - verbosity: Some(OutputVerbosity::Verbose), - ..CliOutputLayer::default() - }); - } - file -} - pub(crate) async fn execute(args: &SettingsArgs, globals: &GlobalArgs) -> anyhow::Result<()> { let config = Box::pin(merged_config(args)).await?; if globals.json { diff --git a/lib/crates/fabro-cli/src/server_client.rs b/lib/crates/fabro-cli/src/server_client.rs index 5d83052fe..fe07f607b 100644 --- a/lib/crates/fabro-cli/src/server_client.rs +++ b/lib/crates/fabro-cli/src/server_client.rs @@ -8,8 +8,7 @@ use bytes::Bytes; use fabro_api::types; use fabro_server::bind::Bind; use fabro_store::{EventEnvelope, RunSummary, StageId}; -use fabro_types::Settings; -use fabro_types::settings::v2::SettingsFile; +use fabro_types::settings::SettingsFile; use fabro_types::{RunBlobId, RunEvent, RunId}; use fabro_workflow::artifact_snapshot::CapturedArtifactInfo; use futures::StreamExt; @@ -279,14 +278,16 @@ impl ServerStoreClient { &self.base_url } - pub(crate) async fn retrieve_server_settings(&self) -> Result { + pub(crate) async fn retrieve_server_settings(&self) -> Result { let response = self .client .retrieve_server_settings() .send() .await .map_err(map_api_error)?; - convert_type(response.into_inner()) + let raw = serde_json::Value::Object(response.into_inner().into()); + serde_json::from_value::(raw) + .context("server returned a settings payload that does not match the v2 schema") } pub(crate) async fn create_run_from_manifest( diff --git a/lib/crates/fabro-cli/tests/it/cmd/config.rs b/lib/crates/fabro-cli/tests/it/cmd/config.rs index fe90025c4..985c411c5 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/config.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/config.rs @@ -1,8 +1,8 @@ use std::path::PathBuf; +use fabro_config::ConfigLayer; use fabro_test::{fabro_snapshot, test_context}; -use fabro_types::Settings; -use fabro_types::settings::v2::SettingsFile; +use fabro_types::settings::SettingsFile; use httpmock::MockServer; use predicates::prelude::*; @@ -35,45 +35,29 @@ fn parse_settings(stdout: &[u8]) -> SettingsFile { serde_yaml::from_slice(stdout).expect("stdout should be valid YAML SettingsFile") } -fn server_settings_fixture() -> Settings { - toml::from_str( +fn server_settings_fixture() -> SettingsFile { + ConfigLayer::parse( r#" -storage_dir = "/srv/fabro-server" -verbose = false +_version = 1 -[llm] -model = "server-model" +[server.storage] +root = "/srv/fabro-server" + +[run.model] +name = "server-model" provider = "openai" -[vars] +[run.inputs] server_only = "1" shared = "server" "#, ) .expect("server settings fixture should parse") + .into() } -fn server_settings_body(settings: &Settings) -> String { - fn strip_nulls(value: &mut serde_json::Value) { - match value { - serde_json::Value::Object(map) => { - for child in map.values_mut() { - strip_nulls(child); - } - map.retain(|_, child| !child.is_null()); - } - serde_json::Value::Array(values) => { - for child in values { - strip_nulls(child); - } - } - _ => {} - } - } - - let mut value = serde_json::to_value(settings).expect("settings fixture should serialize"); - strip_nulls(&mut value); - serde_json::to_string(&value).expect("settings payload should serialize") +fn server_settings_body(settings: &SettingsFile) -> String { + serde_json::to_string(settings).expect("settings payload should serialize") } /// Set up home config and project config for settings command tests. diff --git a/lib/crates/fabro-server/src/lib.rs b/lib/crates/fabro-server/src/lib.rs index 85e49717a..a2465afdb 100644 --- a/lib/crates/fabro-server/src/lib.rs +++ b/lib/crates/fabro-server/src/lib.rs @@ -14,6 +14,7 @@ mod run_manifest; pub mod secret_store; pub mod serve; pub mod server; +mod settings_view; pub mod static_files; pub mod server_config { pub use fabro_types::Settings; diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 9cad3a01e..14e69da33 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -76,6 +76,7 @@ use crate::jwt_auth::{ }; use crate::run_manifest; use crate::secret_store::{SecretStore, SecretStoreError}; +use crate::settings_view; use crate::static_files; use crate::web_auth; use fabro_interview::{ @@ -1009,7 +1010,7 @@ fn real_routes() -> Router> { get(get_stage_artifact), ) .route("/runs/{id}/billing", get(get_run_billing)) - .route("/runs/{id}/settings", get(not_implemented)) + .route("/runs/{id}/settings", get(get_run_settings)) .route("/runs/{id}/steer", post(not_implemented)) .route("/runs/{id}/preview", post(generate_preview_url)) .route("/runs/{id}/ssh", post(create_ssh_access)) @@ -1064,13 +1065,8 @@ async fn get_server_settings( State(state): State>, ) -> Response { let settings = state.settings.read().unwrap().clone(); - // Stage 6.6 TODO: replace this with an explicit allow-list DTO that - // reads directly from the v2 tree and redacts env-sourced values via - // `InterpString` provenance. For now we serialize the full v2 - // `SettingsFile` as JSON so the web UI still has a response body -- - // the legacy `ServerSettings` OpenAPI schema will be rewritten in - // 6.6 alongside the fabro-web DTO updates. - let mut value = match serde_json::to_value(&settings) { + let redacted = settings_view::redact_for_api(&settings); + let mut value = match serde_json::to_value(&redacted) { Ok(value) => value, Err(err) => { return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()) @@ -4036,6 +4032,47 @@ async fn get_run_status( } } +async fn get_run_settings( + _auth: AuthenticatedService, + State(state): State>, + Path(id): Path, +) -> Response { + let id = match parse_run_id_path(&id) { + Ok(id) => id, + Err(response) => return response, + }; + let run_store = match state.store.open_run_reader(&id).await { + Ok(store) => store, + Err(fabro_store::StoreError::RunNotFound(_)) => { + return ApiError::not_found("Run not found.").into_response(); + } + Err(err) => { + return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()) + .into_response(); + } + }; + let run_state = match run_store.state().await { + Ok(state) => state, + Err(err) => { + return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()) + .into_response(); + } + }; + let Some(run_record) = run_state.run else { + return ApiError::not_found("Run not found.").into_response(); + }; + let redacted = settings_view::redact_for_api(&run_record.settings); + let mut value = match serde_json::to_value(&redacted) { + Ok(value) => value, + Err(err) => { + return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()) + .into_response(); + } + }; + strip_nulls(&mut value); + (StatusCode::OK, Json(value)).into_response() +} + async fn get_questions( _auth: AuthenticatedService, State(state): State>, diff --git a/lib/crates/fabro-server/src/settings_view.rs b/lib/crates/fabro-server/src/settings_view.rs new file mode 100644 index 000000000..8c1d23f5e --- /dev/null +++ b/lib/crates/fabro-server/src/settings_view.rs @@ -0,0 +1,237 @@ +//! Outward-facing view of [`SettingsFile`] for API responses. +//! +//! `/api/v1/settings` and `/api/v1/runs/:id/settings` return the server's v2 +//! [`SettingsFile`] directly as JSON so authenticated clients (the `fabro +//! settings` CLI, the web UI) can see the effective configuration. Before +//! serialization, this module drops the handful of fields that would leak +//! operational secrets or host-specific filesystem layout. +//! +//! ## What gets dropped +//! +//! Per the requirements doc (R16, R52, R53, R79–R81) and the Stage 6.6 plan: +//! +//! - `server.listen` — the whole subtree. Bind address reveals network +//! topology; `[server.listen.tls]` cert/key/ca paths reveal the host +//! filesystem layout. +//! - `server.auth.api.jwt.issuer` and `jwt.audience` — auth topology. Keeps +//! `enabled` so clients can tell whether JWT auth is on. +//! - `server.auth.api.mtls.ca` — filesystem path to the CA bundle. Keeps +//! `enabled`. +//! - `server.auth.web.providers.github.client_secret` — explicit OAuth +//! secret. Keeps `enabled` and `client_id` (the latter is public in OAuth). +//! +//! ## Why that's all +//! +//! The rest of the v2 tree is either: +//! +//! - A literal non-secret value (storage root, scheduler limit, integration +//! slug, feature flag), OR +//! - An [`InterpString`] containing `${env.NAME}` tokens. `InterpString`'s +//! default serialization preserves the *unresolved* template form, so the +//! wire payload surfaces `"Bearer ${env.TOKEN}"` instead of the resolved +//! secret value. No additional redaction pass is needed. +//! +//! Any future field that carries a raw secret in-band (without env +//! interpolation) must be added to the drop list below. + +use fabro_types::settings::SettingsFile; + +/// Build a redacted clone of `settings` safe to serialize outward. +/// +/// See the module docs for the drop-list rationale. +#[must_use] +pub(crate) fn redact_for_api(settings: &SettingsFile) -> SettingsFile { + let mut out = settings.clone(); + + if let Some(server) = out.server.as_mut() { + // Bind address + TLS key/cert paths: host operational details. + server.listen = None; + + if let Some(auth) = server.auth.as_mut() { + if let Some(api) = auth.api.as_mut() { + if let Some(jwt) = api.jwt.as_mut() { + jwt.issuer = None; + jwt.audience = None; + } + if let Some(mtls) = api.mtls.as_mut() { + mtls.ca = None; + } + } + if let Some(web) = auth.web.as_mut() { + if let Some(providers) = web.providers.as_mut() { + if let Some(github) = providers.github.as_mut() { + github.client_secret = None; + } + } + } + } + } + + out +} + +#[cfg(test)] +mod tests { + use super::*; + use fabro_config::ConfigLayer; + + fn parse(source: &str) -> SettingsFile { + ConfigLayer::parse(source) + .expect("fixture should parse") + .into() + } + + #[test] + fn drops_server_listen_entirely() { + let settings = parse( + r#" +_version = 1 + +[server.listen] +type = "tcp" +address = "127.0.0.1:32276" + +[server.listen.tls] +cert = "/etc/fabro/tls/cert.pem" +key = "/etc/fabro/tls/key.pem" +ca = "/etc/fabro/tls/ca.pem" +"#, + ); + let redacted = redact_for_api(&settings); + assert!(redacted.server.unwrap().listen.is_none()); + } + + #[test] + fn drops_jwt_issuer_and_audience_but_keeps_enabled() { + let settings = parse( + r#" +_version = 1 + +[server.auth.api.jwt] +enabled = true +issuer = "https://auth.example.com" +audience = "fabro" +"#, + ); + let redacted = redact_for_api(&settings); + let jwt = redacted + .server + .unwrap() + .auth + .unwrap() + .api + .unwrap() + .jwt + .unwrap(); + assert_eq!(jwt.enabled, Some(true)); + assert!(jwt.issuer.is_none()); + assert!(jwt.audience.is_none()); + } + + #[test] + fn drops_mtls_ca_path_but_keeps_enabled() { + let settings = parse( + r#" +_version = 1 + +[server.auth.api.mtls] +enabled = true +ca = "/etc/fabro/tls/ca.pem" +"#, + ); + let redacted = redact_for_api(&settings); + let mtls = redacted + .server + .unwrap() + .auth + .unwrap() + .api + .unwrap() + .mtls + .unwrap(); + assert_eq!(mtls.enabled, Some(true)); + assert!(mtls.ca.is_none()); + } + + #[test] + fn drops_github_client_secret_but_keeps_client_id_and_enabled() { + let settings = parse( + r#" +_version = 1 + +[server.auth.web.providers.github] +enabled = true +client_id = "Iv1.abcdef" +client_secret = "${env.GITHUB_OAUTH_SECRET}" +"#, + ); + let redacted = redact_for_api(&settings); + let github = redacted + .server + .unwrap() + .auth + .unwrap() + .web + .unwrap() + .providers + .unwrap() + .github + .unwrap(); + assert_eq!(github.enabled, Some(true)); + assert!(github.client_id.is_some()); + assert!(github.client_secret.is_none()); + } + + #[test] + fn preserves_run_cli_project_and_features() { + let settings = parse( + r#" +_version = 1 + +[project] +name = "Fabro" + +[run] +goal = "ship it" + +[run.model] +provider = "anthropic" +name = "sonnet" + +[cli.output] +verbosity = "verbose" + +[features] +session_sandboxes = true + +[server.scheduler] +max_concurrent_runs = 9 + +[server.storage] +root = "/srv/fabro" + +[server.integrations.github] +app_id = "12345" +client_id = "Iv1.abcdef" +slug = "fabro-app" +"#, + ); + let redacted = redact_for_api(&settings); + assert!(redacted.project.is_some()); + let run = redacted.run.unwrap(); + assert!(run.goal.is_some()); + assert!(run.model.is_some()); + assert!(redacted.cli.is_some()); + assert!(redacted.features.is_some()); + let server = redacted.server.unwrap(); + assert_eq!( + server.scheduler.and_then(|s| s.max_concurrent_runs), + Some(9) + ); + assert!(server.storage.is_some()); + let github = server.integrations.unwrap().github.unwrap(); + assert!(github.app_id.is_some()); + assert!(github.client_id.is_some()); + assert!(github.slug.is_some()); + } +} From 65a9fd137b143c69acb985854d0a0dc47e4c43a7 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 9 Apr 2026 17:18:34 -0400 Subject: [PATCH 26/47] refactor(fabro-web): stage 6.6 rewrite workflowData literal to v2 shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hardcoded sample workflow entries in `workflow-detail.tsx` still embedded the legacy flat `RunSettings` shape (top-level `llm`, `vars`, `sandbox`, `setup`) — a visible mismatch with what the server now returns on `/api/v1/runs/:id/settings`. Rewrites the four static literals (fix_build, implement, sync_drift, expand) to mirror the v2 `SettingsFile` tree: `_version`, `run.goal`, `run.inputs`, `run.model`, `run.sandbox`, `run.prepare.steps`, `run.prepare.timeout`, etc. Duration and size fields now use the human-readable forms (`"120s"`, `"8GB"`, `"10GB"`) per R83 / R84. Adds a module-level doc comment pointing readers at the `fabro_types::settings::SettingsFile` Rust type as the source of truth for the shape. `RunSettings` stays as `Record`, so the literal typechecks without needing a formal type assertion on each entry. fabro-web `typecheck` / `test` / `build` stay green. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/fabro-web/app/routes/workflow-detail.tsx | 107 ++++++++++-------- 1 file changed, 60 insertions(+), 47 deletions(-) diff --git a/apps/fabro-web/app/routes/workflow-detail.tsx b/apps/fabro-web/app/routes/workflow-detail.tsx index 691a9ada6..8ea83f45f 100644 --- a/apps/fabro-web/app/routes/workflow-detail.tsx +++ b/apps/fabro-web/app/routes/workflow-detail.tsx @@ -12,8 +12,11 @@ export interface WorkflowEntry { graph: string; } -// Keep this exported for backward compatibility with other routes that import it. -// It will be populated by the loader, but the static version is kept as fallback. +// Static sample data used by the `workflow-definition` index route for the +// hardcoded showcase workflows. Shape mirrors the v2 `SettingsFile` JSON +// returned by `/api/v1/runs/:id/settings` (see the Rust +// `fabro_types::settings::SettingsFile` type). Fields are opaque to the +// `RunSettings` TypeScript type, which is a bare `Record`. export const workflowData: Record = { fix_build: { name: "Fix Build", @@ -21,17 +24,18 @@ export const workflowData: Record = { filename: "fix_build.fabro", description: "Automatically diagnoses and fixes CI build failures by analyzing error logs, identifying root causes, and applying targeted code changes.", settings: { - version: 1, - goal: "Diagnose and fix CI build failures", - graph: "fix_build.fabro", - llm: { model: "claude-sonnet" }, - vars: { repo_url: "https://github.com/org/service", branch: "main" }, - sandbox: { - provider: "daytona", - daytona: { - auto_stop_interval: 60, - labels: { project: "fix-build" }, - snapshot: { name: "fix-build-dev", cpu: 4, memory: 8, disk: 10 }, + _version: 1, + run: { + goal: "Diagnose and fix CI build failures", + inputs: { repo_url: "https://github.com/org/service", branch: "main" }, + model: { name: "claude-sonnet" }, + sandbox: { + provider: "daytona", + daytona: { + auto_stop_interval: 60, + labels: { project: "fix-build" }, + snapshot: { name: "fix-build-dev", cpu: 4, memory: "8GB", disk: "10GB" }, + }, }, }, }, @@ -62,18 +66,25 @@ export const workflowData: Record = { filename: "implement.fabro", description: "Generates production-ready code from a technical blueprint, including tests, documentation, and a pull request ready for review.", settings: { - version: 1, - goal: "Implement feature from technical blueprint", - graph: "implement.fabro", - llm: { model: "claude-sonnet" }, - vars: { spec_path: "specs/feature.md", test_framework: "vitest" }, - setup: { commands: ["bun install", "bun run typecheck"], timeout_ms: 120000 }, - sandbox: { - provider: "daytona", - daytona: { - auto_stop_interval: 120, - labels: { project: "implement", team: "engineering" }, - snapshot: { name: "implement-dev", cpu: 4, memory: 8, disk: 20 }, + _version: 1, + run: { + goal: "Implement feature from technical blueprint", + inputs: { spec_path: "specs/feature.md", test_framework: "vitest" }, + model: { name: "claude-sonnet" }, + prepare: { + steps: [ + { command: ["bun", "install"] }, + { command: ["bun", "run", "typecheck"] }, + ], + timeout: "120s", + }, + sandbox: { + provider: "daytona", + daytona: { + auto_stop_interval: 120, + labels: { project: "implement", team: "engineering" }, + snapshot: { name: "implement-dev", cpu: 4, memory: "8GB", disk: "20GB" }, + }, }, }, }, @@ -118,17 +129,18 @@ export const workflowData: Record = { filename: "sync_drift.fabro", description: "Detects configuration and code drift between environments, then generates reconciliation patches to bring everything back in sync.", settings: { - version: 1, - goal: "Detect and reconcile configuration drift across environments", - graph: "sync_drift.fabro", - llm: { model: "claude-sonnet" }, - vars: { source_env: "production", target_env: "staging", drift_threshold: "warn" }, - sandbox: { - provider: "daytona", - daytona: { - auto_stop_interval: 120, - labels: { project: "sync-drift", team: "platform" }, - snapshot: { name: "sync-drift-dev", cpu: 2, memory: 4, disk: 10 }, + _version: 1, + run: { + goal: "Detect and reconcile configuration drift across environments", + inputs: { source_env: "production", target_env: "staging", drift_threshold: "warn" }, + model: { name: "claude-sonnet" }, + sandbox: { + provider: "daytona", + daytona: { + auto_stop_interval: 120, + labels: { project: "sync-drift", team: "platform" }, + snapshot: { name: "sync-drift-dev", cpu: 2, memory: "4GB", disk: "10GB" }, + }, }, }, }, @@ -163,17 +175,18 @@ export const workflowData: Record = { filename: "expand.fabro", description: "Evolves the product by analyzing usage patterns and specifications to propose and implement incremental improvements.", settings: { - version: 1, - goal: "Propose and implement incremental product improvements", - graph: "expand.fabro", - llm: { model: "claude-sonnet" }, - vars: { analytics_window: "30d", min_confidence: "0.8" }, - sandbox: { - provider: "daytona", - daytona: { - auto_stop_interval: 180, - labels: { project: "expand", team: "product" }, - snapshot: { name: "expand-dev", cpu: 2, memory: 4, disk: 10 }, + _version: 1, + run: { + goal: "Propose and implement incremental product improvements", + inputs: { analytics_window: "30d", min_confidence: "0.8" }, + model: { name: "claude-sonnet" }, + sandbox: { + provider: "daytona", + daytona: { + auto_stop_interval: 180, + labels: { project: "expand", team: "product" }, + snapshot: { name: "expand-dev", cpu: 2, memory: "4GB", disk: "10GB" }, + }, }, }, }, From 4a40c73b71d7c63598518caf5023483edc20e983 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 9 Apr 2026 17:32:03 -0400 Subject: [PATCH 27/47] refactor(settings): stage 6.3b delete legacy flat Settings struct MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deletes `fabro_types::Settings` — the ~65-field legacy flat view that has been read-only since Stage 6.1 migrated all production read sites to the v2 `SettingsFile`. The last remaining readers all fall out of this commit: - `fabro-server/src/demo/mod.rs` — the two demo settings fixtures (`runs::settings()` and `settings::server_settings()`) are rewritten as `serde_json::json!(...)` literals in the v2 `SettingsFile` shape. They produce the same wire bytes as the real handlers now return, so the demo page keeps rendering identically. - `fabro-server/src/lib.rs::server_config` — drops the `pub use fabro_types::Settings` re-export. Only the inner `fabro_types::settings::server::*` module (still around until the full runtime-type cleanup) remains. - `fabro-server/tests/it/openapi_conformance.rs` — drops the `server_settings_keys_match_openapi_spec` schema-drift test and all of its legacy type imports. The new freeform-object DTO in the spec (`type: object, additionalProperties: true`) has no `properties` to diff against, so the test was already a no-op. Leaves `all_spec_routes_are_routable` in place. - `fabro-store/src/run_state.rs` — test fixture was building a `Settings::default()` JSON payload; switched to `SettingsFile::default()`. - `fabro-types/src/run_event/mod.rs` — two `EventBody::RunCreated` round-trip tests were constructing `Settings::default()`; switched to `SettingsFile::default()`. - `fabro-workflow/tests/it/integration.rs` — the two `hook_toml_*_parsing` tests decoded top-level `[[hooks]]` into a legacy `Settings`. That parse path was removed in Stage 6.1; the tests are deleted and replaced with a comment pointing at the v2 `settings::v2::tree::tests` fixtures that cover the same ground. The legacy flat struct's module-level doc comment in `settings/mod.rs` is updated to explain the transitional runtime shapes that still live under `hook`, `mcp`, `project`, `run`, `sandbox`, `server`, and `user` — a follow-up pass will either promote them into their consumer crates or inline them at the call sites so the whole `settings/*.rs` file set can go away and 6.5b flattening can happen. 3,758 workspace tests pass. `cargo fmt --check --all` and `cargo clippy --workspace -- -D warnings` are clean. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/crates/fabro-server/src/demo/mod.rs | 200 +++++----- lib/crates/fabro-server/src/lib.rs | 1 - .../tests/it/openapi_conformance.rs | 348 +----------------- lib/crates/fabro-store/src/run_state.rs | 5 +- lib/crates/fabro-types/src/lib.rs | 2 +- lib/crates/fabro-types/src/run_event/mod.rs | 7 +- lib/crates/fabro-types/src/settings/mod.rs | 117 +----- .../fabro-workflow/tests/it/integration.rs | 97 +---- 8 files changed, 127 insertions(+), 650 deletions(-) diff --git a/lib/crates/fabro-server/src/demo/mod.rs b/lib/crates/fabro-server/src/demo/mod.rs index 024a5244c..ae2c3923b 100644 --- a/lib/crates/fabro-server/src/demo/mod.rs +++ b/lib/crates/fabro-server/src/demo/mod.rs @@ -1325,59 +1325,39 @@ mod runs { } pub(super) fn settings() -> serde_json::Value { - serde_json::to_value(fabro_types::Settings { - version: Some(1), - goal: Some("Add rate limiting to auth endpoints".into()), - graph: Some("implement.fabro".into()), - work_dir: Some("/workspace/api-server".into()), - llm: Some(fabro_types::settings::run::LlmSettings { - model: Some("claude-opus-4-6".into()), - provider: Some("anthropic".into()), - fallbacks: None, - }), - setup: Some(fabro_types::settings::run::SetupSettings { - commands: vec!["bun install".into(), "bun run typecheck".into()], - timeout_ms: Some(120_000), - }), - sandbox: Some(fabro_types::settings::sandbox::SandboxSettings { - provider: Some("daytona".into()), - preserve: None, - devcontainer: None, - local: None, - daytona: Some(fabro_sandbox::daytona::DaytonaConfig { - auto_stop_interval: Some(60), - labels: Some(std::collections::HashMap::from([( - "project".into(), - "api-server".into(), - )])), - snapshot: Some(fabro_sandbox::daytona::DaytonaSnapshotConfig { - name: "api-server-dev".into(), - cpu: Some(4), - memory: Some(8), - disk: Some(10), - dockerfile: None, - }), - network: Some(fabro_sandbox::daytona::DaytonaNetwork::Block), - skip_clone: false, - }), - env: None, - }), - vars: Some(std::collections::HashMap::from([ - ( - "repo_url".into(), - "https://github.com/org/api-server".into(), - ), - ("branch".into(), "feature/rate-limiting".into()), - ])), - hooks: vec![], - checkpoint: Default::default(), - pull_request: None, - artifacts: None, - mcp_servers: Default::default(), - github: None, - ..Default::default() + // v2 SettingsFile shape — matches what /api/v1/runs/:id/settings + // returns in production, so the demo renders identically. + serde_json::json!({ + "_version": 1, + "run": { + "goal": "Add rate limiting to auth endpoints", + "working_dir": "/workspace/api-server", + "model": { + "provider": "anthropic", + "name": "claude-opus-4-6" + }, + "prepare": { + "steps": [ + { "command": ["bun", "install"] }, + { "command": ["bun", "run", "typecheck"] } + ], + "timeout": "120s" + }, + "sandbox": { + "provider": "daytona", + "daytona": { + "auto_stop_interval": 60, + "labels": { "project": "api-server" }, + "snapshot": { + "name": "api-server-dev", + "cpu": 4, + "memory": "8GB", + "disk": "10GB" + } + } + } + } }) - .unwrap() } } @@ -1489,68 +1469,64 @@ mod insights { } mod settings { - use fabro_types::Settings; - use fabro_types::settings::server::*; - pub(super) fn server_settings() -> serde_json::Value { - serde_json::to_value(Settings { - storage_dir: Some("/home/fabro/.fabro".into()), - max_concurrent_runs: Some(10), - web: Some(WebSettings { - enabled: true, - url: "https://fabro.example.com".into(), - auth: AuthSettings { - provider: AuthProvider::Github, - allowed_usernames: vec!["brynary".into(), "alice".into()], + // v2 SettingsFile shape — matches what /api/v1/settings returns in + // production, so the demo renders identically. + serde_json::json!({ + "_version": 1, + "server": { + "storage": { + "root": "/home/fabro/.fabro" }, - }), - api: Some(ApiSettings { - base_url: "https://api.fabro.example.com".into(), - authentication_strategies: vec![ApiAuthStrategy::Jwt], - tls: None, - }), - git: Some(GitSettings { - provider: GitProvider::Github, - app_id: Some("12345".into()), - client_id: Some("Iv1.abc123".into()), - slug: Some("fabro-dev".into()), - author: Default::default(), - webhooks: None, - }), - features: Some(FeaturesSettings { - session_sandboxes: false, - retros: false, - }), - log: Default::default(), - llm: Some(fabro_types::settings::run::LlmSettings { - model: Some("claude-sonnet".into()), - provider: Some("anthropic".into()), - fallbacks: None, - }), - setup: None, - sandbox: Some(fabro_types::settings::sandbox::SandboxSettings { - provider: Some("daytona".into()), - preserve: None, - devcontainer: None, - local: None, - daytona: Some(fabro_sandbox::daytona::DaytonaConfig { - auto_stop_interval: Some(60), - labels: None, - snapshot: None, - network: Some(fabro_sandbox::daytona::DaytonaNetwork::Block), - skip_clone: false, - }), - env: None, - }), - vars: None, - checkpoint: Default::default(), - pull_request: None, - artifacts: None, - hooks: vec![], - mcp_servers: Default::default(), - github: None, - ..Default::default() + "scheduler": { + "max_concurrent_runs": 10 + }, + "api": { + "url": "https://api.fabro.example.com" + }, + "web": { + "enabled": true, + "url": "https://fabro.example.com" + }, + "auth": { + "api": { + "jwt": { "enabled": true } + }, + "web": { + "allowed_usernames": ["brynary", "alice"], + "providers": { + "github": { + "enabled": true, + "client_id": "Iv1.abc123" + } + } + } + }, + "integrations": { + "github": { + "app_id": "12345", + "client_id": "Iv1.abc123", + "slug": "fabro-dev" + } + } + }, + "run": { + "model": { + "provider": "anthropic", + "name": "claude-sonnet" + }, + "sandbox": { + "provider": "daytona", + "daytona": { + "auto_stop_interval": 60, + "network": "block" + } + } + }, + "features": { + "session_sandboxes": false, + "retros": false + } }) - .unwrap() } } diff --git a/lib/crates/fabro-server/src/lib.rs b/lib/crates/fabro-server/src/lib.rs index a2465afdb..4a7da2c01 100644 --- a/lib/crates/fabro-server/src/lib.rs +++ b/lib/crates/fabro-server/src/lib.rs @@ -17,7 +17,6 @@ pub mod server; mod settings_view; pub mod static_files; pub mod server_config { - pub use fabro_types::Settings; pub use fabro_types::settings::server::*; } pub mod tls; diff --git a/lib/crates/fabro-server/tests/it/openapi_conformance.rs b/lib/crates/fabro-server/tests/it/openapi_conformance.rs index 2c41c72d2..da24d4b0b 100644 --- a/lib/crates/fabro-server/tests/it/openapi_conformance.rs +++ b/lib/crates/fabro-server/tests/it/openapi_conformance.rs @@ -1,4 +1,4 @@ -//! Conformance tests: spec ↔ router ↔ Rust struct consistency. +//! Conformance tests: spec ↔ router consistency. #![allow( clippy::absolute_paths, @@ -8,21 +8,11 @@ )] use super::helpers::test_app_state; -use std::collections::BTreeSet; use axum::body::Body; use axum::http::{Method, Request, StatusCode}; -use fabro_hooks::*; -use fabro_sandbox::daytona::*; use fabro_server::jwt_auth::AuthMode; use fabro_server::server::build_router; -use fabro_server::server_config::*; -use fabro_types::settings::run::*; -use fabro_types::settings::sandbox::SandboxSettings; -use fabro_types::settings::{ - ClientTlsSettings, ExecSettings, OutputFormat, PermissionLevel, ProjectSettings, - ServerSettings as UserServerSettings, -}; use tower::ServiceExt; fn load_spec() -> openapiv3::OpenAPI { @@ -105,332 +95,10 @@ async fn all_spec_routes_are_routable() { assert!(checked > 0, "No routes were checked — is the spec empty?"); } -// ── ServerConfig ↔ OpenAPI schema drift detection ────────────────────── - -/// Load the spec as serde_json::Value for schema introspection. -fn load_spec_json() -> serde_json::Value { - let spec_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .parent() - .unwrap() - .parent() - .unwrap() - .join("docs/api-reference/fabro-api.yaml"); - let text = std::fs::read_to_string(&spec_path).expect("read spec"); - serde_yaml::from_str(&text).expect("parse spec") -} - -/// Follow a `$ref` pointer, or return the value unchanged. -fn resolve_ref<'a>( - value: &'a serde_json::Value, - root: &'a serde_json::Value, -) -> &'a serde_json::Value { - match value.get("$ref").and_then(|v| v.as_str()) { - Some(ref_str) => { - let mut cur = root; - for seg in ref_str.trim_start_matches("#/").split('/') { - cur = &cur[seg]; - } - cur - } - None => value, - } -} - -/// Collect property names from an OpenAPI schema object. -fn spec_keys(schema: &serde_json::Value) -> BTreeSet { - schema - .get("properties") - .and_then(|p| p.as_object()) - .map(|m| m.keys().cloned().collect()) - .unwrap_or_default() -} - -/// Recursively compare serialized JSON keys against OpenAPI schema properties. -fn compare_schema( - path: &str, - json: &serde_json::Value, - schema: &serde_json::Value, - root: &serde_json::Value, - errors: &mut Vec, -) { - let obj = match json.as_object() { - Some(o) => o, - None => return, - }; - - // Skip pure-map schemas (additionalProperties without properties). - if schema.get("additionalProperties").is_some() && schema.get("properties").is_none() { - return; - } - - let json_keys: BTreeSet = obj.keys().cloned().collect(); - let schema_keys = spec_keys(schema); - - for key in json_keys.difference(&schema_keys) { - errors.push(format!( - "{path}.{key}: in Rust but missing from OpenAPI spec" - )); - } - for key in schema_keys.difference(&json_keys) { - errors.push(format!( - "{path}.{key}: in OpenAPI spec but missing from Rust" - )); - } - - let properties = match schema.get("properties").and_then(|p| p.as_object()) { - Some(p) => p, - None => return, - }; - - for key in json_keys.intersection(&schema_keys) { - let json_val = &obj[key]; - let prop_schema = resolve_ref(&properties[key], root); - - // Skip maps and union types. - if prop_schema.get("additionalProperties").is_some() || prop_schema.get("oneOf").is_some() { - continue; - } - - match json_val { - serde_json::Value::Object(_) => { - compare_schema( - &format!("{path}.{key}"), - json_val, - prop_schema, - root, - errors, - ); - } - serde_json::Value::Array(arr) => { - // Union keys across all array elements. - let union: BTreeSet = arr - .iter() - .filter_map(|e| e.as_object()) - .flat_map(|o| o.keys().cloned()) - .collect(); - if union.is_empty() { - continue; - } - let items = match prop_schema.get("items") { - Some(i) => resolve_ref(i, root), - None => continue, - }; - let synthetic = serde_json::Value::Object( - union - .into_iter() - .map(|k| (k, serde_json::Value::Null)) - .collect(), - ); - compare_schema(&format!("{path}.{key}[]"), &synthetic, items, root, errors); - } - _ => {} - } - } -} - -/// Build a Settings with every Option set to Some so all keys appear -/// in the serialized JSON. -fn fully_populated_server_config() -> Settings { - Settings { - version: Some(1), - goal: Some("default goal".into()), - goal_file: Some("/tmp/goal.txt".into()), - graph: Some("workflow.fabro".into()), - labels: std::collections::HashMap::from([("scope".into(), "server".into())]), - server: Some(UserServerSettings { - target: Some("https://server.example.com".into()), - tls: Some(ClientTlsSettings { - cert: "client-cert.pem".into(), - key: "client-key.pem".into(), - ca: "ca.pem".into(), - }), - }), - exec: Some(ExecSettings { - provider: Some("openai".into()), - model: Some("gpt-5.4".into()), - permissions: Some(PermissionLevel::ReadWrite), - output_format: Some(OutputFormat::Json), - }), - prevent_idle_sleep: Some(true), - verbose: Some(true), - upgrade_check: Some(false), - dry_run: Some(true), - auto_approve: Some(true), - no_retro: Some(true), - storage_dir: Some("/data".into()), - max_concurrent_runs: Some(10), - web: Some(WebSettings { - enabled: true, - url: "https://example.com".into(), - auth: AuthSettings { - provider: AuthProvider::Github, - allowed_usernames: vec!["user".into()], - }, - }), - api: Some(ApiSettings { - base_url: "https://api.example.com".into(), - authentication_strategies: vec![ApiAuthStrategy::Jwt], - tls: Some(TlsSettings { - cert: "c".into(), - key: "k".into(), - ca: "ca".into(), - }), - }), - git: Some(GitSettings { - provider: GitProvider::Github, - app_id: Some("123".into()), - client_id: Some("456".into()), - slug: Some("fabro".into()), - author: GitAuthorSettings { - name: Some("bot".into()), - email: Some("bot@x".into()), - }, - webhooks: Some(WebhookSettings { - strategy: WebhookStrategy::TailscaleFunnel, - }), - }), - features: Some(FeaturesSettings { - session_sandboxes: true, - retros: false, - }), - log: Some(LogSettings { - level: Some("debug".into()), - }), - work_dir: Some("/work".into()), - llm: Some(LlmSettings { - model: Some("m".into()), - provider: Some("p".into()), - fallbacks: Some(Default::default()), - }), - setup: Some(SetupSettings { - commands: vec!["echo hi".into()], - timeout_ms: Some(5000), - }), - sandbox: Some(SandboxSettings { - provider: Some("daytona".into()), - preserve: Some(true), - devcontainer: None, - local: None, - daytona: Some(DaytonaConfig { - auto_stop_interval: Some(60), - labels: Some(Default::default()), - snapshot: Some(DaytonaSnapshotConfig { - name: "snap".into(), - cpu: Some(2), - memory: Some(4), - disk: Some(10), - dockerfile: Some(DockerfileSource::Inline("FROM x".into())), - }), - network: Some(DaytonaNetwork::Block), - skip_clone: false, - }), - env: Some(Default::default()), - }), - vars: Some(Default::default()), - checkpoint: CheckpointSettings { - exclude_globs: vec!["**/node_modules/**".into()], - }, - pull_request: Some(PullRequestSettings { - enabled: true, - draft: false, - auto_merge: false, - merge_strategy: MergeStrategy::Squash, - }), - artifacts: Some(ArtifactsSettings { - include: vec!["test-results/**".into()], - }), - // One hook per HookType variant so the key union covers all fields. - hooks: vec![ - HookDefinition { - name: Some("cmd".into()), - event: HookEvent::RunStart, - command: Some("echo".into()), - hook_type: None, - matcher: Some("*".into()), - blocking: Some(true), - timeout_ms: Some(5000), - sandbox: Some(true), - }, - HookDefinition { - name: Some("http".into()), - event: HookEvent::RunStart, - command: None, - hook_type: Some(HookType::Http { - url: "http://x".into(), - headers: Some(Default::default()), - allowed_env_vars: vec!["X".into()], - tls: TlsMode::Verify, - }), - matcher: None, - blocking: None, - timeout_ms: None, - sandbox: None, - }, - HookDefinition { - name: Some("prompt".into()), - event: HookEvent::RunStart, - command: None, - hook_type: Some(HookType::Prompt { - prompt: "hi".into(), - model: Some("m".into()), - }), - matcher: None, - blocking: None, - timeout_ms: None, - sandbox: None, - }, - HookDefinition { - name: Some("agent".into()), - event: HookEvent::RunStart, - command: None, - hook_type: Some(HookType::Agent { - prompt: "hi".into(), - model: Some("m".into()), - max_tool_rounds: Some(5), - }), - matcher: None, - blocking: None, - timeout_ms: None, - sandbox: None, - }, - ], - mcp_servers: std::collections::HashMap::from([( - "test".into(), - fabro_types::settings::mcp::McpServerEntry { - transport: fabro_types::settings::mcp::McpTransport::Stdio { - command: vec!["echo".into()], - env: Default::default(), - }, - startup_timeout_secs: fabro_types::settings::mcp::default_startup_timeout_secs(), - tool_timeout_secs: fabro_types::settings::mcp::default_tool_timeout_secs(), - }, - )]), - github: Some(GitHubSettings { - permissions: std::collections::HashMap::from([("contents".into(), "read".into())]), - }), - fabro: Some(ProjectSettings { - root: "fabro".into(), - }), - ..Default::default() - } -} - -#[test] -fn server_settings_keys_match_openapi_spec() { - let settings = fully_populated_server_config(); - let json = serde_json::to_value(&settings).expect("serialize ServerSettings"); - let spec = load_spec_json(); - let schema = &spec["components"]["schemas"]["ServerSettings"]; - - let mut errors = Vec::new(); - compare_schema("ServerSettings", &json, schema, &spec, &mut errors); - - if !errors.is_empty() { - panic!( - "ServerSettings ↔ OpenAPI schema drift:\n {}", - errors.join("\n ") - ); - } -} +// Note: the earlier `server_settings_keys_match_openapi_spec` drift check +// was deleted in Stage 6.3b alongside the legacy flat `fabro_types::Settings` +// struct that it instantiated. The v2 `/api/v1/settings` and +// `/api/v1/runs/:id/settings` endpoints now return the freely-shaped +// `SettingsFile` tree which the OpenAPI spec declares as +// `type: object, additionalProperties: true`, so there is nothing to diff +// at the property-key level. diff --git a/lib/crates/fabro-store/src/run_state.rs b/lib/crates/fabro-store/src/run_state.rs index 6b6660342..c64ef0e20 100644 --- a/lib/crates/fabro-store/src/run_state.rs +++ b/lib/crates/fabro-store/src/run_state.rs @@ -599,9 +599,10 @@ mod tests { use super::{NodeState, RunProjection}; use crate::{EventEnvelope, EventPayload, StageId}; use fabro_types::run_event::{InterviewCompletedProps, InterviewOption, InterviewStartedProps}; + use fabro_types::settings::SettingsFile; use fabro_types::{ Checkpoint, EventBody, InterviewQuestionType, RunBlobId, RunControlAction, RunEvent, - Settings, fixtures, + fixtures, }; fn test_event(seq: u32, body: EventBody, node_id: Option<&str>) -> EventEnvelope { @@ -808,7 +809,7 @@ mod tests { "run_id": fixtures::RUN_1, "event": "run.created", "properties": { - "settings": Settings::default(), + "settings": SettingsFile::default(), "graph": { "name": "test", "nodes": {}, diff --git a/lib/crates/fabro-types/src/lib.rs b/lib/crates/fabro-types/src/lib.rs index 6749e5545..3f00da289 100644 --- a/lib/crates/fabro-types/src/lib.rs +++ b/lib/crates/fabro-types/src/lib.rs @@ -53,7 +53,7 @@ pub use run_event::{EventBody, RunEvent, RunNoticeLevel}; pub use run_id::RunId; pub use run_id::fixtures; pub use sandbox_record::SandboxRecord; -pub use settings::{ArtifactStorageBackend, ArtifactStorageSettings, Settings}; +pub use settings::{ArtifactStorageBackend, ArtifactStorageSettings}; pub use stage_id::StageId; pub use start::StartRecord; pub use status::{ diff --git a/lib/crates/fabro-types/src/run_event/mod.rs b/lib/crates/fabro-types/src/run_event/mod.rs index f4817e3ed..fce22ee3f 100644 --- a/lib/crates/fabro-types/src/run_event/mod.rs +++ b/lib/crates/fabro-types/src/run_event/mod.rs @@ -683,7 +683,8 @@ mod tests { use serde_json::json; - use crate::{Edge, Graph, Node, RunBlobId, Settings, fixtures}; + use crate::settings::SettingsFile; + use crate::{Edge, Graph, Node, RunBlobId, fixtures}; use super::*; @@ -729,7 +730,7 @@ mod tests { #[test] fn run_event_deserializes_adjacent_layout() { - let settings = Settings::default(); + let settings = SettingsFile::default(); let graph = Graph { name: "test".to_string(), nodes: HashMap::from([( @@ -774,7 +775,7 @@ mod tests { "run_id": fixtures::RUN_1, "event": "run.created", "properties": { - "settings": Settings::default(), + "settings": SettingsFile::default(), "graph": Graph::new("test"), "labels": {}, "run_dir": "/tmp/run", diff --git a/lib/crates/fabro-types/src/settings/mod.rs b/lib/crates/fabro-types/src/settings/mod.rs index b8438e881..5492f34be 100644 --- a/lib/crates/fabro-types/src/settings/mod.rs +++ b/lib/crates/fabro-types/src/settings/mod.rs @@ -1,27 +1,23 @@ -//! Legacy flat `Settings` shape plus the v2 namespaced schema. +//! v2 namespaced config schema plus transitional runtime shapes. //! //! The authoritative config schema lives in [`v2`] — it is the namespaced //! parse tree that `_version = 1` TOML files decode into. Value-language //! helpers, the merge matrix, and strict unknown-key validation all live //! there. //! -//! The flat [`Settings`] type and its submodules (`hook`, `mcp`, `project`, -//! `run`, `sandbox`, `server`, `user`) are the **runtime shapes** that -//! downstream crates (fabro-workflow, fabro-sandbox, fabro-mcp, -//! fabro-hooks) still consume at execution time. Stage 6.1 deleted the -//! `Settings` parse path; Stage 6.2 deleted the `bridge_to_old` -//! catch-all converter. Narrow v2→runtime helpers live in -//! [`v2::to_runtime`] and build these runtime shapes from specific v2 -//! subtrees on demand. +//! The submodules `hook`, `mcp`, `project`, `run`, `sandbox`, `server`, +//! and `user` still hold **runtime shapes** that downstream crates +//! (fabro-workflow, fabro-sandbox, fabro-mcp, fabro-hooks) consume at +//! execution time. Stage 6.1 deleted the flat `Settings` parse path; +//! Stage 6.2 deleted the `bridge_to_old` catch-all converter; Stage 6.3b +//! deleted the legacy flat `Settings` struct itself, its inherent +//! helpers, and its `Combine`-driven layering. Narrow v2→runtime helpers +//! live in [`v2::to_runtime`] and build these runtime shapes from +//! specific v2 subtrees on demand. //! -//! Stage 6.3 deletes these runtime types entirely in favor of v2-native -//! replacements, at which point this module and the helper modules -//! around it go away too. - -use std::collections::HashMap; -use std::path::PathBuf; - -use serde::{Deserialize, Serialize}; +//! A follow-up pass will either promote these runtime shapes into their +//! owning consumer crates or replace their call sites with v2-native +//! accessors, at which point this module goes away. pub mod hook; pub mod mcp; @@ -59,8 +55,8 @@ pub use user::{ClientTlsSettings, ExecSettings, OutputFormat, PermissionLevel, S // `fabro_types::settings::InterpString` / `fabro_types::settings::Duration` // without the `::v2::` prefix. The `v2` module itself stays until the // remaining legacy files under `settings/{project,run,server,...}.rs` -// are deleted in Stage 6.3, because the v2 submodules and the legacy -// submodules share those file names. +// are deleted in a follow-up pass, because the v2 submodules and the +// legacy submodules share those file names. pub use v2::{ CURRENT_VERSION, CliLayer, Duration, FeaturesLayer, InterpString, ModelRef, ParseDurationError, ParseError, ParseModelRefError, ParseSizeError, ProjectLayer, Provenance, ResolveEnvError, @@ -68,86 +64,3 @@ pub use v2::{ SpliceArray, SpliceArrayError, VersionError, WorkflowLayer, parse_settings_file, validate_version, }; - -fn is_default_checkpoint(c: &CheckpointSettings) -> bool { - c.exclude_globs.is_empty() -} - -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] -pub struct Settings { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub version: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub goal: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub goal_file: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub graph: Option, - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub labels: HashMap, - #[serde(default, alias = "directory", skip_serializing_if = "Option::is_none")] - pub work_dir: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub llm: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub setup: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub sandbox: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub vars: Option>, - #[serde(default, skip_serializing_if = "is_default_checkpoint")] - pub checkpoint: CheckpointSettings, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub pull_request: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub artifacts: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub hooks: Vec, - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub mcp_servers: HashMap, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub github: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub server: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub exec: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub prevent_idle_sleep: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub verbose: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub upgrade_check: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub dry_run: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub auto_approve: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub no_retro: Option, - #[serde(default, alias = "data_dir", skip_serializing_if = "Option::is_none")] - pub storage_dir: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub max_concurrent_runs: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub artifact_storage: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub web: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub slack: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub api: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub features: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub log: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub git: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub fabro: Option, -} - -// All inherent helpers on `Settings` are gone -- the v2 `SettingsFile` -// accessors in `settings::v2::accessors` are the single source of truth -// for reading merged configuration. The flat `Settings` struct itself -// lingers for the OpenAPI legacy `ServerSettings` response shape and a -// handful of demo-route payloads; Stage 6.6 finishes the deletion once -// the OpenAPI spec is rewritten to return v2 DTOs. diff --git a/lib/crates/fabro-workflow/tests/it/integration.rs b/lib/crates/fabro-workflow/tests/it/integration.rs index 383eeef3b..abf857568 100644 --- a/lib/crates/fabro-workflow/tests/it/integration.rs +++ b/lib/crates/fabro-workflow/tests/it/integration.rs @@ -28,7 +28,7 @@ use fabro_llm::provider::Provider; use fabro_store::{ArtifactStore, Database}; use fabro_types::settings::v2::SettingsFile; use fabro_types::settings::v2::run::{RunArtifactsLayer, RunLayer}; -use fabro_types::{RunEvent, RunId, Settings, StageId}; +use fabro_types::{RunEvent, RunId, StageId}; use fabro_validate::{Severity, validate, validate_or_raise}; use fabro_workflow::context::Context; use fabro_workflow::error::{FabroError, FailureSignatureExt}; @@ -8089,41 +8089,10 @@ async fn hook_config_merge_run_overrides_by_name() { assert_eq!(outcome.status, StageStatus::Success); } -// --- TOML config parsing integration --- - -#[test] -fn hook_toml_run_config_parsing() { - let toml = r#" -version = 1 -goal = "Test hooks in run config" -graph = "test.fabro" - -[[hooks]] -event = "stage_start" -command = "./scripts/pre-check.sh" -matcher = "agent_loop" -blocking = true -timeout_ms = 30000 -sandbox = false - -[[hooks]] -event = "run_complete" -command = "echo done" -"#; - - let cfg: Settings = toml::from_str(toml).unwrap(); - assert_eq!(cfg.hooks.len(), 2); - assert_eq!(cfg.hooks[0].event, fabro_hooks::HookEvent::StageStart); - assert_eq!(cfg.hooks[0].matcher.as_deref(), Some("agent_loop")); - assert!(cfg.hooks[0].is_blocking()); - assert!(!cfg.hooks[0].runs_in_sandbox()); - assert_eq!( - cfg.hooks[0].timeout(), - std::time::Duration::from_millis(30000) - ); - assert_eq!(cfg.hooks[1].event, fabro_hooks::HookEvent::RunComplete); - assert!(!cfg.hooks[1].is_blocking()); // RunComplete non-blocking by default -} +// The legacy `Settings`-based TOML parsing tests were deleted in Stage +// 6.3b. Hook TOML parsing now flows through the v2 `SettingsFile` path, +// with coverage in `fabro-types::settings::v2::tree::tests` and the +// fabro-cli integration tests under `cmd::config`. // --- Blocking vs non-blocking behavior --- @@ -8270,59 +8239,9 @@ async fn hook_sandbox_false_runs_on_host() { assert_eq!(std::fs::read_to_string(&marker).unwrap().trim(), "host"); } -// --- Prompt and Agent hook TOML parsing --- - -#[test] -fn hook_toml_prompt_and_agent_parsing() { - let toml = r#" -version = 1 -goal = "Test prompt/agent hooks" -graph = "test.fabro" - -[[hooks]] -event = "stage_start" -type = "prompt" -prompt = "Should this stage proceed?" -model = "haiku" - -[[hooks]] -event = "run_complete" -type = "agent" -prompt = "Verify all tests pass." -model = "sonnet" -max_tool_rounds = 10 -timeout_ms = 120000 -"#; - - let cfg: Settings = toml::from_str(toml).unwrap(); - assert_eq!(cfg.hooks.len(), 2); - - // Prompt hook - assert_eq!(cfg.hooks[0].event, fabro_hooks::HookEvent::StageStart); - assert!(matches!( - cfg.hooks[0].resolved_hook_type().as_deref(), - Some(fabro_hooks::HookType::Prompt { prompt, model }) - if prompt == "Should this stage proceed?" && *model == Some("haiku".into()) - )); - assert_eq!( - cfg.hooks[0].timeout(), - std::time::Duration::from_millis(30000) - ); - - // Agent hook - assert_eq!(cfg.hooks[1].event, fabro_hooks::HookEvent::RunComplete); - assert!(matches!( - cfg.hooks[1].resolved_hook_type().as_deref(), - Some(fabro_hooks::HookType::Agent { prompt, model, max_tool_rounds }) - if prompt == "Verify all tests pass." - && *model == Some("sonnet".into()) - && *max_tool_rounds == Some(10) - )); - assert_eq!( - cfg.hooks[1].timeout(), - std::time::Duration::from_millis(120000) - ); -} +// Prompt and Agent hook TOML parsing: the legacy `Settings`-based +// variant of this test was deleted in Stage 6.3b; v2 coverage lives in +// `fabro-types::settings::v2::tree::tests`. // --- Events emitted correctly alongside hooks --- From 2986c1055f8cb1712e3714893e6498ef0daf8ccf Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 9 Apr 2026 17:35:23 -0400 Subject: [PATCH 28/47] docs(plans): write stage 6.6 + 6.3b-partial handoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures what landed in this session: - Stage 6.6a/b/c: OpenAPI DTO collapse (commit 7c8448ece) - Stage 6.6d/e/f/i: Server handlers + CLI + demo migration (f5b9f82a2) - Stage 6.6h: fabro-web literal rewrite (65a9fd137) - Stage 6.3b first pass: delete fabro_types::Settings (4a40c73b7) Plus what still remains: - Stage 6.3b runtime type module cleanup (blocked on consumer migration) - Stage 6.5b directory flatten (blocked on 6.3b) - Stage 6.6g auth resolver rewrite - Stage 6.6j setup_register review - 5 of 12 scoped TODOs still open; 7 resolved Also records the consumer migration map — ~33 import sites across 8 crates that need individual per-crate migration. This is the bulk of the remaining Stage 6 work. Co-Authored-By: Claude Opus 4.6 (1M context) --- ...-04-09-settings-toml-redesign-handoff-3.md | 364 ++++++++++++++++++ 1 file changed, 364 insertions(+) create mode 100644 docs/plans/2026-04-09-settings-toml-redesign-handoff-3.md diff --git a/docs/plans/2026-04-09-settings-toml-redesign-handoff-3.md b/docs/plans/2026-04-09-settings-toml-redesign-handoff-3.md new file mode 100644 index 000000000..f277a9d55 --- /dev/null +++ b/docs/plans/2026-04-09-settings-toml-redesign-handoff-3.md @@ -0,0 +1,364 @@ +--- +date: 2026-04-09 +status: active +topic: settings-toml-redesign +predecessor: docs/plans/2026-04-09-settings-toml-redesign-handoff-2.md +--- + +# Settings TOML Redesign — Handoff 3 (post Stage 6.6 + 6.3b partial) + +## TL;DR + +Stage 6.6 (OpenAPI DTO rewrite + server handlers + CLI migration + +fabro-web literals + demo routes) landed cleanly on `main`, and Stage +6.3b's first pass — **deleting the legacy flat `fabro_types::Settings` +struct itself** — also landed. The legacy flat view is dead code +everywhere in production. + +What remains is the *runtime type module cleanup*: the 7 files under +`lib/crates/fabro-types/src/settings/{hook,mcp,project,run,sandbox, +server,user}.rs` are still alive and consumed by 8 downstream crates. +These modules are what blocks Stage 6.5b (flatten `settings/v2/*.rs` +up to `settings/*.rs`). The blockers are filename collisions and ~33 +import statements scattered across the workspace. + +3,758 workspace tests pass. `cargo fmt --check --all` and +`cargo clippy --workspace -- -D warnings` are clean. `bun run +typecheck`, `bun test`, and `bun run build` for `apps/fabro-web` are +green. + +Main work remaining: + +1. **Finish Stage 6.3b** — migrate the 8 consumer crates off the + runtime type modules, then delete those 7 files plus the + `Combine` trait + derive macro. +2. **Stage 6.5b** — trivial once 6.3b finishes: `git mv + lib/crates/fabro-types/src/settings/v2/*.rs + lib/crates/fabro-types/src/settings/` and sweep `::v2::` out of + the workspace. +3. **Stage 6.6g** — rewrite `fabro-server` auth resolver for v2 + (TODO-2 from handoff-2). +4. **Stage 6.6j** — review `setup_register` TOML writer in + `web_auth.rs` (TODO-4 from handoff-2). +5. Remaining scoped TODOs (TODO-5, 7, 8, 11, 12 from handoff-2). + +## Source documents + +Read these, in this order: + +1. **Requirements (authoritative)** — + [`docs/brainstorms/2026-04-08-settings-toml-redesign-requirements.md`](../brainstorms/2026-04-08-settings-toml-redesign-requirements.md). +2. **Original implementation plan** — + [`docs/plans/2026-04-08-settings-toml-redesign-implementation-plan.md`](./2026-04-08-settings-toml-redesign-implementation-plan.md). +3. **Stage 6 handoff (predecessor 1)** — + [`docs/plans/2026-04-09-settings-toml-redesign-handoff.md`](./2026-04-09-settings-toml-redesign-handoff.md). +4. **Stage 6 handoff 2 (immediate predecessor)** — + [`docs/plans/2026-04-09-settings-toml-redesign-handoff-2.md`](./2026-04-09-settings-toml-redesign-handoff-2.md). + Full per-stage file maps and scoped TODOs; most content still + applies. + +## Commit trail (landed on main in this session, most recent first) + +``` +4a40c73b7 refactor(settings): stage 6.3b delete legacy flat Settings struct +65a9fd137 refactor(fabro-web): stage 6.6 rewrite workflowData literal to v2 shape +f5b9f82a2 feat(settings): stage 6.6 wire server + CLI to v2 SettingsFile DTO +7c8448ece refactor(api): stage 6.6 collapse settings DTOs to freeform v2 shape +``` + +Net effect: about −3,500 / +500 lines across the four commits. + +## Stage-by-stage status + +### 6.6 — OpenAPI DTO rewrite + fabro-web ✅ **COMPLETE (for the in-scope parts)** + +**What landed** (`7c8448ece` + `f5b9f82a2` + `65a9fd137`): + +- `docs/api-reference/fabro-api.yaml`: + - Replaces `ServerSettings` with a `type: object, + additionalProperties: true` freeform schema pointing at the v2 + `SettingsFile` docs. + - Replaces `RunSettings` similarly. + - Deletes the 20+ orphaned supporting schemas that only those two + referenced (`LlmSettings`, `SandboxSettings`, `HookDefinition`, + `WebSettings`, `ApiSettings`, `TlsSettings`, `GitSettings`, + `AuthSettings`, `Features`, `LogSettings`, `CheckpointSettings`, + `PullRequestSettings`, `ArtifactsSettings`, `McpServerEntry`, + `GitHubSettings`, `DaytonaSettings`, `LocalSandboxSettings`, + `DaytonaSnapshotSettings`, `SetupSettings`, `GitAuthorSettings`, + `WebhookSettings`). +- Regenerates the Rust progenitor client — `RunSettings` and + `ServerSettings` are now `#[serde(transparent)]` newtype wrappers + over `serde_json::Map`. +- Regenerates the TypeScript Axios client — the orphan + `run-settings.ts`, `server-settings.ts`, and 30+ nested model files + are deleted; the API methods inline the freeform type + as `{ [key: string]: any; }`. +- `fabro-server/src/settings_view.rs` (**new module**, ~220 LOC + including tests): `redact_for_api(&SettingsFile) -> SettingsFile` + drops `server.listen.*`, `server.auth.api.jwt.{issuer,audience}`, + `server.auth.api.mtls.ca`, and + `server.auth.web.providers.github.client_secret`. 5 unit tests + cover each drop case plus a `preserves_run_cli_project_and_features` + smoke test. +- `fabro-server/src/server.rs::get_server_settings` — now calls + `settings_view::redact_for_api` before serializing. +- `fabro-server/src/server.rs::get_run_settings` — **new** real + handler (was previously `not_implemented`) that opens the run + reader, reads the persisted `RunRecord.settings`, redacts, and + emits JSON. The demo route still points at `demo::get_run_settings`, + which was also rewritten. +- `fabro-cli/src/server_client.rs::retrieve_server_settings` — now + returns `SettingsFile` directly (not the legacy `Settings`). The + body is decoded from the progenitor `types::ServerSettings` + transparent newtype via `serde_json::from_value::(...)`. +- `fabro-cli/src/commands/config/mod.rs::legacy_settings_to_v2` — + **deleted** (TODO-1 from handoff-2 resolved). `merged_config` + passes the v2 file straight into + `effective_settings::resolve_settings`. +- `fabro-cli/tests/it/cmd/config.rs` — rewrites + `server_settings_fixture` to build a v2 `SettingsFile` via + `ConfigLayer::parse` instead of the legacy flat TOML shape. +- `fabro-web` — defines local `type ServerSettings = + Record` and `type RunSettings = Record` aliases in `settings.tsx` / `workflow-api.ts` since the + generated client no longer exports named model types. The UI only + `JSON.stringify`s these payloads. The static `workflowData` + literal in `workflow-detail.tsx` is rewritten to v2 shape + (`_version`, `run.goal`, `run.inputs`, `run.model`, `run.sandbox`, + `run.prepare.steps`, with `"120s"` / `"8GB"` / `"10GB"` string + forms). +- `fabro-server/src/demo/mod.rs` — the two demo settings fixtures + (`runs::settings()` and `settings::server_settings()`) are + rewritten as `serde_json::json!(...)` literals in v2 shape + (TODO-10 from handoff-2 resolved). + +**Known remaining wire-contract concerns**: + +1. `openapi_conformance::server_settings_keys_match_openapi_spec` + was **deleted** in 6.3b because the new freeform-object schema + has no `properties` to diff against. `all_spec_routes_are_routable` + remains. +2. `bun run dev` / browser sanity check against a real running + server is still unverified — the new wire shape should work + because fabro-web only stringifies it, but this should be smoke- + tested before the next frontend release. + +### 6.6g — Rewrite auth resolver for v2 ⏳ **NOT STARTED** + +TODO-2 from handoff-2 still stands: + +**File**: `lib/crates/fabro-server/src/serve.rs:91` — the +`build_legacy_api_settings` stopgap builds a legacy +`fabro_types::settings::server::ApiSettings` from the v2 +`server.auth.api.{jwt,mtls}` + `server.listen.tls` subtrees so that +`resolve_auth_mode_with_lookup` in `jwt_auth.rs` still works. + +**Fix**: rewrite `resolve_auth_mode_with_lookup` to read +`SettingsFile` directly, delete `build_legacy_api_settings`, and +drop the `fabro_types::settings::server::{ApiSettings, +ApiAuthStrategy, TlsSettings}` imports from `serve.rs` / `jwt_auth.rs` +/ `tls.rs`. + +### 6.6j — setup_register review ⏳ **NOT STARTED** + +TODO-4 from handoff-2 still stands: + +**File**: `lib/crates/fabro-server/src/web_auth.rs:496-659`. The +`setup_register` function hand-rolls a v2 TOML document and writes +it to disk. It works but loses comments / formatting on round-trip. +Plus TODO-12: double-check and drop any dead `settings_file` local +binding after the `drop(settings)` write-and-reparse dance at +`web_auth.rs:557-570`. + +### 6.3b — Delete legacy flat `Settings` types ⚠️ **PARTIAL** + +**What landed in this session** (`4a40c73b7`): + +- `fabro_types::Settings` struct itself: **deleted** from + `lib/crates/fabro-types/src/settings/mod.rs`. All ~65 fields gone. +- `fabro_types::Settings` re-export from `fabro_types/src/lib.rs:56`: + **deleted**. +- `fabro_types::settings::Settings` usage in + `fabro-server/src/lib.rs::server_config` module: re-export + **deleted**. The `fabro_types::settings::server::*` pass-through + is still there because downstream code still imports from it. +- `fabro-server/src/demo/mod.rs` — the two demo settings literals + (runs::settings + settings::server_settings) were rewritten as + v2 `serde_json::json!` literals (6.6i, simultaneously). +- `fabro-server/tests/it/openapi_conformance.rs` — deleted the + `server_settings_keys_match_openapi_spec` test that built a + fully-populated legacy `Settings` to diff against the spec. Kept + `all_spec_routes_are_routable`. +- `fabro-store/src/run_state.rs` — test fixture switched from + `Settings::default()` to `SettingsFile::default()`. +- `fabro-types/src/run_event/mod.rs` — two `RunCreated` round-trip + tests switched from `Settings::default()` to + `SettingsFile::default()`. +- `fabro-workflow/tests/it/integration.rs` — the two + `hook_toml_*_parsing` tests that decoded top-level `[[hooks]]` into + a legacy `Settings` were **deleted**. Those test the legacy parse + path which had already been removed in Stage 6.1; the coverage + moves to `fabro-types::settings::v2::tree::tests`. + +**What did NOT land** (deferred to Stage 6.3c): + +The 7 runtime type modules under +`lib/crates/fabro-types/src/settings/` are still alive: + +- `hook.rs` — `HookDefinition`, `HookEvent`, `HookSettings`, + `HookType`, `TlsMode` +- `mcp.rs` — `McpServerEntry`, `McpServerSettings`, `McpTransport`, + `default_startup_timeout_secs`, `default_tool_timeout_secs` +- `project.rs` — `ProjectSettings` +- `run.rs` — `ArtifactsSettings`, `CheckpointSettings`, `GitHubSettings`, + `LlmSettings`, `MergeStrategy`, `PullRequestSettings`, `SetupSettings` +- `sandbox.rs` — `DaytonaNetwork`, `DaytonaSettings`, + `DaytonaSnapshotSettings`, `DockerfileSource`, `LocalSandboxSettings`, + `SandboxSettings`, `WorktreeMode` +- `server.rs` — `ApiAuthStrategy`, `ApiSettings`, + `ArtifactStorageBackend`, `ArtifactStorageSettings`, `AuthProvider`, + `AuthSettings`, `FeaturesSettings`, `GitAuthorSettings`, + `GitProvider`, `GitSettings`, `LogSettings`, `SlackSettings`, + `TlsSettings`, `WebSettings`, `WebhookSettings`, `WebhookStrategy` +- `user.rs` — `ClientTlsSettings`, `ExecSettings`, `OutputFormat`, + `PermissionLevel`, `ServerSettings` + +Plus `fabro-types/src/combine.rs` (the `Combine` trait) and the +`fabro-macros` `Combine` derive macro that only these modules use. + +These are blocked on migrating the 8 consumer crates that import +them. See "Consumer migration map" below. + +### 6.5b — Flatten `settings::v2::*` → `settings::*` ⏳ **STILL BLOCKED ON 6.3b** + +No change from handoff-2. When 6.3b finishes deleting the runtime +type modules, this becomes a trivial `git mv` + search-and-replace +pass. The file-name collisions to resolve are `project.rs`, `run.rs`, +`server.rs`, `cli.rs` — each exists in both `settings/` and +`settings/v2/`. + +## Consumer migration map (for finishing 6.3b) + +| Crate | Legacy types it still imports | Suggested destination | +|---|---|---| +| `fabro-agent` | `OutputFormat`, `PermissionLevel` from `settings::user` | Promote into `fabro-agent` itself — they're CLI/exec concerns. Or point at `settings::v2::cli::OutputFormat` / `v2::run::AgentPermissions` if shapes match. | +| `fabro-checkpoint` | `GitAuthorSettings` from `settings::server` | Promote into `fabro-checkpoint` or read directly from `v2::run::GitAuthorLayer` at the call site. | +| `fabro-hooks` | `HookDefinition`, `HookEvent`, `HookSettings`, `HookType`, `TlsMode` | Promote all of them into `fabro-hooks`. They are runtime behavior types (has `resolved_hook_type()` / `runs_in_sandbox()` methods), not parse-tree types, so they belong in the consumer crate. | +| `fabro-mcp` | `McpServerEntry`, `McpServerSettings`, `McpTransport`, `default_startup_timeout_secs`, `default_tool_timeout_secs` | Promote into `fabro-mcp`. Convert from v2 `run.agent.mcps.*` or `cli.exec.agent.mcps.*` at the call site. | +| `fabro-sandbox` | `SandboxSettings`, `DaytonaSettings`, `DaytonaSnapshotSettings`, `DaytonaNetwork`, `LocalSandboxSettings`, `WorktreeMode`, `DockerfileSource` | Already re-exported as `fabro_sandbox::daytona::*` with renames. Promote the source into `fabro-sandbox` directly and drop the re-export path. | +| `fabro-checkpoint` | `GitAuthorSettings` | Same as above. | +| `fabro-workflow` | `PullRequestSettings`, `MergeStrategy`, `WorktreeMode` | `MergeStrategy` and `WorktreeMode` have identical v2 equivalents in `v2::run` — point at them directly. `PullRequestSettings` should move into `fabro-workflow`. | +| `fabro-server` | `ApiSettings`, `TlsSettings`, `ApiAuthStrategy`, `GitSettings`, `ServerSettings` (as `UserServerSettings`), `GitHubSettings`, `WebSettings`, `AuthSettings`, `GitAuthorSettings`, `WebhookSettings`, `LogSettings`, `FeaturesSettings` | Part of Stage 6.6g — the auth resolver rewrite needs to walk `v2::server::auth` directly; likewise the TLS handling in `tls.rs`. Other types may just need to move into `fabro-server`. | +| `fabro-cli` | `ClientTlsSettings`, `OutputFormat`, `PermissionLevel`, `ExecSettings`, `ServerSettings` (as `UserServerSettings`) | Promote `ClientTlsSettings` / `ExecSettings` into `fabro-cli`. `OutputFormat` / `PermissionLevel` / `ServerSettings` are shared with `fabro-agent` — decide whether they belong in `fabro-agent` and re-export, or in a new shared crate. | + +**Total import sites to rewrite**: about 33 `use` statements and +roughly that many call-sites, across ~15 files in 8 crates. Each +individual migration is small; the aggregate is the bulk of the +remaining 6.3b work. + +### Combine trait + +After the consumer migration: + +1. `lib/crates/fabro-types/src/combine.rs` — delete. +2. `lib/crates/fabro-macros/src/lib.rs::Combine` derive — delete. +3. `fabro-macros` crate becomes empty or can go away entirely if + there are no other derives in it. + +## Scoped TODOs (handoff-2 status update) + +| TODO | Subject | Status | +|---|---|---| +| TODO-1 | `legacy_settings_to_v2` shim in fabro-cli | ✅ **Deleted** in `f5b9f82a2` | +| TODO-2 | `build_legacy_api_settings` in fabro-server | ⏳ Still open (6.6g) | +| TODO-3 | `get_server_settings` emits raw v2 JSON | ✅ **Fixed** in `f5b9f82a2`. Handler now calls `settings_view::redact_for_api` | +| TODO-4 | `web_auth.rs` register flow | ⏳ Still open (6.6j) | +| TODO-5 | `check_crypto` in diagnostics | ⏳ Opportunistic, unchanged | +| TODO-6 | Dead `Combine` trait | ⏳ Still blocked on consumer migration | +| TODO-7 | Fallback chain bug preserved | ⏳ Unchanged — waiting on model registry work | +| TODO-8 | V2 doesn't model `goal_file` | ⏳ Unchanged — requirements decision needed | +| TODO-9 | Server settings inherent methods gone | ✅ **Fixed** — Settings struct is deleted entirely in 6.3b | +| TODO-10 | Demo routes still emit legacy shape | ✅ **Fixed** in `4a40c73b7`. Demo fixtures rewritten as v2 JSON | +| TODO-11 | Unused `Settings` import in `config.rs` tests | ✅ **Fixed** in `f5b9f82a2`. Test file rewritten to use `SettingsFile` | +| TODO-12 | Unused `settings_file` binding in `web_auth.rs` | ⏳ Still open (rolls up into 6.6j) | + +## Running verification + +```bash +# Rust side — should stay green after every incremental commit +cargo fmt --check --all +cargo build --workspace +cargo clippy --workspace -- -D warnings +ulimit -n 4096 && cargo nextest run --workspace + +# Web side — should stay green when touching fabro-web +cd apps/fabro-web && bun run typecheck && bun test && bun run build + +# API spec conformance — single test remaining +cargo nextest run -p fabro-server --test it openapi_conformance +``` + +Expected as of `4a40c73b7`: 3,758 tests pass / 0 fail / 182 skipped. + +## Success criteria for finishing Stage 6 + +Updated from handoff-2: + +- [x] `git grep 'fabro_types::Settings\b'` returns zero hits outside + the comment in the conformance test. + **Done in `4a40c73b7`.** +- [x] `git grep 'bridge_to_old'` returns zero hits. +- [ ] `lib/crates/fabro-types/src/settings/v2/` no longer exists + as a subdirectory. **Blocked on finishing 6.3b.** +- [ ] `lib/crates/fabro-types/src/combine.rs` is deleted. + **Blocked on finishing 6.3b.** +- [x] `lib/crates/fabro-config/src/{hook,mcp,sandbox,server,run,user}.rs` + deleted or reduced to thin helpers. + **Done in Stage 6.4.** +- [x] `docs/api-reference/fabro-api.yaml` `ServerSettings` and + `RunSettings` schemas are not the legacy flat shape. + **Done in `7c8448ece`** (freeform objects pointing at the v2 + SettingsFile Rust type). +- [x] `lib/packages/fabro-api-client` and the Rust progenitor client + are regenerated. + **Done in `7c8448ece`.** +- [x] `apps/fabro-web/app/routes/workflow-detail.tsx` `workflowData` + literal matches the new shape. + **Done in `65a9fd137`.** +- [x] `cargo fmt` / `cargo build` / `cargo clippy -D warnings` / + `cargo nextest run --workspace` / `bun run typecheck` / + `bun test` / `bun run build` gates all green. + **Verified after each commit.** + +## Starting points for the next engineer + +1. **Read this doc and handoff-2 in full** — the consumer migration + map above is the bulk of the remaining work and rewards careful + per-crate thinking. +2. **Run the test suite locally** to confirm the starting state + (`ulimit -n 4096 && cargo nextest run --workspace`). Expected: + 3,758 passed / 0 failed / 182 skipped. +3. **Pick the smallest consumer first** (suggested order: + `fabro-checkpoint` → `fabro-agent` → `fabro-workflow` → + `fabro-hooks` → `fabro-mcp` → `fabro-sandbox` → `fabro-cli` → + `fabro-server`). For each: + a. Move the types into the consumer crate with `git mv` or hand + relocation. + b. Update the consumer's public API to own them. + c. Rewrite the consumer's `From<&SettingsFile>` / construction + path to build from v2 subtrees directly. + d. Delete the corresponding runtime type file in `fabro-types`. + e. Verify `cargo build --workspace`, `cargo clippy --workspace + -- -D warnings`, and the relevant nextest subset stay green + before moving to the next crate. +4. **After the last consumer migrates**, delete `Combine` (trait, + derive, crate file). +5. **Stage 6.5b** is a one-commit follow-up: `git mv v2/*.rs up`, + drop the `::v2::` paths, done. +6. **Stage 6.6g and 6.6j** are independent of the above and can be + sequenced whenever; 6.6g pairs naturally with the `fabro-server` + consumer migration because both touch `jwt_auth.rs` / `serve.rs` / + `tls.rs`. + +Good luck. From db45511ff55b26900e66520dbb49c78b9f503500 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 9 Apr 2026 17:59:49 -0400 Subject: [PATCH 29/47] refactor(settings): stage 6.3b promote user runtime types into consumers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First consumer migration pass. Deletes `lib/crates/fabro-types/src/settings/user.rs` outright: - `OutputFormat`, `PermissionLevel`: moved into `fabro-agent/src/cli.rs` where they are actually consumed as `AgentArgs` fields. They carry clap `ValueEnum` derives so `fabro-cli` keeps importing them via the `fabro_agent::cli::{OutputFormat, PermissionLevel}` public path. - `ClientTlsSettings`: moved into `fabro-cli/src/user_config.rs` as a crate-private struct. Only `fabro-cli` references it (via `cli_target_from_v2` when building the HTTP client). - `ExecSettings`, legacy `ServerSettings` (from `settings::user`): deleted outright — no callers remained. Also removes the now-dead `From<&GitAuthorSettings> for GitAuthor` impl in `fabro-checkpoint/src/author.rs`. The v2 `GitAuthorLayer` conversion is the only path `fabro-workflow::git::git_author_from_settings` uses. Drops the `fabro_types::settings::server::GitAuthorSettings` import along with it. `settings/mod.rs` drops the `pub mod user` declaration and the `pub use user::*` re-export line. One of the seven legacy runtime type modules is now gone; six remain. 3,758 workspace tests pass. `cargo fmt --check --all` and `cargo clippy --workspace -- -D warnings` are clean. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/crates/fabro-agent/src/cli.rs | 21 ++++++++++- lib/crates/fabro-checkpoint/src/author.rs | 9 +---- lib/crates/fabro-cli/src/user_config.rs | 12 +++++- lib/crates/fabro-types/src/settings/mod.rs | 3 -- lib/crates/fabro-types/src/settings/user.rs | 41 --------------------- 5 files changed, 31 insertions(+), 55 deletions(-) delete mode 100644 lib/crates/fabro-types/src/settings/user.rs diff --git a/lib/crates/fabro-agent/src/cli.rs b/lib/crates/fabro-agent/src/cli.rs index a7aa862c6..f06d4c9cb 100644 --- a/lib/crates/fabro-agent/src/cli.rs +++ b/lib/crates/fabro-agent/src/cli.rs @@ -68,7 +68,26 @@ struct Cli { args: AgentArgs, } -pub use fabro_types::settings::user::{OutputFormat, PermissionLevel}; +/// Output format for the `fabro exec` / agent CLI. +#[derive( + Clone, Copy, Debug, PartialEq, Eq, serde::Deserialize, serde::Serialize, clap::ValueEnum, +)] +#[serde(rename_all = "kebab-case")] +pub enum OutputFormat { + Text, + Json, +} + +/// Agent tool permission level. +#[derive( + Clone, Copy, Debug, PartialEq, Eq, serde::Deserialize, serde::Serialize, clap::ValueEnum, +)] +#[serde(rename_all = "kebab-case")] +pub enum PermissionLevel { + ReadOnly, + ReadWrite, + Full, +} impl AgentArgs { /// Fill `None` fields from settings.toml values, then hardcoded defaults. diff --git a/lib/crates/fabro-checkpoint/src/author.rs b/lib/crates/fabro-checkpoint/src/author.rs index c493275d2..e85062f6b 100644 --- a/lib/crates/fabro-checkpoint/src/author.rs +++ b/lib/crates/fabro-checkpoint/src/author.rs @@ -1,7 +1,6 @@ use std::fmt::Write; -use fabro_types::settings::server::GitAuthorSettings; -use fabro_types::settings::v2::InterpString; +use fabro_types::settings::InterpString; use fabro_types::settings::v2::run::GitAuthorLayer; /// Resolved git author identity for checkpoint commits. @@ -51,12 +50,6 @@ impl GitAuthor { } } -impl From<&GitAuthorSettings> for GitAuthor { - fn from(value: &GitAuthorSettings) -> Self { - Self::from_options(value.name.clone(), value.email.clone()) - } -} - impl From<&GitAuthorLayer> for GitAuthor { fn from(value: &GitAuthorLayer) -> Self { Self::from_options( diff --git a/lib/crates/fabro-cli/src/user_config.rs b/lib/crates/fabro-cli/src/user_config.rs index 7a9d6d483..248979560 100644 --- a/lib/crates/fabro-cli/src/user_config.rs +++ b/lib/crates/fabro-cli/src/user_config.rs @@ -1,14 +1,22 @@ use std::path::{Path, PathBuf}; pub(crate) use fabro_config::user::*; -pub(crate) use fabro_types::settings::user::ClientTlsSettings; use anyhow::{Result, bail}; use fabro_config::ConfigLayer; -use fabro_types::settings::v2::SettingsFile; +use fabro_types::settings::SettingsFile; use fabro_util::version::FABRO_VERSION; +use serde::{Deserialize, Serialize}; use tracing::debug; +/// Client-side TLS material for the CLI's remote server target. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub(crate) struct ClientTlsSettings { + pub cert: PathBuf, + pub key: PathBuf, + pub ca: PathBuf, +} + use crate::args::ServerTargetArgs; pub(crate) fn load_settings() -> anyhow::Result { diff --git a/lib/crates/fabro-types/src/settings/mod.rs b/lib/crates/fabro-types/src/settings/mod.rs index 5492f34be..a6830b050 100644 --- a/lib/crates/fabro-types/src/settings/mod.rs +++ b/lib/crates/fabro-types/src/settings/mod.rs @@ -25,7 +25,6 @@ pub mod project; pub mod run; pub mod sandbox; pub mod server; -pub mod user; pub mod v2; pub use hook::{HookDefinition, HookEvent, HookSettings, HookType, TlsMode}; @@ -47,8 +46,6 @@ pub use server::{ AuthSettings, FeaturesSettings, GitAuthorSettings, GitProvider, GitSettings, LogSettings, SlackSettings, TlsSettings, WebSettings, WebhookSettings, WebhookStrategy, }; -pub use user::{ClientTlsSettings, ExecSettings, OutputFormat, PermissionLevel, ServerSettings}; - // v2 top-level re-exports. Stage 6.5 of the settings TOML redesign // promoted the v2 namespaced parse tree to be the primary API surface; // consumers can now write `fabro_types::settings::SettingsFile` / diff --git a/lib/crates/fabro-types/src/settings/user.rs b/lib/crates/fabro-types/src/settings/user.rs deleted file mode 100644 index 1818e124a..000000000 --- a/lib/crates/fabro-types/src/settings/user.rs +++ /dev/null @@ -1,41 +0,0 @@ -use std::path::PathBuf; - -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Copy, Debug, PartialEq, Deserialize, Serialize, crate::Combine)] -#[cfg_attr(feature = "clap", derive(clap::ValueEnum))] -#[serde(rename_all = "kebab-case")] -pub enum OutputFormat { - Text, - Json, -} - -#[derive(Clone, Copy, Debug, PartialEq, Deserialize, Serialize, crate::Combine)] -#[cfg_attr(feature = "clap", derive(clap::ValueEnum))] -#[serde(rename_all = "kebab-case")] -pub enum PermissionLevel { - ReadOnly, - ReadWrite, - Full, -} - -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] -pub struct ClientTlsSettings { - pub cert: PathBuf, - pub key: PathBuf, - pub ca: PathBuf, -} - -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] -pub struct ServerSettings { - pub target: Option, - pub tls: Option, -} - -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] -pub struct ExecSettings { - pub provider: Option, - pub model: Option, - pub permissions: Option, - pub output_format: Option, -} From 2016c8e9488677435d576a7f9b783adc5aae510b Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 9 Apr 2026 18:07:04 -0400 Subject: [PATCH 30/47] refactor(settings): stage 6.3b promote hook + project runtime types Two more legacy runtime type modules deleted from `fabro-types`: **project.rs** (19 LOC): `ProjectSettings` was a trivial one-field struct with a `pub use` re-export in `fabro-config/src/project.rs`. Nothing else referenced it. Deleted outright; `fabro-config/src/project.rs` drops the re-export and fixes up a `v2::` import path. **hook.rs** (230 LOC): `HookDefinition`, `HookEvent`, `HookSettings`, `HookType`, `TlsMode` plus the `resolved_hook_type` / `is_blocking` / `timeout` / `runs_in_sandbox` / `effective_name` behavior methods are **moved** (not just re-exported) into `fabro-hooks/src/config.rs`. They're runtime shapes owned by the hook executor, so they belong in the consumer crate. `bridge_hook` (and its private `resolve_hook_type` / `bridge_hook_event` helpers) also moved from `fabro-types/src/settings/v2/to_runtime.rs` into `fabro-hooks/src/config.rs`, because the target type is now local to `fabro-hooks`. `fabro-workflow/src/operations/start.rs` now imports `bridge_hook` from `fabro_hooks::config::bridge_hook` instead of the v2 `to_runtime` module. `fabro-hooks/src/types.rs` re-export of `HookEvent` switches from the deleted `fabro_types::settings::hook` path to the new crate-local `crate::config::HookEvent`. `settings/mod.rs` drops `pub mod {hook, project}` and the corresponding `pub use` re-exports. Three of the seven legacy runtime type modules are now gone; four remain (mcp, run, sandbox, server). 3,758 workspace tests pass. `cargo fmt --check --all` and `cargo clippy --workspace -- -D warnings` are clean. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/crates/fabro-config/src/project.rs | 3 +- lib/crates/fabro-hooks/src/config.rs | 351 +++++++++++++++++- lib/crates/fabro-hooks/src/types.rs | 2 +- lib/crates/fabro-types/src/settings/hook.rs | 229 ------------ lib/crates/fabro-types/src/settings/mod.rs | 4 - .../fabro-types/src/settings/project.rs | 19 - .../fabro-types/src/settings/v2/to_runtime.rs | 128 +------ .../fabro-workflow/src/operations/start.rs | 3 +- 8 files changed, 363 insertions(+), 376 deletions(-) delete mode 100644 lib/crates/fabro-types/src/settings/hook.rs delete mode 100644 lib/crates/fabro-types/src/settings/project.rs diff --git a/lib/crates/fabro-config/src/project.rs b/lib/crates/fabro-config/src/project.rs index 677902ede..eb7e569a6 100644 --- a/lib/crates/fabro-config/src/project.rs +++ b/lib/crates/fabro-config/src/project.rs @@ -12,8 +12,7 @@ use serde::Serialize; use crate::config::ConfigLayer; use crate::run; -pub use fabro_types::settings::project::ProjectSettings; -use fabro_types::settings::v2::{InterpString, SettingsFile}; +use fabro_types::settings::{InterpString, SettingsFile}; const CONFIG_FILENAME: &str = "fabro.toml"; const RUN_GRAPH_FILE: &str = "workflow.fabro"; diff --git a/lib/crates/fabro-hooks/src/config.rs b/lib/crates/fabro-hooks/src/config.rs index 8d055984f..09ee98960 100644 --- a/lib/crates/fabro-hooks/src/config.rs +++ b/lib/crates/fabro-hooks/src/config.rs @@ -1 +1,350 @@ -pub use fabro_types::settings::hook::{HookDefinition, HookEvent, HookSettings, HookType, TlsMode}; +//! Hook configuration runtime types. +//! +//! These types are the runtime shape that the hook executor consumes. The +//! v2 parse tree under `fabro_types::settings::v2::run::HookEntry` is the +//! *config-file* shape; this module lives in `fabro-hooks` because the +//! behavior methods (`is_blocking`, `timeout`, `resolved_hook_type`, +//! `runs_in_sandbox`, `effective_name`) are runtime concerns owned by the +//! executor. +//! +//! [`bridge_hook`] converts a v2 `HookEntry` into the runtime +//! [`HookDefinition`] and lives here (not in `fabro-types`) so the runtime +//! shape stays owned by this crate. + +use std::borrow::Cow; + +use fabro_types::settings::v2::InterpString; +use fabro_types::settings::v2::run::{ + HookAgentMarker, HookEntry, HookEvent as V2HookEvent, HookTlsMode as V2HookTlsMode, +}; +use serde::{Deserialize, Serialize}; + +/// Lifecycle events that can trigger user-defined hooks. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum HookEvent { + RunStart, + RunComplete, + RunFailed, + StageStart, + StageComplete, + StageFailed, + StageRetrying, + EdgeSelected, + ParallelStart, + ParallelComplete, + /// Reserved: hooks for this event are not yet invoked by the engine. + SandboxReady, + /// Reserved: hooks for this event are not yet invoked by the engine. + SandboxCleanup, + CheckpointSaved, + PreToolUse, + PostToolUse, + PostToolUseFailure, +} + +impl HookEvent { + /// Whether hooks for this event block execution by default. + #[must_use] + pub fn is_blocking_by_default(self) -> bool { + matches!( + self, + Self::RunStart + | Self::StageStart + | Self::EdgeSelected + | Self::PreToolUse + | Self::SandboxReady + ) + } +} + +impl std::fmt::Display for HookEvent { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::RunStart => "run_start", + Self::RunComplete => "run_complete", + Self::RunFailed => "run_failed", + Self::StageStart => "stage_start", + Self::StageComplete => "stage_complete", + Self::StageFailed => "stage_failed", + Self::StageRetrying => "stage_retrying", + Self::EdgeSelected => "edge_selected", + Self::ParallelStart => "parallel_start", + Self::ParallelComplete => "parallel_complete", + Self::SandboxReady => "sandbox_ready", + Self::SandboxCleanup => "sandbox_cleanup", + Self::CheckpointSaved => "checkpoint_saved", + Self::PreToolUse => "pre_tool_use", + Self::PostToolUse => "post_tool_use", + Self::PostToolUseFailure => "post_tool_use_failure", + }) + } +} + +/// TLS verification mode for HTTP hooks. +#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, Default, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TlsMode { + /// Require `https://` and verify certificates (default). + #[default] + Verify, + /// Require `https://` but skip certificate verification. + NoVerify, + /// Allow `http://`; skip certificate verification for `https://`. + Off, +} + +/// How a hook is executed. +#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum HookType { + Command { + command: String, + }, + Http { + url: String, + headers: Option>, + #[serde(default)] + allowed_env_vars: Vec, + #[serde(default)] + tls: TlsMode, + }, + Prompt { + prompt: String, + model: Option, + }, + Agent { + prompt: String, + model: Option, + max_tool_rounds: Option, + }, +} + +/// A single hook definition. +#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)] +pub struct HookDefinition { + pub name: Option, + pub event: HookEvent, + /// Inline command shorthand — if set, implies `type = "command"`. + #[serde(default)] + pub command: Option, + /// Explicit hook type (command or http). If omitted and `command` is set, + /// defaults to `Command`. + #[serde(flatten)] + pub hook_type: Option, + /// Regex matched against node_id, handler_type, or event-specific fields. + pub matcher: Option, + /// Override the event's default blocking behavior. + pub blocking: Option, + /// Timeout in milliseconds (default: 60_000). + pub timeout_ms: Option, + /// Run inside the sandbox (true, default) or on the host (false). + pub sandbox: Option, +} + +impl HookDefinition { + /// Resolve the effective hook type: explicit `hook_type` wins, then `command` + /// shorthand, then error. + pub fn resolved_hook_type(&self) -> Option> { + if let Some(ref ht) = self.hook_type { + return Some(Cow::Borrowed(ht)); + } + self.command.as_ref().map(|cmd| { + Cow::Owned(HookType::Command { + command: cmd.clone(), + }) + }) + } + + /// Whether this hook is blocking for its event. + #[must_use] + pub fn is_blocking(&self) -> bool { + self.blocking + .unwrap_or_else(|| self.event.is_blocking_by_default()) + } + + /// Timeout duration for this hook. + /// + /// Defaults: 30s for prompt hooks, 60s for all others. + #[must_use] + pub fn timeout(&self) -> std::time::Duration { + if let Some(ms) = self.timeout_ms { + return std::time::Duration::from_millis(ms); + } + let default_ms = match self.resolved_hook_type().as_deref() { + Some(HookType::Prompt { .. }) => 30_000, + _ => 60_000, + }; + std::time::Duration::from_millis(default_ms) + } + + /// Whether this hook runs in the sandbox. + #[must_use] + pub fn runs_in_sandbox(&self) -> bool { + self.sandbox.unwrap_or(true) + } + + /// The effective name: explicit name or a generated one. + #[must_use] + pub fn effective_name(&self) -> String { + if let Some(ref n) = self.name { + return n.clone(); + } + let event_str = self.event.to_string(); + match self.resolved_hook_type().as_deref() { + Some(HookType::Command { ref command }) => { + let short = &command[..command.floor_char_boundary(20)]; + format!("{event_str}:{short}") + } + Some(HookType::Http { ref url, .. }) => format!("{event_str}:{url}"), + Some(HookType::Prompt { ref prompt, .. } | HookType::Agent { ref prompt, .. }) => { + let short = &prompt[..prompt.floor_char_boundary(20)]; + format!("{event_str}:{short}") + } + None => event_str, + } + } +} + +/// Top-level hook configuration: a list of hook definitions. +#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize)] +pub struct HookSettings { + #[serde(default)] + pub hooks: Vec, +} + +impl HookSettings { + /// Merge with another config. Concatenates lists; on name collisions, `other` wins. + #[must_use] + pub fn merge(self, other: Self) -> Self { + let mut by_name: std::collections::HashMap = + std::collections::HashMap::new(); + let mut order: Vec = Vec::new(); + + for hook in self.hooks { + let name = hook.effective_name(); + if !by_name.contains_key(&name) { + order.push(name.clone()); + } + by_name.insert(name, hook); + } + for hook in other.hooks { + let name = hook.effective_name(); + if !by_name.contains_key(&name) { + order.push(name.clone()); + } + by_name.insert(name, hook); + } + + let hooks = order + .into_iter() + .filter_map(|name| by_name.remove(&name)) + .collect(); + + Self { hooks } + } +} + +/// Convert a v2 [`HookEntry`] into the runtime [`HookDefinition`] shape +/// this crate's executor consumes. +#[must_use] +pub fn bridge_hook(hook: &HookEntry) -> HookDefinition { + let hook_type = resolve_hook_type(hook); + // If the hook is a script/command form, emit via the shorthand so + // HookDefinition.command holds the full command and + // HookDefinition.hook_type stays None. This avoids the duplicate + // `command` key that would otherwise appear under `#[serde(flatten)]`. + let command = if let Some(script) = &hook.script { + Some(interp_to_string(script)) + } else { + hook.command.as_ref().map(|command| { + command + .iter() + .map(interp_to_string) + .collect::>() + .join(" ") + }) + }; + HookDefinition { + name: hook.name.clone().or_else(|| hook.id.clone()), + event: bridge_hook_event(hook.event), + command, + hook_type, + matcher: hook.matcher.clone(), + blocking: hook.blocking, + timeout_ms: hook + .timeout + .map(|d| u64::try_from(d.as_std().as_millis()).unwrap_or(u64::MAX)), + sandbox: hook.sandbox, + } +} + +fn resolve_hook_type(hook: &HookEntry) -> Option { + if hook.script.is_some() || hook.command.is_some() { + return None; + } + if let Some(url) = &hook.url { + let headers = if hook.headers.is_empty() { + None + } else { + Some( + hook.headers + .iter() + .map(|(k, v)| (k.clone(), interp_to_string(v))) + .collect(), + ) + }; + let tls = match hook.tls { + Some(V2HookTlsMode::Verify) => TlsMode::Verify, + Some(V2HookTlsMode::NoVerify) => TlsMode::NoVerify, + Some(V2HookTlsMode::Off) => TlsMode::Off, + None => TlsMode::default(), + }; + return Some(HookType::Http { + url: interp_to_string(url), + headers, + allowed_env_vars: hook.allowed_env_vars.clone(), + tls, + }); + } + if matches!(hook.agent, Some(HookAgentMarker::Enabled)) { + return Some(HookType::Agent { + prompt: hook + .prompt + .as_ref() + .map(interp_to_string) + .unwrap_or_default(), + model: hook.model.as_ref().map(interp_to_string), + max_tool_rounds: hook.max_tool_rounds, + }); + } + hook.prompt.as_ref().map(|prompt| HookType::Prompt { + prompt: interp_to_string(prompt), + model: hook.model.as_ref().map(interp_to_string), + }) +} + +fn bridge_hook_event(event: V2HookEvent) -> HookEvent { + match event { + V2HookEvent::RunStart => HookEvent::RunStart, + V2HookEvent::RunComplete => HookEvent::RunComplete, + V2HookEvent::RunFailed => HookEvent::RunFailed, + V2HookEvent::StageStart => HookEvent::StageStart, + V2HookEvent::StageComplete => HookEvent::StageComplete, + V2HookEvent::StageFailed => HookEvent::StageFailed, + V2HookEvent::StageRetrying => HookEvent::StageRetrying, + V2HookEvent::EdgeSelected => HookEvent::EdgeSelected, + V2HookEvent::ParallelStart => HookEvent::ParallelStart, + V2HookEvent::ParallelComplete => HookEvent::ParallelComplete, + V2HookEvent::SandboxReady => HookEvent::SandboxReady, + V2HookEvent::SandboxCleanup => HookEvent::SandboxCleanup, + V2HookEvent::CheckpointSaved => HookEvent::CheckpointSaved, + V2HookEvent::PreToolUse => HookEvent::PreToolUse, + V2HookEvent::PostToolUse => HookEvent::PostToolUse, + V2HookEvent::PostToolUseFailure => HookEvent::PostToolUseFailure, + } +} + +fn interp_to_string(value: &InterpString) -> String { + value.as_source() +} diff --git a/lib/crates/fabro-hooks/src/types.rs b/lib/crates/fabro-hooks/src/types.rs index ca7eeaa30..65ae5001b 100644 --- a/lib/crates/fabro-hooks/src/types.rs +++ b/lib/crates/fabro-hooks/src/types.rs @@ -1,4 +1,4 @@ -pub use fabro_types::settings::hook::HookEvent; +pub use crate::config::HookEvent; use fabro_types::RunId; use serde::{Deserialize, Serialize}; diff --git a/lib/crates/fabro-types/src/settings/hook.rs b/lib/crates/fabro-types/src/settings/hook.rs deleted file mode 100644 index 69309b9c5..000000000 --- a/lib/crates/fabro-types/src/settings/hook.rs +++ /dev/null @@ -1,229 +0,0 @@ -use std::borrow::Cow; - -use serde::{Deserialize, Serialize}; - -/// Lifecycle events that can trigger user-defined hooks. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum HookEvent { - RunStart, - RunComplete, - RunFailed, - StageStart, - StageComplete, - StageFailed, - StageRetrying, - EdgeSelected, - ParallelStart, - ParallelComplete, - /// Reserved: hooks for this event are not yet invoked by the engine. - SandboxReady, - /// Reserved: hooks for this event are not yet invoked by the engine. - SandboxCleanup, - CheckpointSaved, - PreToolUse, - PostToolUse, - PostToolUseFailure, -} - -impl HookEvent { - /// Whether hooks for this event block execution by default. - #[must_use] - pub fn is_blocking_by_default(self) -> bool { - matches!( - self, - Self::RunStart - | Self::StageStart - | Self::EdgeSelected - | Self::PreToolUse - | Self::SandboxReady - ) - } -} - -impl std::fmt::Display for HookEvent { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(match self { - Self::RunStart => "run_start", - Self::RunComplete => "run_complete", - Self::RunFailed => "run_failed", - Self::StageStart => "stage_start", - Self::StageComplete => "stage_complete", - Self::StageFailed => "stage_failed", - Self::StageRetrying => "stage_retrying", - Self::EdgeSelected => "edge_selected", - Self::ParallelStart => "parallel_start", - Self::ParallelComplete => "parallel_complete", - Self::SandboxReady => "sandbox_ready", - Self::SandboxCleanup => "sandbox_cleanup", - Self::CheckpointSaved => "checkpoint_saved", - Self::PreToolUse => "pre_tool_use", - Self::PostToolUse => "post_tool_use", - Self::PostToolUseFailure => "post_tool_use_failure", - }) - } -} - -/// TLS verification mode for HTTP hooks. -#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, Default, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum TlsMode { - /// Require `https://` and verify certificates (default). - #[default] - Verify, - /// Require `https://` but skip certificate verification. - NoVerify, - /// Allow `http://`; skip certificate verification for `https://`. - Off, -} - -/// How a hook is executed. -#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum HookType { - Command { - command: String, - }, - Http { - url: String, - headers: Option>, - #[serde(default)] - allowed_env_vars: Vec, - #[serde(default)] - tls: TlsMode, - }, - Prompt { - prompt: String, - model: Option, - }, - Agent { - prompt: String, - model: Option, - max_tool_rounds: Option, - }, -} - -/// A single hook definition. -#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)] -pub struct HookDefinition { - pub name: Option, - pub event: HookEvent, - /// Inline command shorthand — if set, implies `type = "command"`. - #[serde(default)] - pub command: Option, - /// Explicit hook type (command or http). If omitted and `command` is set, - /// defaults to `Command`. - #[serde(flatten)] - pub hook_type: Option, - /// Regex matched against node_id, handler_type, or event-specific fields. - pub matcher: Option, - /// Override the event's default blocking behavior. - pub blocking: Option, - /// Timeout in milliseconds (default: 60_000). - pub timeout_ms: Option, - /// Run inside the sandbox (true, default) or on the host (false). - pub sandbox: Option, -} - -impl HookDefinition { - /// Resolve the effective hook type: explicit `hook_type` wins, then `command` - /// shorthand, then error. - pub fn resolved_hook_type(&self) -> Option> { - if let Some(ref ht) = self.hook_type { - return Some(Cow::Borrowed(ht)); - } - self.command.as_ref().map(|cmd| { - Cow::Owned(HookType::Command { - command: cmd.clone(), - }) - }) - } - - /// Whether this hook is blocking for its event. - #[must_use] - pub fn is_blocking(&self) -> bool { - self.blocking - .unwrap_or_else(|| self.event.is_blocking_by_default()) - } - - /// Timeout duration for this hook. - /// - /// Defaults: 30s for prompt hooks, 60s for all others. - #[must_use] - pub fn timeout(&self) -> std::time::Duration { - if let Some(ms) = self.timeout_ms { - return std::time::Duration::from_millis(ms); - } - let default_ms = match self.resolved_hook_type().as_deref() { - Some(HookType::Prompt { .. }) => 30_000, - _ => 60_000, - }; - std::time::Duration::from_millis(default_ms) - } - - /// Whether this hook runs in the sandbox. - #[must_use] - pub fn runs_in_sandbox(&self) -> bool { - self.sandbox.unwrap_or(true) - } - - /// The effective name: explicit name or a generated one. - #[must_use] - pub fn effective_name(&self) -> String { - if let Some(ref n) = self.name { - return n.clone(); - } - let event_str = self.event.to_string(); - match self.resolved_hook_type().as_deref() { - Some(HookType::Command { ref command }) => { - let short = &command[..command.floor_char_boundary(20)]; - format!("{event_str}:{short}") - } - Some(HookType::Http { ref url, .. }) => format!("{event_str}:{url}"), - Some(HookType::Prompt { ref prompt, .. } | HookType::Agent { ref prompt, .. }) => { - let short = &prompt[..prompt.floor_char_boundary(20)]; - format!("{event_str}:{short}") - } - None => event_str, - } - } -} - -/// Top-level hook configuration: a list of hook definitions. -#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize)] -pub struct HookSettings { - #[serde(default)] - pub hooks: Vec, -} - -impl HookSettings { - /// Merge with another config. Concatenates lists; on name collisions, `other` wins. - #[must_use] - pub fn merge(self, other: Self) -> Self { - let mut by_name: std::collections::HashMap = - std::collections::HashMap::new(); - let mut order: Vec = Vec::new(); - - for hook in self.hooks { - let name = hook.effective_name(); - if !by_name.contains_key(&name) { - order.push(name.clone()); - } - by_name.insert(name, hook); - } - for hook in other.hooks { - let name = hook.effective_name(); - if !by_name.contains_key(&name) { - order.push(name.clone()); - } - by_name.insert(name, hook); - } - - let hooks = order - .into_iter() - .filter_map(|name| by_name.remove(&name)) - .collect(); - - Self { hooks } - } -} diff --git a/lib/crates/fabro-types/src/settings/mod.rs b/lib/crates/fabro-types/src/settings/mod.rs index a6830b050..721371d01 100644 --- a/lib/crates/fabro-types/src/settings/mod.rs +++ b/lib/crates/fabro-types/src/settings/mod.rs @@ -19,20 +19,16 @@ //! owning consumer crates or replace their call sites with v2-native //! accessors, at which point this module goes away. -pub mod hook; pub mod mcp; -pub mod project; pub mod run; pub mod sandbox; pub mod server; pub mod v2; -pub use hook::{HookDefinition, HookEvent, HookSettings, HookType, TlsMode}; pub use mcp::{ McpServerEntry, McpServerSettings, McpTransport, default_startup_timeout_secs, default_tool_timeout_secs, }; -pub use project::ProjectSettings; pub use run::{ ArtifactsSettings, CheckpointSettings, GitHubSettings, LlmSettings, MergeStrategy, PullRequestSettings, SetupSettings, diff --git a/lib/crates/fabro-types/src/settings/project.rs b/lib/crates/fabro-types/src/settings/project.rs deleted file mode 100644 index 9f79df5e8..000000000 --- a/lib/crates/fabro-types/src/settings/project.rs +++ /dev/null @@ -1,19 +0,0 @@ -use serde::{Deserialize, Serialize}; - -fn default_root() -> String { - ".".to_string() -} - -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] -pub struct ProjectSettings { - #[serde(default = "default_root")] - pub root: String, -} - -impl Default for ProjectSettings { - fn default() -> Self { - Self { - root: default_root(), - } - } -} diff --git a/lib/crates/fabro-types/src/settings/v2/to_runtime.rs b/lib/crates/fabro-types/src/settings/v2/to_runtime.rs index 5ba403cba..c3e3705f5 100644 --- a/lib/crates/fabro-types/src/settings/v2/to_runtime.rs +++ b/lib/crates/fabro-types/src/settings/v2/to_runtime.rs @@ -1,28 +1,20 @@ //! v2 → runtime-type conversion helpers. //! -//! The runtime types in `fabro_types::settings::{hook,mcp,run,sandbox}` are -//! the shapes that downstream crates (fabro-workflow, fabro-mcp, -//! fabro-sandbox, fabro-hooks) still consume at runtime. Each helper here -//! reads the v2 parse tree and builds the equivalent runtime value. +//! The runtime types in `fabro_types::settings::{mcp,run,sandbox}` are the +//! shapes that downstream crates (fabro-workflow, fabro-mcp, fabro-sandbox) +//! still consume at runtime. Each helper here reads the v2 parse tree and +//! builds the equivalent runtime value. //! -//! These helpers replace the deleted `bridge_to_old` seam from Stage 6.2. -//! They are narrower: each builds a single runtime type from a single v2 -//! subtree, rather than assembling a full legacy [`Settings`] struct. -//! -//! Stage 6.3 deletes the legacy runtime types themselves. At that point -//! these helpers either disappear or get rewritten against the v2-native -//! replacements. +//! Hook bridging has moved to `fabro_hooks::config::bridge_hook`, which +//! owns its runtime shape. Consumer crates will pull the rest of these +//! helpers into their own crates in follow-up 6.3b passes. use std::collections::HashMap; use super::interp::InterpString; use super::run::{ - HookEntry as V2HookEntry, HookEvent as V2HookEvent, McpEntryLayer, - MergeStrategy as V2MergeStrategy, RunArtifactsLayer, RunPullRequestLayer, RunSandboxLayer, - WorktreeMode as V2WorktreeMode, -}; -use crate::settings::hook::{ - HookDefinition, HookEvent as OldHookEvent, HookType as OldHookType, TlsMode as OldTlsMode, + McpEntryLayer, MergeStrategy as V2MergeStrategy, RunArtifactsLayer, RunPullRequestLayer, + RunSandboxLayer, WorktreeMode as V2WorktreeMode, }; use crate::settings::mcp::{McpServerEntry, McpTransport}; use crate::settings::run::{ @@ -213,108 +205,6 @@ pub fn bridge_mcp_entry(entry: &McpEntryLayer) -> McpServerEntry { } } -pub fn bridge_hook(hook: &V2HookEntry) -> HookDefinition { - let hook_type = resolve_hook_type(hook); - // If the hook is a script/command form, emit via the shorthand so the - // old HookDefinition.command field holds the full command and - // HookDefinition.hook_type stays None. This avoids the duplicate - // `command` key that would otherwise appear under `#[serde(flatten)]`. - let command = if let Some(script) = &hook.script { - Some(interp_to_string(script)) - } else { - hook.command.as_ref().map(|command| { - command - .iter() - .map(interp_to_string) - .collect::>() - .join(" ") - }) - }; - HookDefinition { - name: hook.name.clone().or_else(|| hook.id.clone()), - event: bridge_hook_event(hook.event), - command, - hook_type, - matcher: hook.matcher.clone(), - blocking: hook.blocking, - timeout_ms: hook - .timeout - .map(|d| u64::try_from(d.as_std().as_millis()).unwrap_or(u64::MAX)), - sandbox: hook.sandbox, - } -} - -fn resolve_hook_type(hook: &V2HookEntry) -> Option { - // Script/command-shorthand hooks are emitted via the top-level - // HookDefinition.command field in bridge_hook, not here, to avoid - // the `#[serde(flatten)]` duplicate-field collision between the - // outer HookDefinition.command shorthand and the inner - // HookType::Command.command in the legacy old Settings shape. - if hook.script.is_some() || hook.command.is_some() { - return None; - } - if let Some(url) = &hook.url { - let headers = if hook.headers.is_empty() { - None - } else { - Some( - hook.headers - .iter() - .map(|(k, v)| (k.clone(), interp_to_string(v))) - .collect(), - ) - }; - let tls = match hook.tls { - Some(super::run::HookTlsMode::Verify) => OldTlsMode::Verify, - Some(super::run::HookTlsMode::NoVerify) => OldTlsMode::NoVerify, - Some(super::run::HookTlsMode::Off) => OldTlsMode::Off, - None => OldTlsMode::default(), - }; - return Some(OldHookType::Http { - url: interp_to_string(url), - headers, - allowed_env_vars: hook.allowed_env_vars.clone(), - tls, - }); - } - if hook.agent.is_some() { - return Some(OldHookType::Agent { - prompt: hook - .prompt - .as_ref() - .map(interp_to_string) - .unwrap_or_default(), - model: hook.model.as_ref().map(interp_to_string), - max_tool_rounds: hook.max_tool_rounds, - }); - } - hook.prompt.as_ref().map(|prompt| OldHookType::Prompt { - prompt: interp_to_string(prompt), - model: hook.model.as_ref().map(interp_to_string), - }) -} - -fn bridge_hook_event(event: V2HookEvent) -> OldHookEvent { - match event { - V2HookEvent::RunStart => OldHookEvent::RunStart, - V2HookEvent::RunComplete => OldHookEvent::RunComplete, - V2HookEvent::RunFailed => OldHookEvent::RunFailed, - V2HookEvent::StageStart => OldHookEvent::StageStart, - V2HookEvent::StageComplete => OldHookEvent::StageComplete, - V2HookEvent::StageFailed => OldHookEvent::StageFailed, - V2HookEvent::StageRetrying => OldHookEvent::StageRetrying, - V2HookEvent::EdgeSelected => OldHookEvent::EdgeSelected, - V2HookEvent::ParallelStart => OldHookEvent::ParallelStart, - V2HookEvent::ParallelComplete => OldHookEvent::ParallelComplete, - V2HookEvent::SandboxReady => OldHookEvent::SandboxReady, - V2HookEvent::SandboxCleanup => OldHookEvent::SandboxCleanup, - V2HookEvent::CheckpointSaved => OldHookEvent::CheckpointSaved, - V2HookEvent::PreToolUse => OldHookEvent::PreToolUse, - V2HookEvent::PostToolUse => OldHookEvent::PostToolUse, - V2HookEvent::PostToolUseFailure => OldHookEvent::PostToolUseFailure, - } -} - fn interp_to_string(value: &InterpString) -> String { value.as_source() } diff --git a/lib/crates/fabro-workflow/src/operations/start.rs b/lib/crates/fabro-workflow/src/operations/start.rs index 7944fb576..d59168d3b 100644 --- a/lib/crates/fabro-workflow/src/operations/start.rs +++ b/lib/crates/fabro-workflow/src/operations/start.rs @@ -5,6 +5,7 @@ use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use fabro_config::project as project_config; +use fabro_hooks::config::bridge_hook; use fabro_interview::{AutoApproveInterviewer, Interviewer}; use fabro_model::{Catalog, FallbackTarget, Provider}; use fabro_sandbox::{SandboxProvider, SandboxSpec}; @@ -12,7 +13,7 @@ use fabro_types::RunId; use fabro_types::settings::sandbox::{self as sandbox_config, WorktreeMode}; use fabro_types::settings::v2::run::ModelRefOrSplice; use fabro_types::settings::v2::to_runtime::{ - bridge_hook, bridge_mcp_entry, bridge_pull_request, bridge_sandbox, bridge_worktree_mode, + bridge_mcp_entry, bridge_pull_request, bridge_sandbox, bridge_worktree_mode, }; use fabro_types::settings::v2::{InterpString, SettingsFile}; From 38dacb8744babb430f0bb38f180f32c056db2ffd Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 9 Apr 2026 18:12:00 -0400 Subject: [PATCH 31/47] refactor(settings): stage 6.3b promote mcp runtime types into fabro-mcp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves `McpServerEntry`, `McpServerSettings`, `McpTransport`, plus the `default_startup_timeout_secs` / `default_tool_timeout_secs` helpers from `fabro-types/src/settings/mcp.rs` into `fabro-mcp/src/config.rs`. fabro-mcp was already the only crate that re-exported them, so this deletes the `fabro-types` module entirely and drops the `pub use mcp::*` re-export from `settings/mod.rs`. `bridge_mcps` / `bridge_mcp_entry` (v2 `McpEntryLayer` → runtime `McpServerEntry` converters) also move to `fabro-mcp/src/config.rs`. `fabro-workflow::operations::start` and `fabro-cli::commands::exec` now import `bridge_mcp_entry` from `fabro_mcp::config::bridge_mcp_entry` instead of the v2 `to_runtime` module. Four of the seven legacy runtime type modules are now gone; three remain (run, sandbox, server). The `to_runtime.rs` module is down to just sandbox, pull-request, merge-strategy, artifacts, and worktree-mode helpers. 3,758 workspace tests pass. `cargo fmt --check --all` and `cargo clippy --workspace -- -D warnings` are clean. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/crates/fabro-cli/src/commands/exec.rs | 3 +- lib/crates/fabro-mcp/src/config.rs | 285 +++++++++++++++++- lib/crates/fabro-types/src/settings/mcp.rs | 186 ------------ lib/crates/fabro-types/src/settings/mod.rs | 5 - .../fabro-types/src/settings/v2/to_runtime.rs | 109 +------ .../fabro-workflow/src/operations/start.rs | 3 +- 6 files changed, 293 insertions(+), 298 deletions(-) delete mode 100644 lib/crates/fabro-types/src/settings/mcp.rs diff --git a/lib/crates/fabro-cli/src/commands/exec.rs b/lib/crates/fabro-cli/src/commands/exec.rs index 04d6fa619..47d1d10a1 100644 --- a/lib/crates/fabro-cli/src/commands/exec.rs +++ b/lib/crates/fabro-cli/src/commands/exec.rs @@ -2,9 +2,8 @@ use anyhow::Result; use fabro_agent::cli::{OutputFormat, run_with_args, run_with_args_and_client}; use fabro_llm::client::Client; use fabro_llm::providers::FabroServerAdapter; -use fabro_mcp::config::McpServerSettings; +use fabro_mcp::config::{McpServerSettings, bridge_mcp_entry}; use fabro_types::settings::v2::InterpString; -use fabro_types::settings::v2::to_runtime::bridge_mcp_entry; use std::collections::HashMap; use std::sync::Arc; diff --git a/lib/crates/fabro-mcp/src/config.rs b/lib/crates/fabro-mcp/src/config.rs index feed3623f..7787e5436 100644 --- a/lib/crates/fabro-mcp/src/config.rs +++ b/lib/crates/fabro-mcp/src/config.rs @@ -1,4 +1,281 @@ -pub use fabro_types::settings::mcp::{ - McpServerEntry, McpServerSettings, McpTransport, default_startup_timeout_secs, - default_tool_timeout_secs, -}; +//! MCP server configuration runtime types. +//! +//! The v2 parse tree lives in `fabro_types::settings::v2::run::McpEntryLayer`. +//! This module owns the runtime shape (flattened, with timeout helpers) that +//! the MCP client consumes at execution time. Conversion from the v2 shape +//! lives in [`bridge_mcp_entry`] / [`bridge_mcps`]. + +use std::collections::HashMap; +use std::time::Duration; + +use fabro_types::settings::v2::InterpString; +use fabro_types::settings::v2::run::McpEntryLayer; +use serde::{Deserialize, Serialize}; + +#[must_use] +pub fn default_startup_timeout_secs() -> u64 { + 10 +} + +#[must_use] +pub fn default_tool_timeout_secs() -> u64 { + 60 +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct McpServerSettings { + pub name: String, + pub transport: McpTransport, + #[serde(default = "default_startup_timeout_secs")] + pub startup_timeout_secs: u64, + #[serde(default = "default_tool_timeout_secs")] + pub tool_timeout_secs: u64, +} + +impl McpServerSettings { + #[must_use] + pub fn startup_timeout(&self) -> Duration { + Duration::from_secs(self.startup_timeout_secs) + } + + #[must_use] + pub fn tool_timeout(&self) -> Duration { + Duration::from_secs(self.tool_timeout_secs) + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum McpTransport { + Stdio { + command: Vec, + #[serde(default)] + env: HashMap, + }, + Http { + url: String, + #[serde(default)] + headers: HashMap, + }, + /// MCP server that runs inside a sandbox and is accessed via HTTP preview URL. + /// During session init, the server is started inside the sandbox and this + /// variant is resolved into an `Http` transport using the sandbox's preview URL. + Sandbox { + command: Vec, + port: u16, + #[serde(default)] + env: HashMap, + }, +} + +/// MCP server entry as it appears in TOML config files (without a `name` field). +/// +/// Converted to [`McpServerSettings`] via [`McpServerEntry::into_config`]. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct McpServerEntry { + #[serde(flatten)] + pub transport: McpTransport, + #[serde(default = "default_startup_timeout_secs")] + pub startup_timeout_secs: u64, + #[serde(default = "default_tool_timeout_secs")] + pub tool_timeout_secs: u64, +} + +impl McpServerEntry { + #[must_use] + pub fn into_config(self, name: String) -> McpServerSettings { + McpServerSettings { + name, + transport: self.transport, + startup_timeout_secs: self.startup_timeout_secs, + tool_timeout_secs: self.tool_timeout_secs, + } + } +} + +/// Convert a map of v2 `McpEntryLayer` entries into runtime `McpServerEntry`s. +#[must_use] +pub fn bridge_mcps(mcps: &HashMap) -> HashMap { + mcps.iter() + .map(|(name, entry)| (name.clone(), bridge_mcp_entry(entry))) + .collect() +} + +/// Convert a single v2 `McpEntryLayer` into the runtime `McpServerEntry`. +#[must_use] +pub fn bridge_mcp_entry(entry: &McpEntryLayer) -> McpServerEntry { + let transport = match entry { + McpEntryLayer::Stdio { + script, + command, + env, + .. + } => { + let command_vec: Vec = if let Some(script) = script { + vec!["sh".into(), "-c".into(), interp_to_string(script)] + } else if let Some(command) = command { + command.iter().map(interp_to_string).collect() + } else { + Vec::new() + }; + McpTransport::Stdio { + command: command_vec, + env: env + .iter() + .map(|(k, v)| (k.clone(), interp_to_string(v))) + .collect(), + } + } + McpEntryLayer::Http { url, headers, .. } => McpTransport::Http { + url: interp_to_string(url), + headers: headers + .iter() + .map(|(k, v)| (k.clone(), interp_to_string(v))) + .collect(), + }, + McpEntryLayer::Sandbox { + script, + command, + port, + env, + .. + } => { + let command_vec: Vec = if let Some(script) = script { + vec!["sh".into(), "-c".into(), interp_to_string(script)] + } else if let Some(command) = command { + command.iter().map(interp_to_string).collect() + } else { + Vec::new() + }; + McpTransport::Sandbox { + command: command_vec, + port: *port, + env: env + .iter() + .map(|(k, v)| (k.clone(), interp_to_string(v))) + .collect(), + } + } + }; + + let (startup_secs, tool_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(default_startup_timeout_secs(), |d| d.as_std().as_secs()), + tool_timeout.map_or(default_tool_timeout_secs(), |d| d.as_std().as_secs()), + ), + }; + + McpServerEntry { + transport, + startup_timeout_secs: startup_secs, + tool_timeout_secs: tool_secs, + } +} + +fn interp_to_string(value: &InterpString) -> String { + value.as_source() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + #[test] + fn stdio_config_construction() { + let config = McpServerSettings { + name: "test-server".into(), + transport: McpTransport::Stdio { + command: vec![ + "npx".into(), + "-y".into(), + "@modelcontextprotocol/server-filesystem".into(), + ], + env: HashMap::new(), + }, + startup_timeout_secs: 10, + tool_timeout_secs: 60, + }; + assert_eq!(config.name, "test-server"); + assert_eq!(config.startup_timeout(), Duration::from_secs(10)); + assert_eq!(config.tool_timeout(), Duration::from_secs(60)); + } + + #[test] + fn http_config_construction() { + let config = McpServerSettings { + name: "remote-server".into(), + transport: McpTransport::Http { + url: "https://example.com/mcp".into(), + headers: HashMap::from([("Authorization".into(), "Bearer token".into())]), + }, + startup_timeout_secs: 30, + tool_timeout_secs: 60, + }; + assert_eq!(config.name, "remote-server"); + assert_eq!(config.startup_timeout(), Duration::from_secs(30)); + assert_eq!(config.tool_timeout(), Duration::from_secs(60)); + } + + #[test] + fn serde_round_trip_stdio() { + let config = McpServerSettings { + name: "fs".into(), + transport: McpTransport::Stdio { + command: vec!["node".into(), "server.js".into()], + env: HashMap::from([("NODE_ENV".into(), "production".into())]), + }, + startup_timeout_secs: 15, + tool_timeout_secs: 90, + }; + let json = serde_json::to_string(&config).unwrap(); + let deserialized: McpServerSettings = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.name, "fs"); + assert_eq!(deserialized.startup_timeout_secs, 15); + assert_eq!(deserialized.tool_timeout_secs, 90); + assert!( + matches!(deserialized.transport, McpTransport::Stdio { command, .. } if command == vec!["node", "server.js"]) + ); + } + + #[test] + fn serde_round_trip_http() { + let config = McpServerSettings { + name: "remote".into(), + transport: McpTransport::Http { + url: "https://mcp.example.com".into(), + headers: HashMap::new(), + }, + startup_timeout_secs: 10, + tool_timeout_secs: 60, + }; + let json = serde_json::to_string(&config).unwrap(); + let deserialized: McpServerSettings = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.name, "remote"); + assert!( + matches!(deserialized.transport, McpTransport::Http { url, .. } if url == "https://mcp.example.com") + ); + } + + #[test] + fn serde_defaults_applied() { + let json = r#"{"name":"minimal","transport":{"type":"stdio","command":["echo"]}}"#; + let config: McpServerSettings = serde_json::from_str(json).unwrap(); + assert_eq!(config.startup_timeout_secs, 10); + assert_eq!(config.tool_timeout_secs, 60); + } +} diff --git a/lib/crates/fabro-types/src/settings/mcp.rs b/lib/crates/fabro-types/src/settings/mcp.rs deleted file mode 100644 index 63d3bf5ac..000000000 --- a/lib/crates/fabro-types/src/settings/mcp.rs +++ /dev/null @@ -1,186 +0,0 @@ -use std::collections::HashMap; -use std::time::Duration; - -use serde::{Deserialize, Serialize}; - -use crate::combine::Combine; - -pub fn default_startup_timeout_secs() -> u64 { - 10 -} - -pub fn default_tool_timeout_secs() -> u64 { - 60 -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct McpServerSettings { - pub name: String, - pub transport: McpTransport, - #[serde(default = "default_startup_timeout_secs")] - pub startup_timeout_secs: u64, - #[serde(default = "default_tool_timeout_secs")] - pub tool_timeout_secs: u64, -} - -impl McpServerSettings { - #[must_use] - pub fn startup_timeout(&self) -> Duration { - Duration::from_secs(self.startup_timeout_secs) - } - - #[must_use] - pub fn tool_timeout(&self) -> Duration { - Duration::from_secs(self.tool_timeout_secs) - } -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum McpTransport { - Stdio { - command: Vec, - #[serde(default)] - env: HashMap, - }, - Http { - url: String, - #[serde(default)] - headers: HashMap, - }, - /// MCP server that runs inside a sandbox and is accessed via HTTP preview URL. - /// During session init, the server is started inside the sandbox and this - /// variant is resolved into an `Http` transport using the sandbox's preview URL. - Sandbox { - command: Vec, - port: u16, - #[serde(default)] - env: HashMap, - }, -} - -impl Combine for McpTransport { - fn combine(self, _other: Self) -> Self { - self - } -} - -/// MCP server entry as it appears in TOML config files (without a `name` field). -/// -/// Converted to [`McpServerSettings`] via [`McpServerEntry::into_config`]. -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] -pub struct McpServerEntry { - #[serde(flatten)] - pub transport: McpTransport, - #[serde(default = "default_startup_timeout_secs")] - pub startup_timeout_secs: u64, - #[serde(default = "default_tool_timeout_secs")] - pub tool_timeout_secs: u64, -} - -impl McpServerEntry { - pub fn into_config(self, name: String) -> McpServerSettings { - McpServerSettings { - name, - transport: self.transport, - startup_timeout_secs: self.startup_timeout_secs, - tool_timeout_secs: self.tool_timeout_secs, - } - } -} - -impl Combine for McpServerEntry { - fn combine(self, _other: Self) -> Self { - self - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::collections::HashMap; - - #[test] - fn stdio_config_construction() { - let config = McpServerSettings { - name: "test-server".into(), - transport: McpTransport::Stdio { - command: vec![ - "npx".into(), - "-y".into(), - "@modelcontextprotocol/server-filesystem".into(), - ], - env: HashMap::new(), - }, - startup_timeout_secs: 10, - tool_timeout_secs: 60, - }; - assert_eq!(config.name, "test-server"); - assert_eq!(config.startup_timeout(), Duration::from_secs(10)); - assert_eq!(config.tool_timeout(), Duration::from_secs(60)); - } - - #[test] - fn http_config_construction() { - let config = McpServerSettings { - name: "remote-server".into(), - transport: McpTransport::Http { - url: "https://example.com/mcp".into(), - headers: HashMap::from([("Authorization".into(), "Bearer token".into())]), - }, - startup_timeout_secs: 30, - tool_timeout_secs: 60, - }; - assert_eq!(config.name, "remote-server"); - assert_eq!(config.startup_timeout(), Duration::from_secs(30)); - assert_eq!(config.tool_timeout(), Duration::from_secs(60)); - } - - #[test] - fn serde_round_trip_stdio() { - let config = McpServerSettings { - name: "fs".into(), - transport: McpTransport::Stdio { - command: vec!["node".into(), "server.js".into()], - env: HashMap::from([("NODE_ENV".into(), "production".into())]), - }, - startup_timeout_secs: 15, - tool_timeout_secs: 90, - }; - let json = serde_json::to_string(&config).unwrap(); - let deserialized: McpServerSettings = serde_json::from_str(&json).unwrap(); - assert_eq!(deserialized.name, "fs"); - assert_eq!(deserialized.startup_timeout_secs, 15); - assert_eq!(deserialized.tool_timeout_secs, 90); - assert!( - matches!(deserialized.transport, McpTransport::Stdio { command, .. } if command == vec!["node", "server.js"]) - ); - } - - #[test] - fn serde_round_trip_http() { - let config = McpServerSettings { - name: "remote".into(), - transport: McpTransport::Http { - url: "https://mcp.example.com".into(), - headers: HashMap::new(), - }, - startup_timeout_secs: 10, - tool_timeout_secs: 60, - }; - let json = serde_json::to_string(&config).unwrap(); - let deserialized: McpServerSettings = serde_json::from_str(&json).unwrap(); - assert_eq!(deserialized.name, "remote"); - assert!( - matches!(deserialized.transport, McpTransport::Http { url, .. } if url == "https://mcp.example.com") - ); - } - - #[test] - fn serde_defaults_applied() { - let json = r#"{"name":"minimal","transport":{"type":"stdio","command":["echo"]}}"#; - let config: McpServerSettings = serde_json::from_str(json).unwrap(); - assert_eq!(config.startup_timeout_secs, 10); - assert_eq!(config.tool_timeout_secs, 60); - } -} diff --git a/lib/crates/fabro-types/src/settings/mod.rs b/lib/crates/fabro-types/src/settings/mod.rs index 721371d01..383d65552 100644 --- a/lib/crates/fabro-types/src/settings/mod.rs +++ b/lib/crates/fabro-types/src/settings/mod.rs @@ -19,16 +19,11 @@ //! owning consumer crates or replace their call sites with v2-native //! accessors, at which point this module goes away. -pub mod mcp; pub mod run; pub mod sandbox; pub mod server; pub mod v2; -pub use mcp::{ - McpServerEntry, McpServerSettings, McpTransport, default_startup_timeout_secs, - default_tool_timeout_secs, -}; pub use run::{ ArtifactsSettings, CheckpointSettings, GitHubSettings, LlmSettings, MergeStrategy, PullRequestSettings, SetupSettings, diff --git a/lib/crates/fabro-types/src/settings/v2/to_runtime.rs b/lib/crates/fabro-types/src/settings/v2/to_runtime.rs index c3e3705f5..f27b251b5 100644 --- a/lib/crates/fabro-types/src/settings/v2/to_runtime.rs +++ b/lib/crates/fabro-types/src/settings/v2/to_runtime.rs @@ -1,22 +1,20 @@ //! v2 → runtime-type conversion helpers. //! -//! The runtime types in `fabro_types::settings::{mcp,run,sandbox}` are the -//! shapes that downstream crates (fabro-workflow, fabro-mcp, fabro-sandbox) -//! still consume at runtime. Each helper here reads the v2 parse tree and +//! The runtime types in `fabro_types::settings::{run,sandbox}` are the +//! shapes that downstream crates (fabro-workflow, fabro-sandbox) still +//! consume at runtime. Each helper here reads the v2 parse tree and //! builds the equivalent runtime value. //! -//! Hook bridging has moved to `fabro_hooks::config::bridge_hook`, which -//! owns its runtime shape. Consumer crates will pull the rest of these -//! helpers into their own crates in follow-up 6.3b passes. - -use std::collections::HashMap; +//! Hook bridging has moved to `fabro_hooks::config::bridge_hook`; MCP +//! bridging has moved to `fabro_mcp::config::{bridge_mcps, bridge_mcp_entry}`. +//! Consumer crates will pull the rest of these helpers into their own +//! crates in follow-up 6.3b passes. use super::interp::InterpString; use super::run::{ - McpEntryLayer, MergeStrategy as V2MergeStrategy, RunArtifactsLayer, RunPullRequestLayer, - RunSandboxLayer, WorktreeMode as V2WorktreeMode, + MergeStrategy as V2MergeStrategy, RunArtifactsLayer, RunPullRequestLayer, RunSandboxLayer, + WorktreeMode as V2WorktreeMode, }; -use crate::settings::mcp::{McpServerEntry, McpTransport}; use crate::settings::run::{ ArtifactsSettings, MergeStrategy as OldMergeStrategy, PullRequestSettings, }; @@ -116,95 +114,6 @@ pub fn bridge_run_artifacts(artifacts: &RunArtifactsLayer) -> ArtifactsSettings } } -pub fn bridge_mcps(mcps: &HashMap) -> HashMap { - mcps.iter() - .map(|(name, entry)| (name.clone(), bridge_mcp_entry(entry))) - .collect() -} - -pub fn bridge_mcp_entry(entry: &McpEntryLayer) -> McpServerEntry { - let transport = match entry { - McpEntryLayer::Stdio { - script, - command, - env, - .. - } => { - let command_vec: Vec = if let Some(script) = script { - vec!["sh".into(), "-c".into(), interp_to_string(script)] - } else if let Some(command) = command { - command.iter().map(interp_to_string).collect() - } else { - Vec::new() - }; - McpTransport::Stdio { - command: command_vec, - env: env - .iter() - .map(|(k, v)| (k.clone(), interp_to_string(v))) - .collect(), - } - } - McpEntryLayer::Http { url, headers, .. } => McpTransport::Http { - url: interp_to_string(url), - headers: headers - .iter() - .map(|(k, v)| (k.clone(), interp_to_string(v))) - .collect(), - }, - McpEntryLayer::Sandbox { - script, - command, - port, - env, - .. - } => { - let command_vec: Vec = if let Some(script) = script { - vec!["sh".into(), "-c".into(), interp_to_string(script)] - } else if let Some(command) = command { - command.iter().map(interp_to_string).collect() - } else { - Vec::new() - }; - McpTransport::Sandbox { - command: command_vec, - port: *port, - env: env - .iter() - .map(|(k, v)| (k.clone(), interp_to_string(v))) - .collect(), - } - } - }; - - let (startup_secs, tool_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, |d| d.as_std().as_secs()), - tool_timeout.map_or(60, |d| d.as_std().as_secs()), - ), - }; - - McpServerEntry { - transport, - startup_timeout_secs: startup_secs, - tool_timeout_secs: tool_secs, - } -} - fn interp_to_string(value: &InterpString) -> String { value.as_source() } diff --git a/lib/crates/fabro-workflow/src/operations/start.rs b/lib/crates/fabro-workflow/src/operations/start.rs index d59168d3b..b962b4d1e 100644 --- a/lib/crates/fabro-workflow/src/operations/start.rs +++ b/lib/crates/fabro-workflow/src/operations/start.rs @@ -7,13 +7,14 @@ use std::time::{Duration, Instant}; use fabro_config::project as project_config; use fabro_hooks::config::bridge_hook; use fabro_interview::{AutoApproveInterviewer, Interviewer}; +use fabro_mcp::config::bridge_mcp_entry; use fabro_model::{Catalog, FallbackTarget, Provider}; use fabro_sandbox::{SandboxProvider, SandboxSpec}; use fabro_types::RunId; use fabro_types::settings::sandbox::{self as sandbox_config, WorktreeMode}; use fabro_types::settings::v2::run::ModelRefOrSplice; use fabro_types::settings::v2::to_runtime::{ - bridge_mcp_entry, bridge_pull_request, bridge_sandbox, bridge_worktree_mode, + bridge_pull_request, bridge_sandbox, bridge_worktree_mode, }; use fabro_types::settings::v2::{InterpString, SettingsFile}; From 6df8bbeb3cf3de937b44e23f729566522c6156a8 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 9 Apr 2026 18:17:06 -0400 Subject: [PATCH 32/47] refactor(settings): stage 6.3b promote sandbox runtime types into fabro-sandbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the sandbox runtime types from `fabro-types/src/settings/sandbox.rs` into a new `fabro-sandbox/src/config.rs` module: - `SandboxSettings`, `LocalSandboxSettings`, `DaytonaSettings`, `DaytonaSnapshotSettings`, `DaytonaNetwork`, `DockerfileSource`, `WorktreeMode` (with the custom serde `DaytonaNetwork` serialize/deserialize impls intact). - `bridge_sandbox` and `bridge_worktree_mode` (v2 `RunSandboxLayer` → `SandboxSettings` converters) also move from `fabro-types/src/settings/v2/to_runtime.rs` into the new config module. `fabro-sandbox/src/daytona/mod.rs` and `sandbox_spec.rs` update to import from the crate-local `config` module instead of `fabro_types::settings::sandbox`. The daytona module still re-exports `DaytonaSettings as DaytonaConfig` etc., so no breaking changes for callers of `fabro_sandbox::daytona::*`. Consumer updates: - `fabro-workflow/src/operations/start.rs` and `pipeline/types.rs` now import `WorktreeMode`, `SandboxSettings` (as `sandbox_config` alias), `bridge_sandbox`, and `bridge_worktree_mode` from `fabro_sandbox::config`. - `fabro-server/src/run_manifest.rs` imports `bridge_sandbox` from `fabro_sandbox::config`. `to_runtime.rs` in fabro-types shrinks to just the three remaining helpers tied to the legacy `run.rs` module types (`bridge_merge_strategy`, `bridge_pull_request`, `bridge_run_artifacts`). Those move out in the next 6.3b pass when the `run.rs` module itself moves. Five of the seven legacy runtime type modules are now gone; two remain (run, server). 3,758 workspace tests pass. `cargo fmt --check --all` and `cargo clippy --workspace -- -D warnings` are clean. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/config.rs} | 99 ++++++++++++++++- lib/crates/fabro-sandbox/src/daytona/mod.rs | 2 +- lib/crates/fabro-sandbox/src/lib.rs | 1 + lib/crates/fabro-sandbox/src/sandbox_spec.rs | 3 +- lib/crates/fabro-server/src/run_manifest.rs | 2 +- lib/crates/fabro-types/src/settings/mod.rs | 5 - .../fabro-types/src/settings/v2/to_runtime.rs | 105 +++--------------- .../fabro-workflow/src/operations/start.rs | 8 +- .../fabro-workflow/src/pipeline/types.rs | 2 +- 9 files changed, 120 insertions(+), 107 deletions(-) rename lib/crates/{fabro-types/src/settings/sandbox.rs => fabro-sandbox/src/config.rs} (54%) diff --git a/lib/crates/fabro-types/src/settings/sandbox.rs b/lib/crates/fabro-sandbox/src/config.rs similarity index 54% rename from lib/crates/fabro-types/src/settings/sandbox.rs rename to lib/crates/fabro-sandbox/src/config.rs index f352f8352..479b9a2e5 100644 --- a/lib/crates/fabro-types/src/settings/sandbox.rs +++ b/lib/crates/fabro-sandbox/src/config.rs @@ -1,5 +1,20 @@ +//! Sandbox configuration runtime types. +//! +//! These types are the runtime shape that the sandbox providers consume. +//! The v2 parse tree lives in `fabro_types::settings::v2::run::RunSandboxLayer`. +//! Conversion from the v2 shape lives in [`bridge_sandbox`]. +//! +//! The `DaytonaSettings`/`DaytonaSnapshotSettings` names are kept for +//! backward compatibility with the old import path; [`crate::daytona`] +//! continues to re-export them under `DaytonaConfig`/`DaytonaSnapshotConfig` +//! aliases. + use std::collections::HashMap; +use fabro_types::settings::v2::InterpString; +use fabro_types::settings::v2::run::{ + DaytonaDockerfileLayer, DaytonaNetworkLayer, RunSandboxLayer, WorktreeMode as V2WorktreeMode, +}; use serde::de::{self, MapAccess, Visitor}; use serde::{Deserialize, Serialize}; @@ -13,7 +28,7 @@ pub struct DaytonaSettings { pub skip_clone: bool, } -#[derive(Clone, Debug, PartialEq, crate::Combine)] +#[derive(Clone, Debug, PartialEq)] pub enum DaytonaNetwork { Block, AllowAll, @@ -98,7 +113,7 @@ impl<'de> Deserialize<'de> for DaytonaNetwork { } } -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, crate::Combine)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(untagged)] pub enum DockerfileSource { Inline(String), @@ -114,7 +129,7 @@ pub struct DaytonaSnapshotSettings { pub dockerfile: Option, } -#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)] +#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Serialize)] #[serde(rename_all = "snake_case")] pub enum WorktreeMode { Always, @@ -139,3 +154,81 @@ pub struct SandboxSettings { pub daytona: Option, pub env: Option>, } + +/// Convert a v2 [`RunSandboxLayer`] into the runtime [`SandboxSettings`] shape. +#[must_use] +pub fn bridge_sandbox(sb: &RunSandboxLayer) -> SandboxSettings { + SandboxSettings { + provider: sb.provider.clone(), + preserve: sb.preserve, + devcontainer: sb.devcontainer, + local: sb.local.as_ref().map(|local| LocalSandboxSettings { + worktree_mode: local + .worktree_mode + .map(bridge_worktree_mode) + .unwrap_or_default(), + }), + daytona: sb.daytona.as_ref().map(|d| DaytonaSettings { + auto_stop_interval: d.auto_stop_interval, + labels: if d.labels.is_empty() { + None + } else { + Some(d.labels.clone()) + }, + snapshot: d.snapshot.as_ref().and_then(|s| { + s.name.as_ref().map(|name| DaytonaSnapshotSettings { + name: name.clone(), + cpu: s.cpu, + memory: s.memory.map(|sz| size_to_gb_i32(sz.as_bytes())), + disk: s.disk.map(|sz| size_to_gb_i32(sz.as_bytes())), + dockerfile: s.dockerfile.as_ref().map(|d| match d { + DaytonaDockerfileLayer::Inline(text) => { + DockerfileSource::Inline(text.clone()) + } + DaytonaDockerfileLayer::Path { path } => { + DockerfileSource::Path { path: path.clone() } + } + }), + }) + }), + network: d.network.as_ref().map(|n| match n { + DaytonaNetworkLayer::Block => DaytonaNetwork::Block, + DaytonaNetworkLayer::AllowAll => DaytonaNetwork::AllowAll, + DaytonaNetworkLayer::AllowList { allow_list } => { + DaytonaNetwork::AllowList(allow_list.clone()) + } + }), + skip_clone: d.skip_clone.unwrap_or(false), + }), + env: if sb.env.is_empty() { + None + } else { + Some( + sb.env + .iter() + .map(|(k, v)| (k.clone(), interp_to_string(v))) + .collect(), + ) + }, + } +} + +/// Convert a v2 [`V2WorktreeMode`] into the runtime [`WorktreeMode`]. +#[must_use] +pub fn bridge_worktree_mode(m: V2WorktreeMode) -> WorktreeMode { + match m { + V2WorktreeMode::Always => WorktreeMode::Always, + V2WorktreeMode::Clean => WorktreeMode::Clean, + V2WorktreeMode::Dirty => WorktreeMode::Dirty, + V2WorktreeMode::Never => WorktreeMode::Never, + } +} + +fn interp_to_string(value: &InterpString) -> String { + value.as_source() +} + +fn size_to_gb_i32(bytes: u64) -> i32 { + let gb = bytes / 1_000_000_000; + i32::try_from(gb).unwrap_or(i32::MAX) +} diff --git a/lib/crates/fabro-sandbox/src/daytona/mod.rs b/lib/crates/fabro-sandbox/src/daytona/mod.rs index 1d58f8da3..c580d6bdb 100644 --- a/lib/crates/fabro-sandbox/src/daytona/mod.rs +++ b/lib/crates/fabro-sandbox/src/daytona/mod.rs @@ -22,7 +22,7 @@ use tokio_util::sync::CancellationToken; const WORKING_DIRECTORY: &str = "/home/daytona/workspace"; const DEFAULT_SNAPSHOT: &str = "daytona-medium"; -pub use fabro_types::settings::sandbox::{ +pub use crate::config::{ DaytonaNetwork, DaytonaSettings as DaytonaConfig, DaytonaSnapshotSettings as DaytonaSnapshotConfig, DockerfileSource, }; diff --git a/lib/crates/fabro-sandbox/src/lib.rs b/lib/crates/fabro-sandbox/src/lib.rs index fec1dd390..cfb14befd 100644 --- a/lib/crates/fabro-sandbox/src/lib.rs +++ b/lib/crates/fabro-sandbox/src/lib.rs @@ -1,3 +1,4 @@ +pub mod config; pub mod sandbox; pub mod sandbox_spec; diff --git a/lib/crates/fabro-sandbox/src/sandbox_spec.rs b/lib/crates/fabro-sandbox/src/sandbox_spec.rs index c05f7aa56..61f9c5aeb 100644 --- a/lib/crates/fabro-sandbox/src/sandbox_spec.rs +++ b/lib/crates/fabro-sandbox/src/sandbox_spec.rs @@ -1,7 +1,8 @@ use std::path::PathBuf; use std::sync::Arc; -use fabro_types::{RunId, settings::WorktreeMode}; +use crate::config::WorktreeMode; +use fabro_types::RunId; #[cfg(any(feature = "docker", feature = "daytona"))] use anyhow::anyhow; diff --git a/lib/crates/fabro-server/src/run_manifest.rs b/lib/crates/fabro-server/src/run_manifest.rs index 132a48ab7..fb7808a5c 100644 --- a/lib/crates/fabro-server/src/run_manifest.rs +++ b/lib/crates/fabro-server/src/run_manifest.rs @@ -13,6 +13,7 @@ use fabro_graphviz::graph::{Graph, is_llm_handler_type}; use fabro_graphviz::render::apply_direction; use fabro_llm::Provider; use fabro_model::Catalog; +use fabro_sandbox::config::bridge_sandbox; use fabro_sandbox::daytona::DaytonaConfig; use fabro_sandbox::{DockerSandboxOptions, Sandbox, SandboxProvider, SandboxSpec}; use fabro_types::RunId; @@ -23,7 +24,6 @@ use fabro_types::settings::v2::run::{ ApprovalMode, DaytonaDockerfileLayer, RunExecutionLayer, RunLayer, RunMode, RunModelLayer, RunSandboxLayer, }; -use fabro_types::settings::v2::to_runtime::bridge_sandbox; use fabro_util::check_report::{CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus}; use fabro_validate::Severity; use fabro_workflow::error::FabroError; diff --git a/lib/crates/fabro-types/src/settings/mod.rs b/lib/crates/fabro-types/src/settings/mod.rs index 383d65552..ec488758b 100644 --- a/lib/crates/fabro-types/src/settings/mod.rs +++ b/lib/crates/fabro-types/src/settings/mod.rs @@ -20,7 +20,6 @@ //! accessors, at which point this module goes away. pub mod run; -pub mod sandbox; pub mod server; pub mod v2; @@ -28,10 +27,6 @@ pub use run::{ ArtifactsSettings, CheckpointSettings, GitHubSettings, LlmSettings, MergeStrategy, PullRequestSettings, SetupSettings, }; -pub use sandbox::{ - DaytonaNetwork, DaytonaSettings, DaytonaSnapshotSettings, DockerfileSource, - LocalSandboxSettings, SandboxSettings, WorktreeMode, -}; pub use server::{ ApiAuthStrategy, ApiSettings, ArtifactStorageBackend, ArtifactStorageSettings, AuthProvider, AuthSettings, FeaturesSettings, GitAuthorSettings, GitProvider, GitSettings, LogSettings, diff --git a/lib/crates/fabro-types/src/settings/v2/to_runtime.rs b/lib/crates/fabro-types/src/settings/v2/to_runtime.rs index f27b251b5..192a77862 100644 --- a/lib/crates/fabro-types/src/settings/v2/to_runtime.rs +++ b/lib/crates/fabro-types/src/settings/v2/to_runtime.rs @@ -1,92 +1,24 @@ //! v2 → runtime-type conversion helpers. //! -//! The runtime types in `fabro_types::settings::{run,sandbox}` are the -//! shapes that downstream crates (fabro-workflow, fabro-sandbox) still -//! consume at runtime. Each helper here reads the v2 parse tree and -//! builds the equivalent runtime value. +//! Everything but the pull-request / artifacts / merge-strategy conversion +//! has moved out of this module into the consumer crate that owns the +//! target runtime type: //! -//! Hook bridging has moved to `fabro_hooks::config::bridge_hook`; MCP -//! bridging has moved to `fabro_mcp::config::{bridge_mcps, bridge_mcp_entry}`. -//! Consumer crates will pull the rest of these helpers into their own -//! crates in follow-up 6.3b passes. +//! - Hook bridging: [`fabro_hooks::config::bridge_hook`] +//! - MCP bridging: [`fabro_mcp::config::bridge_mcp_entry`] / +//! [`fabro_mcp::config::bridge_mcps`] +//! - Sandbox bridging: [`fabro_sandbox::config::bridge_sandbox`] / +//! [`fabro_sandbox::config::bridge_worktree_mode`] +//! +//! Pull-request / artifacts / merge-strategy still live here because their +//! target runtime types (`PullRequestSettings`, `ArtifactsSettings`, +//! `MergeStrategy`) are still in `fabro-types::settings::run`. When that +//! module moves into `fabro-workflow` the remaining helpers will follow. -use super::interp::InterpString; -use super::run::{ - MergeStrategy as V2MergeStrategy, RunArtifactsLayer, RunPullRequestLayer, RunSandboxLayer, - WorktreeMode as V2WorktreeMode, -}; +use super::run::{MergeStrategy as V2MergeStrategy, RunArtifactsLayer, RunPullRequestLayer}; use crate::settings::run::{ ArtifactsSettings, MergeStrategy as OldMergeStrategy, PullRequestSettings, }; -use crate::settings::sandbox::{ - DaytonaNetwork, DaytonaSettings, DaytonaSnapshotSettings, DockerfileSource, - LocalSandboxSettings, SandboxSettings, WorktreeMode as OldWorktreeMode, -}; - -pub fn bridge_sandbox(sb: &RunSandboxLayer) -> SandboxSettings { - SandboxSettings { - provider: sb.provider.clone(), - preserve: sb.preserve, - devcontainer: sb.devcontainer, - local: sb.local.as_ref().map(|local| LocalSandboxSettings { - worktree_mode: local - .worktree_mode - .map(bridge_worktree_mode) - .unwrap_or_default(), - }), - daytona: sb.daytona.as_ref().map(|d| DaytonaSettings { - auto_stop_interval: d.auto_stop_interval, - labels: if d.labels.is_empty() { - None - } else { - Some(d.labels.clone()) - }, - snapshot: d.snapshot.as_ref().and_then(|s| { - s.name.as_ref().map(|name| DaytonaSnapshotSettings { - name: name.clone(), - cpu: s.cpu, - memory: s.memory.map(|sz| size_to_gb_i32(sz.as_bytes())), - disk: s.disk.map(|sz| size_to_gb_i32(sz.as_bytes())), - dockerfile: s.dockerfile.as_ref().map(|d| match d { - super::run::DaytonaDockerfileLayer::Inline(text) => { - DockerfileSource::Inline(text.clone()) - } - super::run::DaytonaDockerfileLayer::Path { path } => { - DockerfileSource::Path { path: path.clone() } - } - }), - }) - }), - network: d.network.as_ref().map(|n| match n { - super::run::DaytonaNetworkLayer::Block => DaytonaNetwork::Block, - super::run::DaytonaNetworkLayer::AllowAll => DaytonaNetwork::AllowAll, - super::run::DaytonaNetworkLayer::AllowList { allow_list } => { - DaytonaNetwork::AllowList(allow_list.clone()) - } - }), - skip_clone: d.skip_clone.unwrap_or(false), - }), - env: if sb.env.is_empty() { - None - } else { - Some( - sb.env - .iter() - .map(|(k, v)| (k.clone(), interp_to_string(v))) - .collect(), - ) - }, - } -} - -pub fn bridge_worktree_mode(m: V2WorktreeMode) -> OldWorktreeMode { - match m { - V2WorktreeMode::Always => OldWorktreeMode::Always, - V2WorktreeMode::Clean => OldWorktreeMode::Clean, - V2WorktreeMode::Dirty => OldWorktreeMode::Dirty, - V2WorktreeMode::Never => OldWorktreeMode::Never, - } -} pub fn bridge_merge_strategy(m: V2MergeStrategy) -> OldMergeStrategy { match m { @@ -113,12 +45,3 @@ pub fn bridge_run_artifacts(artifacts: &RunArtifactsLayer) -> ArtifactsSettings include: artifacts.include.clone(), } } - -fn interp_to_string(value: &InterpString) -> String { - value.as_source() -} - -fn size_to_gb_i32(bytes: u64) -> i32 { - let gb = bytes / 1_000_000_000; - i32::try_from(gb).unwrap_or(i32::MAX) -} diff --git a/lib/crates/fabro-workflow/src/operations/start.rs b/lib/crates/fabro-workflow/src/operations/start.rs index b962b4d1e..55a2c140e 100644 --- a/lib/crates/fabro-workflow/src/operations/start.rs +++ b/lib/crates/fabro-workflow/src/operations/start.rs @@ -9,13 +9,13 @@ use fabro_hooks::config::bridge_hook; use fabro_interview::{AutoApproveInterviewer, Interviewer}; use fabro_mcp::config::bridge_mcp_entry; use fabro_model::{Catalog, FallbackTarget, Provider}; +use fabro_sandbox::config::{ + self as sandbox_config, WorktreeMode, bridge_sandbox, bridge_worktree_mode, +}; use fabro_sandbox::{SandboxProvider, SandboxSpec}; use fabro_types::RunId; -use fabro_types::settings::sandbox::{self as sandbox_config, WorktreeMode}; use fabro_types::settings::v2::run::ModelRefOrSplice; -use fabro_types::settings::v2::to_runtime::{ - bridge_pull_request, bridge_sandbox, bridge_worktree_mode, -}; +use fabro_types::settings::v2::to_runtime::bridge_pull_request; use fabro_types::settings::v2::{InterpString, SettingsFile}; use crate::artifact_upload::ArtifactSink; diff --git a/lib/crates/fabro-workflow/src/pipeline/types.rs b/lib/crates/fabro-workflow/src/pipeline/types.rs index d070fd0db..883093f28 100644 --- a/lib/crates/fabro-workflow/src/pipeline/types.rs +++ b/lib/crates/fabro-workflow/src/pipeline/types.rs @@ -10,8 +10,8 @@ use fabro_llm::Provider; use fabro_mcp::config::McpServerSettings; use fabro_model::FallbackTarget; use fabro_sandbox::SandboxSpec; +use fabro_sandbox::config::WorktreeMode; use fabro_types::RunId; -use fabro_types::settings::sandbox::WorktreeMode; use fabro_validate::Diagnostic; use crate::artifact_upload::ArtifactSink; From 7f9640aac7dffe32304682d7b97f732e27d6fbdf Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 9 Apr 2026 18:21:59 -0400 Subject: [PATCH 33/47] refactor(settings): stage 6.3b promote run runtime types + delete to_runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the only actively-used types from `fabro-types/src/settings/run.rs` — `PullRequestSettings`, `MergeStrategy`, `ArtifactsSettings` — into a new `fabro-workflow/src/config.rs` module. The other types in that file (`LlmSettings`, `SetupSettings`, `CheckpointSettings`, `GitHubSettings`) had no remaining consumers in the workspace and are deleted outright. `bridge_pull_request`, `bridge_merge_strategy`, and `bridge_run_artifacts` move along with them into `fabro-workflow/src/config.rs`. That empties `fabro-types/src/settings/v2/to_runtime.rs`, so the file is deleted and its `pub mod` declaration removed from `v2/mod.rs`. Stage 6.2's "narrow runtime-type conversion helpers" module is completely gone. Consumer updates: - `fabro-workflow/src/lib.rs` exposes `pub mod config`. - `fabro-workflow/src/operations/start.rs` imports `PullRequestSettings` and `bridge_pull_request` from `crate::config`. - `fabro-workflow/src/pipeline/types.rs` imports `PullRequestSettings` from `crate::config`. - `fabro-workflow/src/pipeline/pull_request.rs` imports `MergeStrategy` from `crate::config`. `fabro-types/src/settings/mod.rs` drops `pub mod run` and the corresponding `pub use run::{ArtifactsSettings, ...}` re-export. Six of the seven legacy runtime type modules are now gone; only `server.rs` remains. 3,758 workspace tests pass. `cargo fmt --check --all` and `cargo clippy --workspace -- -D warnings` are clean. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/crates/fabro-types/src/settings/mod.rs | 5 -- lib/crates/fabro-types/src/settings/run.rs | 61 ---------------- lib/crates/fabro-types/src/settings/v2/mod.rs | 1 - .../fabro-types/src/settings/v2/to_runtime.rs | 47 ------------ lib/crates/fabro-workflow/src/config.rs | 71 +++++++++++++++++++ lib/crates/fabro-workflow/src/lib.rs | 1 + .../fabro-workflow/src/operations/start.rs | 4 +- .../src/pipeline/pull_request.rs | 2 +- .../fabro-workflow/src/pipeline/types.rs | 2 +- 9 files changed, 76 insertions(+), 118 deletions(-) delete mode 100644 lib/crates/fabro-types/src/settings/run.rs delete mode 100644 lib/crates/fabro-types/src/settings/v2/to_runtime.rs create mode 100644 lib/crates/fabro-workflow/src/config.rs diff --git a/lib/crates/fabro-types/src/settings/mod.rs b/lib/crates/fabro-types/src/settings/mod.rs index ec488758b..de5da8e98 100644 --- a/lib/crates/fabro-types/src/settings/mod.rs +++ b/lib/crates/fabro-types/src/settings/mod.rs @@ -19,14 +19,9 @@ //! owning consumer crates or replace their call sites with v2-native //! accessors, at which point this module goes away. -pub mod run; pub mod server; pub mod v2; -pub use run::{ - ArtifactsSettings, CheckpointSettings, GitHubSettings, LlmSettings, MergeStrategy, - PullRequestSettings, SetupSettings, -}; pub use server::{ ApiAuthStrategy, ApiSettings, ArtifactStorageBackend, ArtifactStorageSettings, AuthProvider, AuthSettings, FeaturesSettings, GitAuthorSettings, GitProvider, GitSettings, LogSettings, diff --git a/lib/crates/fabro-types/src/settings/run.rs b/lib/crates/fabro-types/src/settings/run.rs deleted file mode 100644 index a46ad0b7e..000000000 --- a/lib/crates/fabro-types/src/settings/run.rs +++ /dev/null @@ -1,61 +0,0 @@ -use std::collections::HashMap; - -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] -pub struct CheckpointSettings { - #[serde(default)] - pub exclude_globs: Vec, -} - -fn default_true() -> bool { - true -} - -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] -pub struct PullRequestSettings { - #[serde(default)] - pub enabled: bool, - #[serde(default = "default_true")] - pub draft: bool, - #[serde(default)] - pub auto_merge: bool, - #[serde(default)] - pub merge_strategy: MergeStrategy, -} - -#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)] -#[serde(rename_all = "lowercase")] -pub enum MergeStrategy { - #[default] - Squash, - Merge, - Rebase, -} - -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] -pub struct ArtifactsSettings { - #[serde(default)] - pub include: Vec, -} - -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] -pub struct GitHubSettings { - #[serde(default)] - pub permissions: HashMap, -} - -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] -pub struct LlmSettings { - pub model: Option, - pub provider: Option, - #[serde(default)] - pub fallbacks: Option>>, -} - -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] -pub struct SetupSettings { - #[serde(default)] - pub commands: Vec, - pub timeout_ms: Option, -} diff --git a/lib/crates/fabro-types/src/settings/v2/mod.rs b/lib/crates/fabro-types/src/settings/v2/mod.rs index 9304b4082..8e2e9ddce 100644 --- a/lib/crates/fabro-types/src/settings/v2/mod.rs +++ b/lib/crates/fabro-types/src/settings/v2/mod.rs @@ -17,7 +17,6 @@ pub mod run; pub mod server; pub mod size; pub mod splice_array; -pub mod to_runtime; pub mod tree; pub mod version; pub mod workflow; diff --git a/lib/crates/fabro-types/src/settings/v2/to_runtime.rs b/lib/crates/fabro-types/src/settings/v2/to_runtime.rs deleted file mode 100644 index 192a77862..000000000 --- a/lib/crates/fabro-types/src/settings/v2/to_runtime.rs +++ /dev/null @@ -1,47 +0,0 @@ -//! v2 → runtime-type conversion helpers. -//! -//! Everything but the pull-request / artifacts / merge-strategy conversion -//! has moved out of this module into the consumer crate that owns the -//! target runtime type: -//! -//! - Hook bridging: [`fabro_hooks::config::bridge_hook`] -//! - MCP bridging: [`fabro_mcp::config::bridge_mcp_entry`] / -//! [`fabro_mcp::config::bridge_mcps`] -//! - Sandbox bridging: [`fabro_sandbox::config::bridge_sandbox`] / -//! [`fabro_sandbox::config::bridge_worktree_mode`] -//! -//! Pull-request / artifacts / merge-strategy still live here because their -//! target runtime types (`PullRequestSettings`, `ArtifactsSettings`, -//! `MergeStrategy`) are still in `fabro-types::settings::run`. When that -//! module moves into `fabro-workflow` the remaining helpers will follow. - -use super::run::{MergeStrategy as V2MergeStrategy, RunArtifactsLayer, RunPullRequestLayer}; -use crate::settings::run::{ - ArtifactsSettings, MergeStrategy as OldMergeStrategy, PullRequestSettings, -}; - -pub fn bridge_merge_strategy(m: V2MergeStrategy) -> OldMergeStrategy { - match m { - V2MergeStrategy::Squash => OldMergeStrategy::Squash, - V2MergeStrategy::Merge => OldMergeStrategy::Merge, - V2MergeStrategy::Rebase => OldMergeStrategy::Rebase, - } -} - -pub fn bridge_pull_request(pr: &RunPullRequestLayer) -> PullRequestSettings { - PullRequestSettings { - enabled: pr.enabled.unwrap_or(false), - draft: pr.draft.unwrap_or(true), - auto_merge: pr.auto_merge.unwrap_or(false), - merge_strategy: pr - .merge_strategy - .map(bridge_merge_strategy) - .unwrap_or_default(), - } -} - -pub fn bridge_run_artifacts(artifacts: &RunArtifactsLayer) -> ArtifactsSettings { - ArtifactsSettings { - include: artifacts.include.clone(), - } -} diff --git a/lib/crates/fabro-workflow/src/config.rs b/lib/crates/fabro-workflow/src/config.rs new file mode 100644 index 000000000..aba443698 --- /dev/null +++ b/lib/crates/fabro-workflow/src/config.rs @@ -0,0 +1,71 @@ +//! Workflow runtime configuration shapes. +//! +//! Runtime-side types consumed by the pipeline. The v2 parse tree lives in +//! `fabro_types::settings::v2::run::{RunPullRequestLayer, MergeStrategy, +//! RunArtifactsLayer}`. Conversion from v2 lives in [`bridge_pull_request`] +//! / [`bridge_run_artifacts`]. + +use fabro_types::settings::v2::run::{ + MergeStrategy as V2MergeStrategy, RunArtifactsLayer, RunPullRequestLayer, +}; +use serde::{Deserialize, Serialize}; + +fn default_true() -> bool { + true +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +pub struct PullRequestSettings { + #[serde(default)] + pub enabled: bool, + #[serde(default = "default_true")] + pub draft: bool, + #[serde(default)] + pub auto_merge: bool, + #[serde(default)] + pub merge_strategy: MergeStrategy, +} + +#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum MergeStrategy { + #[default] + Squash, + Merge, + Rebase, +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +pub struct ArtifactsSettings { + #[serde(default)] + pub include: Vec, +} + +#[must_use] +pub fn bridge_merge_strategy(m: V2MergeStrategy) -> MergeStrategy { + match m { + V2MergeStrategy::Squash => MergeStrategy::Squash, + V2MergeStrategy::Merge => MergeStrategy::Merge, + V2MergeStrategy::Rebase => MergeStrategy::Rebase, + } +} + +#[must_use] +pub fn bridge_pull_request(pr: &RunPullRequestLayer) -> PullRequestSettings { + PullRequestSettings { + enabled: pr.enabled.unwrap_or(false), + draft: pr.draft.unwrap_or(true), + auto_merge: pr.auto_merge.unwrap_or(false), + merge_strategy: pr + .merge_strategy + .map(bridge_merge_strategy) + .unwrap_or_default(), + } +} + +#[must_use] +pub fn bridge_run_artifacts(artifacts: &RunArtifactsLayer) -> ArtifactsSettings { + ArtifactsSettings { + include: artifacts.include.clone(), + } +} diff --git a/lib/crates/fabro-workflow/src/lib.rs b/lib/crates/fabro-workflow/src/lib.rs index 76e2311a1..270ce6d9d 100644 --- a/lib/crates/fabro-workflow/src/lib.rs +++ b/lib/crates/fabro-workflow/src/lib.rs @@ -116,6 +116,7 @@ pub mod artifact; pub mod artifact_snapshot; pub mod artifact_upload; pub(crate) mod condition; +pub mod config; pub mod context; pub mod devcontainer_bridge; pub mod error; diff --git a/lib/crates/fabro-workflow/src/operations/start.rs b/lib/crates/fabro-workflow/src/operations/start.rs index 55a2c140e..9d120e679 100644 --- a/lib/crates/fabro-workflow/src/operations/start.rs +++ b/lib/crates/fabro-workflow/src/operations/start.rs @@ -15,9 +15,10 @@ use fabro_sandbox::config::{ use fabro_sandbox::{SandboxProvider, SandboxSpec}; use fabro_types::RunId; use fabro_types::settings::v2::run::ModelRefOrSplice; -use fabro_types::settings::v2::to_runtime::bridge_pull_request; use fabro_types::settings::v2::{InterpString, SettingsFile}; +use crate::config::{PullRequestSettings, bridge_pull_request}; + use crate::artifact_upload::ArtifactSink; use crate::context::Context; use crate::error::FabroError; @@ -41,7 +42,6 @@ use crate::workflow_bundle::{RunDefinition, WorkflowBundle}; use fabro_retro::retro::Retro; use fabro_sandbox::daytona::DaytonaConfig; use fabro_sandbox::daytona::detect_repo_info; -use fabro_types::settings::run::PullRequestSettings; use tokio::runtime::Handle; struct RunSession { diff --git a/lib/crates/fabro-workflow/src/pipeline/pull_request.rs b/lib/crates/fabro-workflow/src/pipeline/pull_request.rs index 21eb8d84d..97ed9aaff 100644 --- a/lib/crates/fabro-workflow/src/pipeline/pull_request.rs +++ b/lib/crates/fabro-workflow/src/pipeline/pull_request.rs @@ -1,6 +1,6 @@ +use crate::config::MergeStrategy; use fabro_store::RunProjection; use fabro_types::PullRequestRecord; -use fabro_types::settings::run::MergeStrategy; use tracing::{debug, info}; use fabro_github::{self as github_app, GitHubAppCredentials, ssh_url_to_https}; diff --git a/lib/crates/fabro-workflow/src/pipeline/types.rs b/lib/crates/fabro-workflow/src/pipeline/types.rs index 883093f28..653a9fc12 100644 --- a/lib/crates/fabro-workflow/src/pipeline/types.rs +++ b/lib/crates/fabro-workflow/src/pipeline/types.rs @@ -15,6 +15,7 @@ use fabro_types::RunId; use fabro_validate::Diagnostic; use crate::artifact_upload::ArtifactSink; +use crate::config::PullRequestSettings; use crate::context::Context; use crate::error::FabroError; use crate::event::Emitter; @@ -29,7 +30,6 @@ use crate::transforms::Transform; use crate::workflow_bundle::WorkflowBundle; use fabro_llm::client::Client; use fabro_retro::retro::Retro; -use fabro_types::settings::run::PullRequestSettings; use fabro_validate::Severity; /// Output of the PARSE phase. From 3ac7ab9035a1489159b32c74fc3173771465d791 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 9 Apr 2026 18:27:46 -0400 Subject: [PATCH 34/47] refactor(settings): stage 6.3b shrink server runtime types + delete Combine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prunes `fabro-types/src/settings/server.rs` down to just the three types that still have live consumers: - `ApiAuthStrategy` — used by `fabro-server::jwt_auth::resolve_auth_mode_with_lookup` - `TlsSettings` — used by `fabro-server::tls::*` and the mTLS integration test - `ApiSettings` — the shim struct built by `fabro-server::serve::build_legacy_api_settings` so the pre-v2 `resolve_auth_mode_with_lookup` signature still compiles Deletes the rest as dead code (all unreferenced in the workspace): `AuthProvider`, `AuthSettings`, `GitProvider`, `GitSettings`, `GitAuthorSettings`, `WebSettings`, `WebhookSettings`, `WebhookStrategy`, `SlackSettings`, `FeaturesSettings`, `LogSettings`, `ArtifactStorageBackend`, `ArtifactStorageSettings`. Trims the `ApiSettings` struct itself to just the two fields the auth resolver reads; drops the never-used `base_url` field and the `build_legacy_api_settings` lines that were computing it. Drops `pub use settings::{ArtifactStorageBackend, ArtifactStorageSettings}` from `fabro-types/src/lib.rs`. Also deletes the dead `Combine` trait machinery alongside its only remaining consumers: - `lib/crates/fabro-types/src/combine.rs` — deleted. - `pub mod combine;` / `pub use fabro_macros::Combine;` removed from `fabro-types/src/lib.rs`. - `#[proc_macro_derive(Combine)] fn derive_combine` — deleted from `fabro-macros/src/lib.rs` along with its `syn::{Data, DeriveInput, Fields}` imports. The `e2e_test` proc-macro is untouched. The seven legacy runtime type modules (`hook`, `mcp`, `project`, `run`, `sandbox`, `user`, plus now the bulk of `server`) are effectively all gone. Only a tiny `server.rs` remains as a transitional home for the three auth-resolver types until Stage 6.6g rewrites `resolve_auth_mode_with_lookup` to walk the v2 `server.auth.api` subtree directly. 3,758 workspace tests pass. `cargo fmt --check --all` and `cargo clippy --workspace -- -D warnings` are clean. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/crates/fabro-macros/src/lib.rs | 53 +----- lib/crates/fabro-server/src/serve.rs | 9 - lib/crates/fabro-types/src/combine.rs | 61 ------- lib/crates/fabro-types/src/lib.rs | 3 - lib/crates/fabro-types/src/settings/mod.rs | 6 +- lib/crates/fabro-types/src/settings/server.rs | 172 ++---------------- 6 files changed, 17 insertions(+), 287 deletions(-) delete mode 100644 lib/crates/fabro-types/src/combine.rs diff --git a/lib/crates/fabro-macros/src/lib.rs b/lib/crates/fabro-macros/src/lib.rs index 2ca0fbfce..2d1344ab6 100644 --- a/lib/crates/fabro-macros/src/lib.rs +++ b/lib/crates/fabro-macros/src/lib.rs @@ -2,9 +2,7 @@ use proc_macro::TokenStream; use quote::quote; use syn::parse::{Parse, ParseStream}; use syn::punctuated::Punctuated; -use syn::{ - Data, DeriveInput, Fields, Ident, ItemFn, LitStr, Token, parenthesized, parse_macro_input, -}; +use syn::{Ident, ItemFn, LitStr, Token, parenthesized, parse_macro_input}; enum E2eRequirement { Twin, @@ -33,55 +31,6 @@ impl Parse for E2eRequirement { } } -#[proc_macro_derive(Combine)] -pub fn derive_combine(input: TokenStream) -> TokenStream { - let input = parse_macro_input!(input as DeriveInput); - let ident = input.ident; - let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); - - let body = match input.data { - Data::Struct(data) => match data.fields { - Fields::Named(fields) => { - let combined = fields.named.into_iter().map(|field| { - let ident = field.ident.expect("named field"); - quote! { - #ident: ::fabro_types::combine::Combine::combine(self.#ident, other.#ident) - } - }); - quote! { - Self { - #(#combined,)* - } - } - } - Fields::Unnamed(fields) => { - let combined = fields.unnamed.iter().enumerate().map(|(index, _)| { - let index = syn::Index::from(index); - quote! { - ::fabro_types::combine::Combine::combine(self.#index, other.#index) - } - }); - quote! { - Self(#(#combined),*) - } - } - Fields::Unit => quote!(Self), - }, - Data::Enum(_) | Data::Union(_) => { - quote!(self) - } - }; - - quote! { - impl #impl_generics ::fabro_types::combine::Combine for #ident #ty_generics #where_clause { - fn combine(self, other: Self) -> Self { - #body - } - } - } - .into() -} - #[proc_macro_attribute] pub fn e2e_test(attr: TokenStream, item: TokenStream) -> TokenStream { let requirements = diff --git a/lib/crates/fabro-server/src/serve.rs b/lib/crates/fabro-server/src/serve.rs index df6d75322..99806c727 100644 --- a/lib/crates/fabro-server/src/serve.rs +++ b/lib/crates/fabro-server/src/serve.rs @@ -116,14 +116,6 @@ fn build_legacy_api_settings(file: &SettingsFile) -> ApiSettings { authentication_strategies.push(ApiAuthStrategy::Mtls); } - let base_url = file - .server_api() - .and_then(|api| api.url.as_ref()) - .map_or_else( - || "http://localhost:3000/api/v1".to_string(), - InterpString::as_source, - ); - // TLS files now live under `server.listen.tls.{cert,key,ca}` in v2. // Build a legacy TlsSettings from the listen TLS subtree so the // existing rustls config path keeps working. @@ -147,7 +139,6 @@ fn build_legacy_api_settings(file: &SettingsFile) -> ApiSettings { }); ApiSettings { - base_url, authentication_strategies, tls, } diff --git a/lib/crates/fabro-types/src/combine.rs b/lib/crates/fabro-types/src/combine.rs deleted file mode 100644 index 6014cb957..000000000 --- a/lib/crates/fabro-types/src/combine.rs +++ /dev/null @@ -1,61 +0,0 @@ -use std::collections::HashMap; -use std::hash::Hash; -use std::path::PathBuf; - -pub trait Combine { - #[must_use] - fn combine(self, other: Self) -> Self; -} - -impl Combine for Option { - fn combine(self, other: Self) -> Self { - match (self, other) { - (Some(this), Some(other)) => Some(this.combine(other)), - (Some(this), None) => Some(this), - (None, Some(other)) => Some(other), - (None, None) => None, - } - } -} - -impl Combine for Vec { - fn combine(mut self, other: Self) -> Self { - self.extend(other); - self - } -} - -impl Combine for HashMap -where - K: Eq + Hash, - V: Combine, -{ - fn combine(mut self, other: Self) -> Self { - for (key, value) in other { - match self.remove(&key) { - Some(existing) => { - self.insert(key, existing.combine(value)); - } - None => { - self.insert(key, value); - } - } - } - - self - } -} - -macro_rules! impl_left_wins { - ($($ty:ty),* $(,)?) => { - $( - impl Combine for $ty { - fn combine(self, _other: Self) -> Self { - self - } - } - )* - }; -} - -impl_left_wins!(bool, i32, u16, u32, u64, usize, String, PathBuf,); diff --git a/lib/crates/fabro-types/src/lib.rs b/lib/crates/fabro-types/src/lib.rs index 3f00da289..7b522f786 100644 --- a/lib/crates/fabro-types/src/lib.rs +++ b/lib/crates/fabro-types/src/lib.rs @@ -3,7 +3,6 @@ extern crate self as fabro_types; pub mod billing; pub mod blob_ref; pub mod checkpoint; -pub mod combine; pub mod conclusion; pub mod failure_signature; pub mod graph; @@ -33,7 +32,6 @@ pub use blob_ref::{ }; pub use checkpoint::Checkpoint; pub use conclusion::{Conclusion, StageSummary}; -pub use fabro_macros::Combine; pub use failure_signature::FailureSignature; pub use graph::{AttrValue, Edge, Graph, Node, is_llm_handler_type, shape_to_handler_type}; pub use interview::{InterviewQuestionRecord, InterviewQuestionType}; @@ -53,7 +51,6 @@ pub use run_event::{EventBody, RunEvent, RunNoticeLevel}; pub use run_id::RunId; pub use run_id::fixtures; pub use sandbox_record::SandboxRecord; -pub use settings::{ArtifactStorageBackend, ArtifactStorageSettings}; pub use stage_id::StageId; pub use start::StartRecord; pub use status::{ diff --git a/lib/crates/fabro-types/src/settings/mod.rs b/lib/crates/fabro-types/src/settings/mod.rs index de5da8e98..38003a3ad 100644 --- a/lib/crates/fabro-types/src/settings/mod.rs +++ b/lib/crates/fabro-types/src/settings/mod.rs @@ -22,11 +22,7 @@ pub mod server; pub mod v2; -pub use server::{ - ApiAuthStrategy, ApiSettings, ArtifactStorageBackend, ArtifactStorageSettings, AuthProvider, - AuthSettings, FeaturesSettings, GitAuthorSettings, GitProvider, GitSettings, LogSettings, - SlackSettings, TlsSettings, WebSettings, WebhookSettings, WebhookStrategy, -}; +pub use server::{ApiAuthStrategy, ApiSettings, TlsSettings}; // v2 top-level re-exports. Stage 6.5 of the settings TOML redesign // promoted the v2 namespaced parse tree to be the primary API surface; // consumers can now write `fabro_types::settings::SettingsFile` / diff --git a/lib/crates/fabro-types/src/settings/server.rs b/lib/crates/fabro-types/src/settings/server.rs index b1f006932..7562290ff 100644 --- a/lib/crates/fabro-types/src/settings/server.rs +++ b/lib/crates/fabro-types/src/settings/server.rs @@ -1,34 +1,24 @@ +//! Transitional server-domain runtime types. +//! +//! Only the three types that the auth resolver (`jwt_auth.rs`) and the +//! TLS loader (`tls.rs`) still consume remain here. Stage 6.6g will +//! rewrite those consumers to walk the v2 `server.auth.api` / `server.listen.tls` +//! subtrees directly, at which point this file goes away and the +//! legacy runtime type module tree is fully gone. + use std::path::PathBuf; use serde::{Deserialize, Serialize}; -fn default_artifact_storage_prefix() -> String { - "artifacts".to_string() -} - -#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize, crate::Combine)] -#[serde(rename_all = "snake_case")] -pub enum AuthProvider { - #[default] - Github, - InsecureDisabled, -} - -#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize)] -pub struct AuthSettings { - #[serde(default)] - pub provider: AuthProvider, - #[serde(default)] - pub allowed_usernames: Vec, -} - -#[derive(Debug, Clone, Deserialize, PartialEq, Serialize, crate::Combine)] +/// Authentication strategy flag consumed by `resolve_auth_mode_with_lookup`. +#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)] #[serde(rename_all = "snake_case")] pub enum ApiAuthStrategy { Jwt, Mtls, } +/// mTLS material loaded by `fabro_server::tls::*`. #[derive(Debug, Clone, Deserialize, PartialEq, Serialize)] pub struct TlsSettings { pub cert: PathBuf, @@ -36,144 +26,12 @@ pub struct TlsSettings { pub ca: PathBuf, } -#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)] +/// Shim `ApiSettings` that `fabro_server::serve::build_legacy_api_settings` +/// projects out of the v2 tree so the pre-v2 `resolve_auth_mode_with_lookup` +/// signature keeps working until Stage 6.6g rewrites it. +#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize)] pub struct ApiSettings { - #[serde(default = "default_base_url")] - pub base_url: String, #[serde(default)] pub authentication_strategies: Vec, pub tls: Option, } - -fn default_base_url() -> String { - "http://localhost:3000/api/v1".to_string() -} - -impl Default for ApiSettings { - fn default() -> Self { - Self { - base_url: default_base_url(), - authentication_strategies: Vec::new(), - tls: None, - } - } -} - -#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize, crate::Combine)] -#[serde(rename_all = "snake_case")] -pub enum GitProvider { - #[default] - Github, -} - -#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize)] -pub struct GitAuthorSettings { - pub name: Option, - pub email: Option, -} - -#[derive(Debug, Clone, Deserialize, PartialEq, Serialize, crate::Combine)] -#[serde(rename_all = "snake_case")] -pub enum WebhookStrategy { - TailscaleFunnel, -} - -#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)] -pub struct WebhookSettings { - pub strategy: WebhookStrategy, -} - -#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize)] -pub struct GitSettings { - #[serde(default)] - pub provider: GitProvider, - pub app_id: Option, - pub client_id: Option, - pub slug: Option, - #[serde(default)] - pub author: GitAuthorSettings, - pub webhooks: Option, -} - -#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)] -pub struct WebSettings { - #[serde(default = "default_web_enabled")] - pub enabled: bool, - #[serde(default = "default_web_url")] - pub url: String, - #[serde(default)] - pub auth: AuthSettings, -} - -fn default_web_enabled() -> bool { - true -} - -fn default_web_url() -> String { - "http://localhost:3000".to_string() -} - -impl Default for WebSettings { - fn default() -> Self { - Self { - enabled: default_web_enabled(), - url: default_web_url(), - auth: AuthSettings::default(), - } - } -} - -#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize, crate::Combine)] -pub struct SlackSettings { - pub default_channel: Option, -} - -#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize)] -pub struct FeaturesSettings { - #[serde(default)] - pub session_sandboxes: bool, - #[serde(default)] - pub retros: bool, -} - -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] -pub struct LogSettings { - pub level: Option, -} - -#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize, crate::Combine)] -#[serde(rename_all = "snake_case")] -pub enum ArtifactStorageBackend { - #[default] - Local, - S3, -} - -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, crate::Combine)] -pub struct ArtifactStorageSettings { - #[serde(default)] - pub backend: ArtifactStorageBackend, - #[serde(default = "default_artifact_storage_prefix")] - pub prefix: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub bucket: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub region: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub endpoint: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub path_style: Option, -} - -impl Default for ArtifactStorageSettings { - fn default() -> Self { - Self { - backend: ArtifactStorageBackend::Local, - prefix: default_artifact_storage_prefix(), - bucket: None, - region: None, - endpoint: None, - path_style: None, - } - } -} From 15b799fb3f33689ea6aec222892aaf6f55217888 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 9 Apr 2026 18:32:54 -0400 Subject: [PATCH 35/47] =?UTF-8?q?refactor(settings):=20stage=206.3b=20+=20?= =?UTF-8?q?6.5b=20finish=20=E2=80=94=20delete=20last=20legacy=20server=20t?= =?UTF-8?q?ypes=20and=20flatten=20v2/?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **6.3b finishing touch:** relocates the last three transitional server runtime types (`ApiAuthStrategy`, `TlsSettings`, `ApiSettings`) out of `fabro-types` into `fabro-server/src/jwt_auth.rs` — the only crate that consumes them. `serve.rs`, `tls.rs`, and the mTLS integration test now import from `crate::jwt_auth` / `fabro_server::jwt_auth` instead of `fabro_types::settings::server`. `lib/crates/fabro-types/src/settings/server.rs` (the legacy one) and the `pub mod server_config { pub use fabro_types::settings::server::*; }` block in `fabro-server/src/lib.rs` are both deleted. The legacy runtime type module tree under `fabro-types/src/settings/{hook, mcp, project, run, sandbox, server, user}.rs` is now fully gone — nothing left to promote. **6.5b flatten:** `git mv` the fourteen v2 modules up one directory: - `settings/v2/accessors.rs` → `settings/accessors.rs` - `settings/v2/cli.rs` → `settings/cli.rs` - `settings/v2/duration.rs` → `settings/duration.rs` - `settings/v2/features.rs` → `settings/features.rs` - `settings/v2/interp.rs` → `settings/interp.rs` - `settings/v2/model_ref.rs` → `settings/model_ref.rs` - `settings/v2/project.rs` → `settings/project.rs` - `settings/v2/run.rs` → `settings/run.rs` - `settings/v2/server.rs` → `settings/server.rs` (name no longer collides with the deleted legacy `server.rs`) - `settings/v2/size.rs` → `settings/size.rs` - `settings/v2/splice_array.rs` → `settings/splice_array.rs` - `settings/v2/tree.rs` → `settings/tree.rs` - `settings/v2/version.rs` → `settings/version.rs` - `settings/v2/workflow.rs` → `settings/workflow.rs` - `settings/v2/mod.rs` — deleted (its `pub mod` / `pub use` block moved into `settings/mod.rs`). `settings/mod.rs` picks up those `pub mod` declarations and the accompanying `pub use ::*` re-exports, plus a transitional `pub mod v2 { pub use super::*; }` alias so that existing `fabro_types::settings::v2::*` import paths across the workspace keep compiling. A follow-up sweep will drop the `::v2::` prefix from every consumer and then the alias can go away. 3,758 workspace tests pass. `cargo fmt --check --all` and `cargo clippy --workspace -- -D warnings` are clean. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/crates/fabro-server/src/jwt_auth.rs | 37 +- lib/crates/fabro-server/src/lib.rs | 3 - lib/crates/fabro-server/src/serve.rs | 4 +- lib/crates/fabro-server/src/tls.rs | 4 +- lib/crates/fabro-server/tests/it/api/mtls.rs | 3 +- .../src/settings/{v2 => }/accessors.rs | 0 .../fabro-types/src/settings/{v2 => }/cli.rs | 0 .../src/settings/{v2 => }/duration.rs | 0 .../src/settings/{v2 => }/features.rs | 0 .../src/settings/{v2 => }/interp.rs | 0 lib/crates/fabro-types/src/settings/mod.rs | 76 ++-- .../src/settings/{v2 => }/model_ref.rs | 0 .../src/settings/{v2 => }/project.rs | 0 .../fabro-types/src/settings/{v2 => }/run.rs | 0 lib/crates/fabro-types/src/settings/server.rs | 346 ++++++++++++++++-- .../fabro-types/src/settings/{v2 => }/size.rs | 0 .../src/settings/{v2 => }/splice_array.rs | 0 .../fabro-types/src/settings/{v2 => }/tree.rs | 0 lib/crates/fabro-types/src/settings/v2/mod.rs | 38 -- .../fabro-types/src/settings/v2/server.rs | 323 ---------------- .../src/settings/{v2 => }/version.rs | 0 .../src/settings/{v2 => }/workflow.rs | 0 22 files changed, 395 insertions(+), 439 deletions(-) rename lib/crates/fabro-types/src/settings/{v2 => }/accessors.rs (100%) rename lib/crates/fabro-types/src/settings/{v2 => }/cli.rs (100%) rename lib/crates/fabro-types/src/settings/{v2 => }/duration.rs (100%) rename lib/crates/fabro-types/src/settings/{v2 => }/features.rs (100%) rename lib/crates/fabro-types/src/settings/{v2 => }/interp.rs (100%) rename lib/crates/fabro-types/src/settings/{v2 => }/model_ref.rs (100%) rename lib/crates/fabro-types/src/settings/{v2 => }/project.rs (100%) rename lib/crates/fabro-types/src/settings/{v2 => }/run.rs (100%) rename lib/crates/fabro-types/src/settings/{v2 => }/size.rs (100%) rename lib/crates/fabro-types/src/settings/{v2 => }/splice_array.rs (100%) rename lib/crates/fabro-types/src/settings/{v2 => }/tree.rs (100%) delete mode 100644 lib/crates/fabro-types/src/settings/v2/mod.rs delete mode 100644 lib/crates/fabro-types/src/settings/v2/server.rs rename lib/crates/fabro-types/src/settings/{v2 => }/version.rs (100%) rename lib/crates/fabro-types/src/settings/{v2 => }/workflow.rs (100%) diff --git a/lib/crates/fabro-server/src/jwt_auth.rs b/lib/crates/fabro-server/src/jwt_auth.rs index 698b1a06d..f1589f311 100644 --- a/lib/crates/fabro-server/src/jwt_auth.rs +++ b/lib/crates/fabro-server/src/jwt_auth.rs @@ -1,3 +1,4 @@ +use std::path::PathBuf; use std::sync::Arc; use axum::extract::FromRequestParts; @@ -5,13 +6,43 @@ use axum::http::request::Parts; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use jsonwebtoken::{Algorithm, DecodingKey, Validation}; use rustls_pki_types::CertificateDer; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use tracing::warn; use crate::error::ApiError; use crate::web_auth::SessionCookie; use fabro_types::RunAuthMethod; -use fabro_types::settings::server::ApiSettings; + +/// Authentication strategy flag consumed by `resolve_auth_mode_with_lookup`. +/// +/// Projected out of the v2 `server.auth.api.{jwt,mtls}` subtree by +/// `serve::build_legacy_api_settings`. Stage 6.6g will delete this shim +/// and walk the v2 tree directly in the auth resolver. +#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ApiAuthStrategy { + Jwt, + Mtls, +} + +/// mTLS material loaded from `[server.listen.tls]`. Consumed by the +/// `tls.rs` rustls config builder. +#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)] +pub struct TlsSettings { + pub cert: PathBuf, + pub key: PathBuf, + pub ca: PathBuf, +} + +/// Shim `ApiSettings` that `serve::build_legacy_api_settings` projects out +/// of the v2 tree so the pre-v2 [`resolve_auth_mode_with_lookup`] signature +/// keeps working until Stage 6.6g rewrites it. +#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize)] +pub struct ApiSettings { + #[serde(default)] + pub authentication_strategies: Vec, + pub tls: Option, +} /// JWT claims for service-to-service authentication. #[derive(Debug, Deserialize)] @@ -86,8 +117,6 @@ pub fn resolve_auth_mode_with_lookup( where F: Fn(&str) -> Option, { - use fabro_types::settings::server::ApiAuthStrategy; - if api_settings.authentication_strategies.is_empty() && std::env::var("FABRO_LOCAL_NO_AUTH").ok().as_deref() == Some("1") { diff --git a/lib/crates/fabro-server/src/lib.rs b/lib/crates/fabro-server/src/lib.rs index 4a7da2c01..507c77f2a 100644 --- a/lib/crates/fabro-server/src/lib.rs +++ b/lib/crates/fabro-server/src/lib.rs @@ -16,8 +16,5 @@ pub mod serve; pub mod server; mod settings_view; pub mod static_files; -pub mod server_config { - pub use fabro_types::settings::server::*; -} pub mod tls; pub mod web_auth; diff --git a/lib/crates/fabro-server/src/serve.rs b/lib/crates/fabro-server/src/serve.rs index 99806c727..65904f1e1 100644 --- a/lib/crates/fabro-server/src/serve.rs +++ b/lib/crates/fabro-server/src/serve.rs @@ -2,10 +2,10 @@ use std::path::{Path, PathBuf}; use std::sync::{Arc, RwLock}; use std::time::Duration; +use crate::jwt_auth::ApiSettings; use fabro_config::Storage; use fabro_config::resolve_storage_dir; use fabro_config::user::{active_settings_path, load_settings_config}; -use fabro_types::settings::server::ApiSettings; use fabro_util::terminal::Styles; use object_store::ObjectStore; use object_store::aws::AmazonS3Builder; @@ -90,7 +90,7 @@ fn load_settings(path: Option<&Path>) -> anyhow::Result { /// v2 tree. Stage 6.6 replaces this with a v2-aware auth resolver and drops /// the legacy `ApiSettings` type entirely. fn build_legacy_api_settings(file: &SettingsFile) -> ApiSettings { - use fabro_types::settings::server::{ApiAuthStrategy, TlsSettings}; + use crate::jwt_auth::{ApiAuthStrategy, TlsSettings}; use fabro_types::settings::v2::interp::InterpString; use fabro_types::settings::v2::server::ServerListenLayer; diff --git a/lib/crates/fabro-server/src/tls.rs b/lib/crates/fabro-server/src/tls.rs index 0009253ca..ff908b3fd 100644 --- a/lib/crates/fabro-server/src/tls.rs +++ b/lib/crates/fabro-server/src/tls.rs @@ -8,9 +8,7 @@ use rustls_pki_types::{CertificateDer, PrivateKeyDer}; use tokio::net::TcpListener; use tracing::error; -use fabro_types::settings::server::TlsSettings; - -use crate::jwt_auth::PeerCertificates; +use crate::jwt_auth::{PeerCertificates, TlsSettings}; /// How client certificates should be verified. #[derive(Clone, Copy)] diff --git a/lib/crates/fabro-server/tests/it/api/mtls.rs b/lib/crates/fabro-server/tests/it/api/mtls.rs index 9ae4ade9f..232cc545c 100644 --- a/lib/crates/fabro-server/tests/it/api/mtls.rs +++ b/lib/crates/fabro-server/tests/it/api/mtls.rs @@ -4,9 +4,8 @@ use crate::helpers::api; use std::path::{Path, PathBuf}; use std::sync::Arc; -use fabro_server::jwt_auth::{AuthMode, AuthStrategy}; +use fabro_server::jwt_auth::{AuthMode, AuthStrategy, TlsSettings}; use fabro_server::server::{build_router, create_app_state}; -use fabro_server::server_config::TlsSettings; use fabro_server::tls::{ClientAuth, build_rustls_config}; use tokio::net::TcpListener; diff --git a/lib/crates/fabro-types/src/settings/v2/accessors.rs b/lib/crates/fabro-types/src/settings/accessors.rs similarity index 100% rename from lib/crates/fabro-types/src/settings/v2/accessors.rs rename to lib/crates/fabro-types/src/settings/accessors.rs diff --git a/lib/crates/fabro-types/src/settings/v2/cli.rs b/lib/crates/fabro-types/src/settings/cli.rs similarity index 100% rename from lib/crates/fabro-types/src/settings/v2/cli.rs rename to lib/crates/fabro-types/src/settings/cli.rs diff --git a/lib/crates/fabro-types/src/settings/v2/duration.rs b/lib/crates/fabro-types/src/settings/duration.rs similarity index 100% rename from lib/crates/fabro-types/src/settings/v2/duration.rs rename to lib/crates/fabro-types/src/settings/duration.rs diff --git a/lib/crates/fabro-types/src/settings/v2/features.rs b/lib/crates/fabro-types/src/settings/features.rs similarity index 100% rename from lib/crates/fabro-types/src/settings/v2/features.rs rename to lib/crates/fabro-types/src/settings/features.rs diff --git a/lib/crates/fabro-types/src/settings/v2/interp.rs b/lib/crates/fabro-types/src/settings/interp.rs similarity index 100% rename from lib/crates/fabro-types/src/settings/v2/interp.rs rename to lib/crates/fabro-types/src/settings/interp.rs diff --git a/lib/crates/fabro-types/src/settings/mod.rs b/lib/crates/fabro-types/src/settings/mod.rs index 38003a3ad..016922ca8 100644 --- a/lib/crates/fabro-types/src/settings/mod.rs +++ b/lib/crates/fabro-types/src/settings/mod.rs @@ -1,40 +1,48 @@ -//! v2 namespaced config schema plus transitional runtime shapes. +//! Namespaced settings schema. //! -//! The authoritative config schema lives in [`v2`] — it is the namespaced -//! parse tree that `_version = 1` TOML files decode into. Value-language -//! helpers, the merge matrix, and strict unknown-key validation all live -//! there. +//! Top-level schema is strictly namespaced with `_version`, `[project]`, +//! `[workflow]`, `[run]`, `[cli]`, `[server]`, and `[features]`. +//! Value-language helpers live alongside the tree: durations, byte sizes, +//! model references, env interpolation, and splice-capable arrays. //! -//! The submodules `hook`, `mcp`, `project`, `run`, `sandbox`, `server`, -//! and `user` still hold **runtime shapes** that downstream crates -//! (fabro-workflow, fabro-sandbox, fabro-mcp, fabro-hooks) consume at -//! execution time. Stage 6.1 deleted the flat `Settings` parse path; -//! Stage 6.2 deleted the `bridge_to_old` catch-all converter; Stage 6.3b -//! deleted the legacy flat `Settings` struct itself, its inherent -//! helpers, and its `Combine`-driven layering. Narrow v2→runtime helpers -//! live in [`v2::to_runtime`] and build these runtime shapes from -//! specific v2 subtrees on demand. -//! -//! A follow-up pass will either promote these runtime shapes into their -//! owning consumer crates or replace their call sites with v2-native -//! accessors, at which point this module goes away. +//! Stage 6.5b promoted these modules up out of the transitional +//! `settings/v2/` subdirectory, so the `::v2::` path prefix no longer +//! exists. +pub mod accessors; +pub mod cli; +pub mod duration; +pub mod features; +pub mod interp; +pub mod model_ref; +pub mod project; +pub mod run; pub mod server; -pub mod v2; +pub mod size; +pub mod splice_array; +pub mod tree; +pub mod version; +pub mod workflow; -pub use server::{ApiAuthStrategy, ApiSettings, TlsSettings}; -// v2 top-level re-exports. Stage 6.5 of the settings TOML redesign -// promoted the v2 namespaced parse tree to be the primary API surface; -// consumers can now write `fabro_types::settings::SettingsFile` / -// `fabro_types::settings::InterpString` / `fabro_types::settings::Duration` -// without the `::v2::` prefix. The `v2` module itself stays until the -// remaining legacy files under `settings/{project,run,server,...}.rs` -// are deleted in a follow-up pass, because the v2 submodules and the -// legacy submodules share those file names. -pub use v2::{ - CURRENT_VERSION, CliLayer, Duration, FeaturesLayer, InterpString, ModelRef, ParseDurationError, - ParseError, ParseModelRefError, ParseSizeError, ProjectLayer, Provenance, ResolveEnvError, - Resolved, ResolvedModelRef, RunLayer, SchemaVersion, ServerLayer, SettingsFile, Size, - SpliceArray, SpliceArrayError, VersionError, WorkflowLayer, parse_settings_file, - validate_version, +pub use cli::CliLayer; +pub use duration::{Duration, ParseDurationError}; +pub use features::FeaturesLayer; +pub use interp::{InterpString, Provenance, ResolveEnvError, Resolved}; +pub use model_ref::{ + AmbiguousModelRef, ModelRef, ModelRegistry, ParseModelRefError, ResolvedModelRef, }; +pub use project::ProjectLayer; +pub use run::RunLayer; +pub use server::ServerLayer; +pub use size::{ParseSizeError, Size}; +pub use splice_array::{SPLICE_MARKER, SpliceArray, SpliceArrayError}; +pub use tree::{ParseError, SettingsFile, parse_settings_file}; +pub use version::{CURRENT_VERSION, SchemaVersion, VersionError, validate_version}; +pub use workflow::WorkflowLayer; + +/// Transitional alias for code still using `fabro_types::settings::v2::*` +/// paths. The whole `v2` namespace is scheduled for removal once the +/// workspace-wide sweep (Stage 6.5b follow-up) is done. +pub mod v2 { + pub use super::*; +} diff --git a/lib/crates/fabro-types/src/settings/v2/model_ref.rs b/lib/crates/fabro-types/src/settings/model_ref.rs similarity index 100% rename from lib/crates/fabro-types/src/settings/v2/model_ref.rs rename to lib/crates/fabro-types/src/settings/model_ref.rs diff --git a/lib/crates/fabro-types/src/settings/v2/project.rs b/lib/crates/fabro-types/src/settings/project.rs similarity index 100% rename from lib/crates/fabro-types/src/settings/v2/project.rs rename to lib/crates/fabro-types/src/settings/project.rs diff --git a/lib/crates/fabro-types/src/settings/v2/run.rs b/lib/crates/fabro-types/src/settings/run.rs similarity index 100% rename from lib/crates/fabro-types/src/settings/v2/run.rs rename to lib/crates/fabro-types/src/settings/run.rs diff --git a/lib/crates/fabro-types/src/settings/server.rs b/lib/crates/fabro-types/src/settings/server.rs index 7562290ff..c1931c620 100644 --- a/lib/crates/fabro-types/src/settings/server.rs +++ b/lib/crates/fabro-types/src/settings/server.rs @@ -1,37 +1,323 @@ -//! Transitional server-domain runtime types. +//! Server domain. //! -//! Only the three types that the auth resolver (`jwt_auth.rs`) and the -//! TLS loader (`tls.rs`) still consume remain here. Stage 6.6g will -//! rewrite those consumers to walk the v2 `server.auth.api` / `server.listen.tls` -//! subtrees directly, at which point this file goes away and the -//! legacy runtime type module tree is fully gone. +//! `[server]` is a namespace container; actual settings live in named +//! subdomains (listen, api, web, auth, storage, artifacts, slatedb, +//! scheduler, logging, integrations). Same-host and split-host deployments +//! use the same schema. -use std::path::PathBuf; +use std::collections::HashMap; use serde::{Deserialize, Serialize}; -/// Authentication strategy flag consumed by `resolve_auth_mode_with_lookup`. -#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)] +use super::duration::Duration; +use super::interp::InterpString; + +/// A sparse `[server]` layer as it appears in a single settings file. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub listen: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub api: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub web: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auth: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub storage: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub artifacts: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub slatedb: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scheduler: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub logging: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub integrations: Option, +} + +/// `[server.listen]` — shared bind transport. TLS lives under `[server.listen.tls]`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, tag = "type", rename_all = "lowercase")] +pub enum ServerListenLayer { + Tcp { + #[serde(default)] + address: Option, + #[serde(default)] + tls: Option, + }, + Unix { + #[serde(default)] + path: Option, + }, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerListenTlsLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cert: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ca: Option, +} + +/// `[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, +} + +/// `[server.web]` — web surface settings. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerWebLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub url: Option, +} + +/// `[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)] +#[serde(deny_unknown_fields)] +pub struct ServerAuthLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub api: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub web: Option, +} + +/// `[server.auth.api]` — supports multiple strategies concurrently. Each +/// strategy is a named subtable: `[server.auth.api.jwt]`, `[server.auth.api.mtls]`. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerAuthApiLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub jwt: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mtls: Option, +} + +/// `[server.auth.api.jwt]` — JWT auth strategy fields. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerAuthApiJwtLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub issuer: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub audience: Option, +} + +/// `[server.auth.api.mtls]` — mutual TLS auth strategy fields. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerAuthApiMtlsLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ca: Option, +} + +/// `[server.auth.web]` — provider-neutral access rules plus keyed providers. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerAuthWebLayer { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub allowed_usernames: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub providers: Option, +} + +/// `[server.auth.web.providers.]` — web auth providers keyed by +/// provider name. First-pass providers cover GitHub. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerAuthWebProvidersLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub github: Option, +} + +/// `[server.auth.web.providers.github]` — GitHub OAuth configuration fields. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerAuthWebGithubLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client_secret: Option, +} + +/// `[server.storage]` — single managed local disk root. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerStorageLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub root: Option, +} + +/// `[server.artifacts]` — object-store-backed artifact storage. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerArtifactsLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prefix: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub local: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub s3: Option, +} + +/// `[server.slatedb]` — SlateDB bottomless storage plus tunables. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerSlateDbLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prefix: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub flush_interval: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub local: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub s3: Option, +} + +/// Closed enum of object-store providers. Unknown providers hard-fail +/// against the schema rather than passing through as opaque strings. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ObjectStoreProvider { + Local, + S3, +} + +#[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`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub root: Option, +} + +#[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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub region: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub endpoint: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path_style: Option, +} + +/// `[server.scheduler]` — server-managed execution policy. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerSchedulerLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_concurrent_runs: Option, +} + +/// `[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, +} + +/// `[server.integrations.]` — 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)] +#[serde(deny_unknown_fields)] +pub struct ServerIntegrationsLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub github: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub slack: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub discord: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub teams: Option, +} + +/// `[server.integrations.github]` — GitHub App, credentials, and inbound webhooks. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GithubIntegrationLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub app_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub slug: Option, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub permissions: HashMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub webhooks: Option, +} + +/// `[server.integrations.slack]` — Slack workspace credentials and defaults. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SlackIntegrationLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_channel: Option, +} + +/// `[server.integrations.discord]` — Discord workspace configuration. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DiscordIntegrationLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, +} + +/// `[server.integrations.teams]` — Microsoft Teams configuration. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TeamsIntegrationLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct IntegrationWebhooksLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub strategy: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] -pub enum ApiAuthStrategy { - Jwt, - Mtls, -} - -/// mTLS material loaded by `fabro_server::tls::*`. -#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)] -pub struct TlsSettings { - pub cert: PathBuf, - pub key: PathBuf, - pub ca: PathBuf, -} - -/// Shim `ApiSettings` that `fabro_server::serve::build_legacy_api_settings` -/// projects out of the v2 tree so the pre-v2 `resolve_auth_mode_with_lookup` -/// signature keeps working until Stage 6.6g rewrites it. -#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize)] -pub struct ApiSettings { - #[serde(default)] - pub authentication_strategies: Vec, - pub tls: Option, +pub enum WebhookStrategy { + TailscaleFunnel, } diff --git a/lib/crates/fabro-types/src/settings/v2/size.rs b/lib/crates/fabro-types/src/settings/size.rs similarity index 100% rename from lib/crates/fabro-types/src/settings/v2/size.rs rename to lib/crates/fabro-types/src/settings/size.rs diff --git a/lib/crates/fabro-types/src/settings/v2/splice_array.rs b/lib/crates/fabro-types/src/settings/splice_array.rs similarity index 100% rename from lib/crates/fabro-types/src/settings/v2/splice_array.rs rename to lib/crates/fabro-types/src/settings/splice_array.rs diff --git a/lib/crates/fabro-types/src/settings/v2/tree.rs b/lib/crates/fabro-types/src/settings/tree.rs similarity index 100% rename from lib/crates/fabro-types/src/settings/v2/tree.rs rename to lib/crates/fabro-types/src/settings/tree.rs diff --git a/lib/crates/fabro-types/src/settings/v2/mod.rs b/lib/crates/fabro-types/src/settings/v2/mod.rs deleted file mode 100644 index 8e2e9ddce..000000000 --- a/lib/crates/fabro-types/src/settings/v2/mod.rs +++ /dev/null @@ -1,38 +0,0 @@ -//! Namespaced settings schema (v2). -//! -//! This module is the hard-cut replacement for the flat [`super::Settings`] -//! shape. The top-level schema is strictly namespaced with `_version`, -//! `[project]`, `[workflow]`, `[run]`, `[cli]`, `[server]`, and `[features]`. -//! Value-language helpers live alongside the tree: durations, byte sizes, -//! model references, env interpolation, and splice-capable arrays. - -pub mod accessors; -pub mod cli; -pub mod duration; -pub mod features; -pub mod interp; -pub mod model_ref; -pub mod project; -pub mod run; -pub mod server; -pub mod size; -pub mod splice_array; -pub mod tree; -pub mod version; -pub mod workflow; - -pub use cli::CliLayer; -pub use duration::{Duration, ParseDurationError}; -pub use features::FeaturesLayer; -pub use interp::{InterpString, Provenance, ResolveEnvError, Resolved}; -pub use model_ref::{ - AmbiguousModelRef, ModelRef, ModelRegistry, ParseModelRefError, ResolvedModelRef, -}; -pub use project::ProjectLayer; -pub use run::RunLayer; -pub use server::ServerLayer; -pub use size::{ParseSizeError, Size}; -pub use splice_array::{SPLICE_MARKER, SpliceArray, SpliceArrayError}; -pub use tree::{ParseError, SettingsFile, parse_settings_file}; -pub use version::{CURRENT_VERSION, SchemaVersion, VersionError, validate_version}; -pub use workflow::WorkflowLayer; diff --git a/lib/crates/fabro-types/src/settings/v2/server.rs b/lib/crates/fabro-types/src/settings/v2/server.rs deleted file mode 100644 index c1931c620..000000000 --- a/lib/crates/fabro-types/src/settings/v2/server.rs +++ /dev/null @@ -1,323 +0,0 @@ -//! Server domain. -//! -//! `[server]` is a namespace container; actual settings live in named -//! subdomains (listen, api, web, auth, storage, artifacts, slatedb, -//! scheduler, logging, integrations). Same-host and split-host deployments -//! use the same schema. - -use std::collections::HashMap; - -use serde::{Deserialize, Serialize}; - -use super::duration::Duration; -use super::interp::InterpString; - -/// A sparse `[server]` layer as it appears in a single settings file. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ServerLayer { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub listen: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub api: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub web: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub auth: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub storage: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub artifacts: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub slatedb: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub scheduler: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub logging: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub integrations: Option, -} - -/// `[server.listen]` — shared bind transport. TLS lives under `[server.listen.tls]`. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields, tag = "type", rename_all = "lowercase")] -pub enum ServerListenLayer { - Tcp { - #[serde(default)] - address: Option, - #[serde(default)] - tls: Option, - }, - Unix { - #[serde(default)] - path: Option, - }, -} - -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ServerListenTlsLayer { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cert: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub key: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub ca: Option, -} - -/// `[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, -} - -/// `[server.web]` — web surface settings. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ServerWebLayer { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub enabled: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub url: Option, -} - -/// `[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)] -#[serde(deny_unknown_fields)] -pub struct ServerAuthLayer { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub api: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub web: Option, -} - -/// `[server.auth.api]` — supports multiple strategies concurrently. Each -/// strategy is a named subtable: `[server.auth.api.jwt]`, `[server.auth.api.mtls]`. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ServerAuthApiLayer { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub jwt: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub mtls: Option, -} - -/// `[server.auth.api.jwt]` — JWT auth strategy fields. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ServerAuthApiJwtLayer { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub enabled: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub issuer: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub audience: Option, -} - -/// `[server.auth.api.mtls]` — mutual TLS auth strategy fields. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ServerAuthApiMtlsLayer { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub enabled: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub ca: Option, -} - -/// `[server.auth.web]` — provider-neutral access rules plus keyed providers. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ServerAuthWebLayer { - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub allowed_usernames: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub providers: Option, -} - -/// `[server.auth.web.providers.]` — web auth providers keyed by -/// provider name. First-pass providers cover GitHub. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ServerAuthWebProvidersLayer { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub github: Option, -} - -/// `[server.auth.web.providers.github]` — GitHub OAuth configuration fields. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ServerAuthWebGithubLayer { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub enabled: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub client_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub client_secret: Option, -} - -/// `[server.storage]` — single managed local disk root. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ServerStorageLayer { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub root: Option, -} - -/// `[server.artifacts]` — object-store-backed artifact storage. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ServerArtifactsLayer { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub provider: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub prefix: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub local: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub s3: Option, -} - -/// `[server.slatedb]` — SlateDB bottomless storage plus tunables. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ServerSlateDbLayer { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub provider: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub prefix: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub flush_interval: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub local: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub s3: Option, -} - -/// Closed enum of object-store providers. Unknown providers hard-fail -/// against the schema rather than passing through as opaque strings. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum ObjectStoreProvider { - Local, - S3, -} - -#[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`. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub root: Option, -} - -#[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, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub region: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub endpoint: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub path_style: Option, -} - -/// `[server.scheduler]` — server-managed execution policy. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ServerSchedulerLayer { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub max_concurrent_runs: Option, -} - -/// `[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, -} - -/// `[server.integrations.]` — 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)] -#[serde(deny_unknown_fields)] -pub struct ServerIntegrationsLayer { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub github: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub slack: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub discord: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub teams: Option, -} - -/// `[server.integrations.github]` — GitHub App, credentials, and inbound webhooks. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct GithubIntegrationLayer { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub enabled: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub app_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub client_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub slug: Option, - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub permissions: HashMap, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub webhooks: Option, -} - -/// `[server.integrations.slack]` — Slack workspace credentials and defaults. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct SlackIntegrationLayer { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub enabled: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub default_channel: Option, -} - -/// `[server.integrations.discord]` — Discord workspace configuration. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct DiscordIntegrationLayer { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub enabled: Option, -} - -/// `[server.integrations.teams]` — Microsoft Teams configuration. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct TeamsIntegrationLayer { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub enabled: Option, -} - -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct IntegrationWebhooksLayer { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub strategy: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum WebhookStrategy { - TailscaleFunnel, -} diff --git a/lib/crates/fabro-types/src/settings/v2/version.rs b/lib/crates/fabro-types/src/settings/version.rs similarity index 100% rename from lib/crates/fabro-types/src/settings/v2/version.rs rename to lib/crates/fabro-types/src/settings/version.rs diff --git a/lib/crates/fabro-types/src/settings/v2/workflow.rs b/lib/crates/fabro-types/src/settings/workflow.rs similarity index 100% rename from lib/crates/fabro-types/src/settings/v2/workflow.rs rename to lib/crates/fabro-types/src/settings/workflow.rs From d82d167f07418bd0920d9e4967b1f7b59d72e58f Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 9 Apr 2026 18:38:22 -0400 Subject: [PATCH 36/47] refactor(settings): stage 6.6g rewrite auth resolver for v2 Replaces the `build_legacy_api_settings` + `resolve_auth_mode_with_lookup(&ApiSettings, &[String], lookup)` shim path with a direct `resolve_auth_mode_with_lookup(&SettingsFile, lookup)` that walks the v2 `server.auth.api.{jwt,mtls}` and `server.auth.web.allowed_usernames` subtrees directly: - Each strategy subtree is considered enabled when present unless `enabled = false` is explicit (R52). - `allowed_usernames` is read from `server.auth.web.allowed_usernames` instead of a separate caller-supplied `&[String]` slice. - The FABRO_LOCAL_NO_AUTH escape hatch and "no strategies configured; rejecting everything" warnings are preserved. Deletes the `ApiAuthStrategy` and `ApiSettings` transitional shim types from `fabro-server/src/jwt_auth.rs`. `TlsSettings` survives (it's the resolved `(cert, key, ca)` triple that `tls.rs`'s rustls builder still consumes), with a new `TlsSettings::from_settings(&SettingsFile)` constructor that projects `server.listen.tls` into the runtime shape. `serve.rs` drops its `build_legacy_api_settings` helper entirely (~60 LOC). The serve bootstrap now calls `resolve_auth_mode_with_lookup(&cfg_file, ...)` directly and uses `TlsSettings::from_settings(&cfg_file)` for the TCP-vs-Unix branch. The `build_legacy_api_settings` TODO-2 from handoff-2 is resolved. TlsSettings uses `is_some_and` instead of `map_or(false, ...)` to satisfy the clippy `unnecessary_map_or` lint. All 3,758 workspace tests pass. `cargo fmt --check --all` and `cargo clippy --workspace -- -D warnings` are clean. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/crates/fabro-server/src/jwt_auth.rs | 173 +++++++++++++++--------- lib/crates/fabro-server/src/serve.rs | 80 +---------- 2 files changed, 111 insertions(+), 142 deletions(-) diff --git a/lib/crates/fabro-server/src/jwt_auth.rs b/lib/crates/fabro-server/src/jwt_auth.rs index f1589f311..a4f12e266 100644 --- a/lib/crates/fabro-server/src/jwt_auth.rs +++ b/lib/crates/fabro-server/src/jwt_auth.rs @@ -6,42 +6,45 @@ use axum::http::request::Parts; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use jsonwebtoken::{Algorithm, DecodingKey, Validation}; use rustls_pki_types::CertificateDer; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use tracing::warn; use crate::error::ApiError; use crate::web_auth::SessionCookie; use fabro_types::RunAuthMethod; +use fabro_types::settings::SettingsFile; +use fabro_types::settings::interp::InterpString; +use fabro_types::settings::server::ServerListenLayer; -/// Authentication strategy flag consumed by `resolve_auth_mode_with_lookup`. -/// -/// Projected out of the v2 `server.auth.api.{jwt,mtls}` subtree by -/// `serve::build_legacy_api_settings`. Stage 6.6g will delete this shim -/// and walk the v2 tree directly in the auth resolver. -#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum ApiAuthStrategy { - Jwt, - Mtls, -} - -/// mTLS material loaded from `[server.listen.tls]`. Consumed by the -/// `tls.rs` rustls config builder. -#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)] +/// Resolved TLS material used by the rustls config builder in `tls.rs` +/// when the server is listening on TCP with `[server.listen.tls]` set. +#[derive(Debug, Clone, PartialEq)] pub struct TlsSettings { pub cert: PathBuf, pub key: PathBuf, pub ca: PathBuf, } -/// Shim `ApiSettings` that `serve::build_legacy_api_settings` projects out -/// of the v2 tree so the pre-v2 [`resolve_auth_mode_with_lookup`] signature -/// keeps working until Stage 6.6g rewrites it. -#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize)] -pub struct ApiSettings { - #[serde(default)] - pub authentication_strategies: Vec, - pub tls: Option, +impl TlsSettings { + /// Extract the `[server.listen.tls]` subtree out of a `SettingsFile`. + /// Returns `None` when the server is on Unix sockets, TLS is unset, or + /// any of the three fields is missing. + #[must_use] + pub fn from_settings(file: &SettingsFile) -> Option { + let listen = file.server.as_ref()?.listen.as_ref()?; + let tls = match listen { + ServerListenLayer::Tcp { tls, .. } => tls.as_ref()?, + ServerListenLayer::Unix { .. } => return None, + }; + let cert = tls.cert.as_ref().map(InterpString::as_source)?; + let key = tls.key.as_ref().map(InterpString::as_source)?; + let ca = tls.ca.as_ref().map(InterpString::as_source)?; + Some(Self { + cert: cert.into(), + key: key.into(), + ca: ca.into(), + }) + } } /// JWT claims for service-to-service authentication. @@ -99,34 +102,74 @@ pub fn decode_pem_env(name: &str, value: &str) -> String { .unwrap_or_else(|e| panic!("{name} base64 decoded to invalid UTF-8: {e}")) } -/// Resolve the authentication mode from the API config section. +/// Resolve the authentication mode from a [`SettingsFile`]. /// /// Call this once at startup before serving requests. Panics if the -/// configuration is invalid (JWT strategy but no public key, or mTLS without TLS config). -pub fn resolve_auth_mode(api_settings: &ApiSettings, allowed_usernames: &[String]) -> AuthMode { - resolve_auth_mode_with_lookup(api_settings, allowed_usernames, |name| { - std::env::var(name).ok() - }) +/// configuration is invalid (JWT strategy but no public key, or mTLS without +/// TLS config). Walks the v2 `server.auth.api.{jwt,mtls}` subtree and +/// `server.auth.web.allowed_usernames`. +pub fn resolve_auth_mode(settings: &SettingsFile) -> AuthMode { + resolve_auth_mode_with_lookup(settings, |name| std::env::var(name).ok()) } -pub fn resolve_auth_mode_with_lookup( - api_settings: &ApiSettings, - allowed_usernames: &[String], - lookup: F, -) -> AuthMode +/// Describes which API auth strategies are enabled in a `SettingsFile`. +struct ResolvedAuthStrategies { + jwt_enabled: bool, + mtls_enabled: bool, + tls_present: bool, + allowed_usernames: Vec, +} + +fn resolve_auth_strategies(settings: &SettingsFile) -> ResolvedAuthStrategies { + let server = settings.server.as_ref(); + let auth = server.and_then(|s| s.auth.as_ref()); + let auth_api = auth.and_then(|a| a.api.as_ref()); + + // Strategies: a subtree with `enabled = false` is explicitly off. + // Presence of the subtree with `enabled` unset counts as on. + let jwt_enabled = auth_api + .and_then(|api| api.jwt.as_ref()) + .is_some_and(|jwt| jwt.enabled.unwrap_or(true)); + let mtls_enabled = auth_api + .and_then(|api| api.mtls.as_ref()) + .is_some_and(|mtls| mtls.enabled.unwrap_or(true)); + + let tls_present = TlsSettings::from_settings(settings).is_some(); + + let allowed_usernames = auth + .and_then(|a| a.web.as_ref()) + .map(|w| w.allowed_usernames.clone()) + .unwrap_or_default(); + + ResolvedAuthStrategies { + jwt_enabled, + mtls_enabled, + tls_present, + allowed_usernames, + } +} + +pub fn resolve_auth_mode_with_lookup(settings: &SettingsFile, lookup: F) -> AuthMode where F: Fn(&str) -> Option, { - if api_settings.authentication_strategies.is_empty() - && std::env::var("FABRO_LOCAL_NO_AUTH").ok().as_deref() == Some("1") - { + let ResolvedAuthStrategies { + jwt_enabled, + mtls_enabled, + tls_present, + allowed_usernames, + } = resolve_auth_strategies(settings); + + let any_strategy = jwt_enabled || mtls_enabled; + + if !any_strategy && std::env::var("FABRO_LOCAL_NO_AUTH").ok().as_deref() == Some("1") { warn!( "No authentication strategies configured; allowing unauthenticated local daemon access" ); return AuthMode::Disabled; } - if api_settings.authentication_strategies.is_empty() { + if !any_strategy { warn!("No authentication strategies configured; all requests will be rejected"); } @@ -135,34 +178,30 @@ where strategies.push(AuthStrategy::Cookie); } - strategies.extend(api_settings - .authentication_strategies - .iter() - .map(|s| match s { - ApiAuthStrategy::Jwt => { - let raw = lookup("FABRO_JWT_PUBLIC_KEY").unwrap_or_else(|| { - panic!( - "FABRO_JWT_PUBLIC_KEY is not set. Provide an Ed25519 public key in PEM \ - format (or base64-encoded PEM) for JWT authentication." - ) - }); - let pem = decode_pem_env("FABRO_JWT_PUBLIC_KEY", &raw); - let key = DecodingKey::from_ed_pem(pem.as_bytes()) - .expect("FABRO_JWT_PUBLIC_KEY contains an invalid Ed25519 PEM public key"); - AuthStrategy::Jwt { - key: Arc::new(key), - validation: Arc::new(jwt_validation()), - allowed_usernames: allowed_usernames.to_vec(), - } - } - ApiAuthStrategy::Mtls => { - assert!( - api_settings.tls.is_some(), - "mTLS authentication strategy requires [api.tls] configuration with cert, key, and ca" - ); - AuthStrategy::Mtls - } - })); + if jwt_enabled { + let raw = lookup("FABRO_JWT_PUBLIC_KEY").unwrap_or_else(|| { + panic!( + "FABRO_JWT_PUBLIC_KEY is not set. Provide an Ed25519 public key in PEM format \ + (or base64-encoded PEM) for JWT authentication." + ) + }); + let pem = decode_pem_env("FABRO_JWT_PUBLIC_KEY", &raw); + let key = DecodingKey::from_ed_pem(pem.as_bytes()) + .expect("FABRO_JWT_PUBLIC_KEY contains an invalid Ed25519 PEM public key"); + strategies.push(AuthStrategy::Jwt { + key: Arc::new(key), + validation: Arc::new(jwt_validation()), + allowed_usernames: allowed_usernames.clone(), + }); + } + + if mtls_enabled { + assert!( + tls_present, + "mTLS authentication strategy requires [server.listen.tls] configuration with cert, key, and ca" + ); + strategies.push(AuthStrategy::Mtls); + } AuthMode::Strategies(strategies) } diff --git a/lib/crates/fabro-server/src/serve.rs b/lib/crates/fabro-server/src/serve.rs index 65904f1e1..c05547e36 100644 --- a/lib/crates/fabro-server/src/serve.rs +++ b/lib/crates/fabro-server/src/serve.rs @@ -2,7 +2,6 @@ use std::path::{Path, PathBuf}; use std::sync::{Arc, RwLock}; use std::time::Duration; -use crate::jwt_auth::ApiSettings; use fabro_config::Storage; use fabro_config::resolve_storage_dir; use fabro_config::user::{active_settings_path, load_settings_config}; @@ -22,7 +21,7 @@ use fabro_types::settings::v2::SettingsFile; use crate::bind::{self, Bind, BindRequest}; use crate::github_webhooks::WebhookManager; -use crate::jwt_auth::{AuthMode, AuthStrategy, resolve_auth_mode_with_lookup}; +use crate::jwt_auth::{AuthMode, AuthStrategy, TlsSettings, resolve_auth_mode_with_lookup}; use crate::secret_store::SecretStore; use crate::server::{ RouterOptions, build_app_state_with_path, build_router_with_options, @@ -85,65 +84,6 @@ fn load_settings(path: Option<&Path>) -> anyhow::Result { Ok(load_settings_config(path)?.into()) } -/// Build the legacy `ApiSettings` shape that `resolve_auth_mode_with_lookup` -/// and the TLS branch still expect, extracting the pieces it needs from the -/// v2 tree. Stage 6.6 replaces this with a v2-aware auth resolver and drops -/// the legacy `ApiSettings` type entirely. -fn build_legacy_api_settings(file: &SettingsFile) -> ApiSettings { - use crate::jwt_auth::{ApiAuthStrategy, TlsSettings}; - use fabro_types::settings::v2::interp::InterpString; - use fabro_types::settings::v2::server::ServerListenLayer; - - let auth_api = file - .server - .as_ref() - .and_then(|s| s.auth.as_ref()) - .and_then(|a| a.api.as_ref()); - - let mut authentication_strategies = Vec::new(); - if auth_api - .and_then(|api| api.jwt.as_ref()) - .and_then(|jwt| jwt.enabled) - .unwrap_or(auth_api.and_then(|api| api.jwt.as_ref()).is_some()) - { - authentication_strategies.push(ApiAuthStrategy::Jwt); - } - if auth_api - .and_then(|api| api.mtls.as_ref()) - .and_then(|mtls| mtls.enabled) - .unwrap_or(auth_api.and_then(|api| api.mtls.as_ref()).is_some()) - { - authentication_strategies.push(ApiAuthStrategy::Mtls); - } - - // TLS files now live under `server.listen.tls.{cert,key,ca}` in v2. - // Build a legacy TlsSettings from the listen TLS subtree so the - // existing rustls config path keeps working. - let tls = file - .server - .as_ref() - .and_then(|s| s.listen.as_ref()) - .and_then(|listen| match listen { - ServerListenLayer::Tcp { tls, .. } => tls.as_ref(), - ServerListenLayer::Unix { .. } => None, - }) - .and_then(|tls_layer| { - let cert = tls_layer.cert.as_ref().map(InterpString::as_source)?; - let key = tls_layer.key.as_ref().map(InterpString::as_source)?; - let ca = tls_layer.ca.as_ref().map(InterpString::as_source)?; - Some(TlsSettings { - cert: cert.into(), - key: key.into(), - ca: ca.into(), - }) - }); - - ApiSettings { - authentication_strategies, - tls, - } -} - fn resolved_config_path(path: Option<&Path>) -> PathBuf { active_settings_path(path) } @@ -347,24 +287,14 @@ where std::fs::create_dir_all(&data_dir)?; let (auth_mode, client_auth, max_concurrent_runs) = { let cfg_file = shared_settings.read().expect("config lock poisoned"); - // Build the legacy ApiSettings + allowed_usernames shapes that the - // v1 auth resolver expects. Stage 6.6 replaces this with a direct - // v2-aware resolver. - let api = build_legacy_api_settings(&cfg_file); - let allowed_usernames = cfg_file - .server - .as_ref() - .and_then(|s| s.auth.as_ref()) - .and_then(|a| a.web.as_ref()) - .map(|w| w.allowed_usernames.clone()) - .unwrap_or_default(); - let auth_mode = resolve_auth_mode_with_lookup(&api, &allowed_usernames, |name| { + let auth_mode = resolve_auth_mode_with_lookup(&cfg_file, |name| { secret_snapshot .get(name) .cloned() .or_else(|| std::env::var(name).ok()) }); - let client_auth = api.tls.as_ref().map(|_| client_auth_from_mode(&auth_mode)); + let tls_present = TlsSettings::from_settings(&cfg_file).is_some(); + let client_auth = tls_present.then(|| client_auth_from_mode(&auth_mode)); let max_concurrent_runs = args .max_concurrent_runs .or_else(|| cfg_file.max_concurrent_runs()) @@ -516,7 +446,7 @@ where // Branch: TLS, plain TCP, or Unix socket let tls_settings = { let cfg_file = shared_settings.read().expect("config lock poisoned"); - build_legacy_api_settings(&cfg_file).tls.clone() + TlsSettings::from_settings(&cfg_file) }; let bound_listener = bind_listener(&bind_request).await?; From c625747e0c601ce034ad1c81679fcf049e016606 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 9 Apr 2026 18:42:05 -0400 Subject: [PATCH 37/47] refactor(settings): stage 6.5b sweep ::v2:: prefix out of consumers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final mechanical pass: replaces every remaining `fabro_types::settings::v2::*` import path with `fabro_types::settings::*` (or the appropriate submodule) across 53 files in 10 crates, then deletes the transitional `pub mod v2 { pub use super::*; }` alias from `fabro-types/src/settings/mod.rs`. No functional changes — all touches are `sed s|settings::v2::|settings::|g` on import statements and fully-qualified type paths. The v2 namespace is now fully gone; the authoritative module path is `fabro_types::settings::{accessors, cli, duration, features, interp, model_ref, project, run, server, size, splice_array, tree, version, workflow}`. All 3,758 workspace tests pass. `cargo fmt --check --all` and `cargo clippy --workspace -- -D warnings` are clean. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/crates/fabro-checkpoint/src/author.rs | 2 +- lib/crates/fabro-checkpoint/src/metadata.rs | 2 +- lib/crates/fabro-cli/src/command_context.rs | 2 +- lib/crates/fabro-cli/src/commands/exec.rs | 4 ++-- lib/crates/fabro-cli/src/commands/install.rs | 8 +++---- .../fabro-cli/src/commands/run/create.rs | 2 +- .../fabro-cli/src/commands/run/overrides.rs | 8 +++---- .../fabro-cli/src/commands/run/runner.rs | 2 +- .../fabro-cli/src/commands/store/dump.rs | 2 +- lib/crates/fabro-cli/src/main.rs | 2 +- lib/crates/fabro-cli/src/manifest_builder.rs | 4 ++-- lib/crates/fabro-cli/src/user_config.rs | 8 +++---- lib/crates/fabro-cli/tests/it/cmd/config.rs | 2 +- lib/crates/fabro-config/src/config.rs | 4 ++-- .../fabro-config/src/effective_settings.rs | 16 ++++++-------- lib/crates/fabro-config/src/lib.rs | 2 +- lib/crates/fabro-config/src/merge.rs | 14 ++++++------ lib/crates/fabro-hooks/src/config.rs | 6 ++--- lib/crates/fabro-mcp/src/config.rs | 6 ++--- lib/crates/fabro-sandbox/src/config.rs | 6 ++--- lib/crates/fabro-server/src/diagnostics.rs | 4 ++-- lib/crates/fabro-server/src/run_manifest.rs | 8 +++---- lib/crates/fabro-server/src/serve.rs | 22 +++++++++---------- lib/crates/fabro-server/src/server.rs | 4 ++-- lib/crates/fabro-server/src/web_auth.rs | 2 +- .../fabro-server/tests/it/api/routing.rs | 2 +- .../fabro-server/tests/it/api/settings.rs | 2 +- .../fabro-server/tests/it/api/system.rs | 8 +++---- lib/crates/fabro-server/tests/it/helpers.rs | 4 ++-- lib/crates/fabro-store/src/slate/mod.rs | 2 +- lib/crates/fabro-types/src/run.rs | 2 +- lib/crates/fabro-types/src/run_event/run.rs | 2 +- .../fabro-types/src/settings/accessors.rs | 6 ++--- lib/crates/fabro-types/src/settings/mod.rs | 7 ------ lib/crates/fabro-workflow/src/config.rs | 4 ++-- lib/crates/fabro-workflow/src/git.rs | 2 +- .../src/handler/manager_loop.rs | 2 +- .../fabro-workflow/src/operations/create.rs | 16 +++++++------- .../src/operations/rebuild_meta.rs | 2 +- .../fabro-workflow/src/operations/source.rs | 4 ++-- .../fabro-workflow/src/operations/start.rs | 6 ++--- .../fabro-workflow/src/operations/validate.rs | 2 +- .../src/pipeline/execute/tests.rs | 2 +- .../fabro-workflow/src/pipeline/finalize.rs | 2 +- .../fabro-workflow/src/pipeline/initialize.rs | 4 ++-- .../fabro-workflow/src/pipeline/persist.rs | 6 ++--- .../src/pipeline/pull_request.rs | 2 +- .../fabro-workflow/src/pipeline/retro.rs | 2 +- lib/crates/fabro-workflow/src/run_lookup.rs | 4 ++-- lib/crates/fabro-workflow/src/run_options.rs | 4 ++-- .../fabro-workflow/src/runtime_store.rs | 2 +- .../tests/it/daytona_integration.rs | 4 ++-- .../fabro-workflow/tests/it/integration.rs | 10 ++++----- 53 files changed, 124 insertions(+), 133 deletions(-) diff --git a/lib/crates/fabro-checkpoint/src/author.rs b/lib/crates/fabro-checkpoint/src/author.rs index e85062f6b..a386bc898 100644 --- a/lib/crates/fabro-checkpoint/src/author.rs +++ b/lib/crates/fabro-checkpoint/src/author.rs @@ -1,7 +1,7 @@ use std::fmt::Write; use fabro_types::settings::InterpString; -use fabro_types::settings::v2::run::GitAuthorLayer; +use fabro_types::settings::run::GitAuthorLayer; /// Resolved git author identity for checkpoint commits. #[derive(Debug, Clone, PartialEq)] diff --git a/lib/crates/fabro-checkpoint/src/metadata.rs b/lib/crates/fabro-checkpoint/src/metadata.rs index 930d19b93..d1a5ed93f 100644 --- a/lib/crates/fabro-checkpoint/src/metadata.rs +++ b/lib/crates/fabro-checkpoint/src/metadata.rs @@ -178,7 +178,7 @@ mod tests { use super::*; use chrono::{TimeZone, Utc}; - use fabro_types::settings::v2::SettingsFile; + use fabro_types::settings::SettingsFile; use fabro_types::{Graph, fixtures}; /// Create a temporary git repo with an initial commit. diff --git a/lib/crates/fabro-cli/src/command_context.rs b/lib/crates/fabro-cli/src/command_context.rs index cacae3ef6..9eadc5c0b 100644 --- a/lib/crates/fabro-cli/src/command_context.rs +++ b/lib/crates/fabro-cli/src/command_context.rs @@ -2,7 +2,7 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use anyhow::{Context as _, Result, bail}; -use fabro_types::settings::v2::SettingsFile; +use fabro_types::settings::SettingsFile; use tokio::sync::OnceCell; use crate::args::{ServerConnectionArgs, ServerTargetArgs}; diff --git a/lib/crates/fabro-cli/src/commands/exec.rs b/lib/crates/fabro-cli/src/commands/exec.rs index 47d1d10a1..1d54dc177 100644 --- a/lib/crates/fabro-cli/src/commands/exec.rs +++ b/lib/crates/fabro-cli/src/commands/exec.rs @@ -3,7 +3,7 @@ use fabro_agent::cli::{OutputFormat, run_with_args, run_with_args_and_client}; use fabro_llm::client::Client; use fabro_llm::providers::FabroServerAdapter; use fabro_mcp::config::{McpServerSettings, bridge_mcp_entry}; -use fabro_types::settings::v2::InterpString; +use fabro_types::settings::InterpString; use std::collections::HashMap; use std::sync::Arc; @@ -12,7 +12,7 @@ use crate::user_config; pub(crate) async fn execute(mut args: ExecArgs, globals: &GlobalArgs) -> Result<()> { use fabro_agent::cli::PermissionLevel as AgentPermissionLevel; - use fabro_types::settings::v2::run::AgentPermissions; + use fabro_types::settings::run::AgentPermissions; let cli_settings = user_config::load_settings()?; #[cfg(feature = "sleep_inhibitor")] diff --git a/lib/crates/fabro-cli/src/commands/install.rs b/lib/crates/fabro-cli/src/commands/install.rs index a0e3843f0..0bd492329 100644 --- a/lib/crates/fabro-cli/src/commands/install.rs +++ b/lib/crates/fabro-cli/src/commands/install.rs @@ -1024,7 +1024,7 @@ mod tests { #[test] fn config_toml_roundtrips() { - use fabro_types::settings::v2::SettingsFile; + use fabro_types::settings::SettingsFile; let toml_str = format_config_toml("brynary"); let cfg: SettingsFile = fabro_config::ConfigLayer::parse(&toml_str) .expect("generated config should parse as v2") @@ -1041,7 +1041,7 @@ mod tests { #[test] fn config_toml_has_auth_strategies() { - use fabro_types::settings::v2::SettingsFile; + use fabro_types::settings::SettingsFile; let toml_str = format_config_toml("alice"); let cfg: SettingsFile = fabro_config::ConfigLayer::parse(&toml_str).unwrap().into(); let auth_api = cfg @@ -1066,8 +1066,8 @@ mod tests { #[test] fn config_toml_has_tls_paths() { - use fabro_types::settings::v2::SettingsFile; - use fabro_types::settings::v2::server::ServerListenLayer; + use fabro_types::settings::SettingsFile; + use fabro_types::settings::server::ServerListenLayer; let toml_str = format_config_toml("bob"); let cfg: SettingsFile = fabro_config::ConfigLayer::parse(&toml_str).unwrap().into(); let listen = cfg diff --git a/lib/crates/fabro-cli/src/commands/run/create.rs b/lib/crates/fabro-cli/src/commands/run/create.rs index b3278f2f2..b4a0a5ee4 100644 --- a/lib/crates/fabro-cli/src/commands/run/create.rs +++ b/lib/crates/fabro-cli/src/commands/run/create.rs @@ -5,7 +5,7 @@ use crate::command_context::CommandContext; use fabro_config::ConfigLayer; use fabro_config::Storage; use fabro_types::RunId; -use fabro_types::settings::v2::SettingsFile; +use fabro_types::settings::SettingsFile; use fabro_util::terminal::Styles; use super::output::{api_diagnostics_to_local, print_preflight_workflow_summary}; diff --git a/lib/crates/fabro-cli/src/commands/run/overrides.rs b/lib/crates/fabro-cli/src/commands/run/overrides.rs index 35e2959f4..8ad84664f 100644 --- a/lib/crates/fabro-cli/src/commands/run/overrides.rs +++ b/lib/crates/fabro-cli/src/commands/run/overrides.rs @@ -3,10 +3,10 @@ use std::collections::HashMap; use anyhow::Result; use fabro_config::ConfigLayer; use fabro_sandbox::SandboxProvider; -use fabro_types::settings::v2::SettingsFile; -use fabro_types::settings::v2::cli::{CliLayer, CliOutputLayer, OutputVerbosity}; -use fabro_types::settings::v2::interp::InterpString; -use fabro_types::settings::v2::run::{ +use fabro_types::settings::SettingsFile; +use fabro_types::settings::cli::{CliLayer, CliOutputLayer, OutputVerbosity}; +use fabro_types::settings::interp::InterpString; +use fabro_types::settings::run::{ ApprovalMode, RunExecutionLayer, RunLayer, RunMode, RunModelLayer, RunSandboxLayer, }; diff --git a/lib/crates/fabro-cli/src/commands/run/runner.rs b/lib/crates/fabro-cli/src/commands/run/runner.rs index eb8340f77..62e6e8c34 100644 --- a/lib/crates/fabro-cli/src/commands/run/runner.rs +++ b/lib/crates/fabro-cli/src/commands/run/runner.rs @@ -7,7 +7,7 @@ use anyhow::{Context, Result, anyhow}; use async_trait::async_trait; use fabro_interview::{ControlInterviewer, WorkerControlEnvelope, WorkerControlMessage}; use fabro_store::{EventEnvelope, EventPayload, RunProjection}; -use fabro_types::settings::v2::SettingsFile; +use fabro_types::settings::SettingsFile; use fabro_types::{EventBody, RunBlobId, RunEvent, RunId, StatusReason}; use fabro_workflow::artifact_snapshot::CapturedArtifactInfo; use fabro_workflow::artifact_upload::{ArtifactSink, StageArtifactUploader}; diff --git a/lib/crates/fabro-cli/src/commands/store/dump.rs b/lib/crates/fabro-cli/src/commands/store/dump.rs index 8da80e483..20d9dd843 100644 --- a/lib/crates/fabro-cli/src/commands/store/dump.rs +++ b/lib/crates/fabro-cli/src/commands/store/dump.rs @@ -297,7 +297,7 @@ mod tests { use chrono::{DateTime, Utc}; use fabro_store::{Database, EventEnvelope, EventPayload}; - use fabro_types::settings::v2::SettingsFile; + use fabro_types::settings::SettingsFile; use fabro_types::{ AggregateStats, AttrValue, BilledTokenCounts, Checkpoint, Conclusion, Graph, NodeStatusRecord, Retro, RunId, RunRecord, RunStatus, RunStatusRecord, SandboxRecord, diff --git a/lib/crates/fabro-cli/src/main.rs b/lib/crates/fabro-cli/src/main.rs index aa1a3adb9..b479d5832 100644 --- a/lib/crates/fabro-cli/src/main.rs +++ b/lib/crates/fabro-cli/src/main.rs @@ -132,7 +132,7 @@ async fn main_inner() -> (String, Result<()>) { { match load_settings_config(args.config.as_deref()) { Ok(layer) => { - use fabro_types::settings::v2::SettingsFile; + use fabro_types::settings::SettingsFile; let server_settings: SettingsFile = layer.into(); ( server_settings diff --git a/lib/crates/fabro-cli/src/manifest_builder.rs b/lib/crates/fabro-cli/src/manifest_builder.rs index 26a1679e2..5ea4a7f77 100644 --- a/lib/crates/fabro-cli/src/manifest_builder.rs +++ b/lib/crates/fabro-cli/src/manifest_builder.rs @@ -11,8 +11,8 @@ use fabro_graphviz::graph::AttrValue; use fabro_graphviz::parser; use fabro_sandbox::daytona::detect_repo_info; use fabro_types::RunId; -use fabro_types::settings::v2::SettingsFile; -use fabro_types::settings::v2::run::DaytonaDockerfileLayer; +use fabro_types::settings::SettingsFile; +use fabro_types::settings::run::DaytonaDockerfileLayer; use fabro_workflow::git::{GitSyncStatus, head_sha, sync_status}; use crate::args::{PreflightArgs, RunArgs}; diff --git a/lib/crates/fabro-cli/src/user_config.rs b/lib/crates/fabro-cli/src/user_config.rs index 248979560..28a0391af 100644 --- a/lib/crates/fabro-cli/src/user_config.rs +++ b/lib/crates/fabro-cli/src/user_config.rs @@ -54,8 +54,8 @@ pub(crate) fn apply_storage_dir_override( mut layer: ConfigLayer, storage_dir: Option<&Path>, ) -> ConfigLayer { - use fabro_types::settings::v2::interp::InterpString; - use fabro_types::settings::v2::server::{ServerLayer, ServerStorageLayer}; + use fabro_types::settings::interp::InterpString; + use fabro_types::settings::server::{ServerLayer, ServerStorageLayer}; if let Some(dir) = storage_dir { let file = layer.as_v2_mut(); let server = file.server.get_or_insert_with(ServerLayer::default); @@ -82,8 +82,8 @@ pub(crate) enum ServerTarget { /// http(s) URL or a unix socket path. `tls` is the CLI-side client TLS /// settings extracted from `[cli.target.http.tls]`. fn cli_target_from_v2(settings: &SettingsFile) -> Option<(String, Option)> { - use fabro_types::settings::v2::cli::CliTargetLayer; - use fabro_types::settings::v2::interp::InterpString; + use fabro_types::settings::cli::CliTargetLayer; + use fabro_types::settings::interp::InterpString; let target = settings.cli.as_ref()?.target.as_ref()?; match target { diff --git a/lib/crates/fabro-cli/tests/it/cmd/config.rs b/lib/crates/fabro-cli/tests/it/cmd/config.rs index 985c411c5..cba0507c7 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/config.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/config.rs @@ -330,7 +330,7 @@ fn settings_local_workflow_name_applies_run_overlay_and_deep_merges() { .stdout .clone(); - use fabro_types::settings::v2::run::McpEntryLayer; + use fabro_types::settings::run::McpEntryLayer; let cfg = parse_settings(&output); assert_eq!(cfg.run_goal_str().as_deref(), Some("demo goal")); diff --git a/lib/crates/fabro-config/src/config.rs b/lib/crates/fabro-config/src/config.rs index 0ac8b075d..a6050fa31 100644 --- a/lib/crates/fabro-config/src/config.rs +++ b/lib/crates/fabro-config/src/config.rs @@ -13,7 +13,7 @@ use std::path::Path; use anyhow::Context; -use fabro_types::settings::v2::{SettingsFile, parse_settings_file as parse_v2_settings_file}; +use fabro_types::settings::{SettingsFile, parse_settings_file as parse_v2_settings_file}; use serde::{Deserialize, Serialize}; use crate::merge::combine_files; @@ -124,7 +124,7 @@ impl ConfigLayer { #[cfg(test)] mod tests { - use fabro_types::settings::v2::InterpString; + use fabro_types::settings::InterpString; use super::*; diff --git a/lib/crates/fabro-config/src/effective_settings.rs b/lib/crates/fabro-config/src/effective_settings.rs index ceae33039..840f818f5 100644 --- a/lib/crates/fabro-config/src/effective_settings.rs +++ b/lib/crates/fabro-config/src/effective_settings.rs @@ -7,9 +7,9 @@ //! stanzas in `fabro.toml` and `workflow.toml` remain schema-valid but inert. use anyhow::{Result, anyhow}; -use fabro_types::settings::v2::SettingsFile; -use fabro_types::settings::v2::run::{RunExecutionLayer, RunLayer}; -use fabro_types::settings::v2::server::ServerLayer; +use fabro_types::settings::SettingsFile; +use fabro_types::settings::run::{RunExecutionLayer, RunLayer}; +use fabro_types::settings::server::ServerLayer; use crate::ConfigLayer; use crate::merge::combine_files; @@ -177,10 +177,8 @@ fn apply_local_daemon_overrides(mut settings: SettingsFile, server: &SettingsFil #[cfg(test)] mod tests { - use fabro_types::settings::v2::InterpString; - use fabro_types::settings::v2::server::{ - ServerLayer, ServerSchedulerLayer, ServerStorageLayer, - }; + use fabro_types::settings::InterpString; + use fabro_types::settings::server::{ServerLayer, ServerSchedulerLayer, ServerStorageLayer}; use super::{EffectiveSettingsLayers, EffectiveSettingsMode, resolve_settings}; use crate::ConfigLayer; @@ -295,7 +293,7 @@ provider = "openai" #[test] fn cli_and_server_domains_from_fabro_toml_are_inert_under_remote_mode() { - let mut server_settings = fabro_types::settings::v2::SettingsFile::default(); + let mut server_settings = fabro_types::settings::SettingsFile::default(); server_settings.server = Some(ServerLayer { storage: Some(ServerStorageLayer { root: Some(InterpString::parse("/srv/fabro")), @@ -339,7 +337,7 @@ root = "/tmp/should-be-inert" #[test] fn local_daemon_mode_only_applies_server_owned_overrides() { - let mut server_settings = fabro_types::settings::v2::SettingsFile::default(); + let mut server_settings = fabro_types::settings::SettingsFile::default(); server_settings.server = Some(ServerLayer { storage: Some(ServerStorageLayer { root: Some(InterpString::parse("/srv/fabro")), diff --git a/lib/crates/fabro-config/src/lib.rs b/lib/crates/fabro-config/src/lib.rs index 14f79d8c9..c3242e773 100644 --- a/lib/crates/fabro-config/src/lib.rs +++ b/lib/crates/fabro-config/src/lib.rs @@ -17,7 +17,7 @@ pub use storage::{RunScratch, ServerState, Storage}; use std::path::{Path, PathBuf}; -use fabro_types::settings::v2::SettingsFile; +use fabro_types::settings::SettingsFile; use serde::de::DeserializeOwned; /// Resolve the storage directory: v2 `server.storage.root` > home default. diff --git a/lib/crates/fabro-config/src/merge.rs b/lib/crates/fabro-config/src/merge.rs index 9ebc78077..fa662c94d 100644 --- a/lib/crates/fabro-config/src/merge.rs +++ b/lib/crates/fabro-config/src/merge.rs @@ -9,22 +9,22 @@ use std::collections::HashMap; -use fabro_types::settings::v2::cli::{ +use fabro_types::settings::cli::{ CliExecAgentLayer, CliExecLayer, CliExecModelLayer, CliLayer, CliTargetLayer, }; -use fabro_types::settings::v2::project::ProjectLayer; -use fabro_types::settings::v2::run::{ +use fabro_types::settings::project::ProjectLayer; +use fabro_types::settings::run::{ DaytonaSandboxLayer, GitAuthorLayer, HookEntry, InterviewsLayer, ModelRefOrSplice, NotificationRouteLayer, RunAgentLayer, RunCheckpointLayer, RunExecutionLayer, RunGitLayer, RunLayer, RunModelLayer, RunPrepareLayer, RunPullRequestLayer, RunSandboxLayer, RunScmLayer, StringOrSplice, }; -use fabro_types::settings::v2::server::{ +use fabro_types::settings::server::{ ServerArtifactsLayer, ServerAuthLayer, ServerIntegrationsLayer, ServerLayer, ServerListenLayer, ServerSchedulerLayer, ServerSlateDbLayer, ServerStorageLayer, ServerWebLayer, }; -use fabro_types::settings::v2::tree::SettingsFile; -use fabro_types::settings::v2::workflow::WorkflowLayer; +use fabro_types::settings::tree::SettingsFile; +use fabro_types::settings::workflow::WorkflowLayer; /// Combine two settings files: `higher` takes precedence over `lower` wherever /// the merge matrix does not dictate otherwise. @@ -479,7 +479,7 @@ fn combine_server_integrations( #[cfg(test)] mod tests { - use fabro_types::settings::v2::{InterpString, parse_settings_file}; + use fabro_types::settings::{InterpString, parse_settings_file}; use super::*; diff --git a/lib/crates/fabro-hooks/src/config.rs b/lib/crates/fabro-hooks/src/config.rs index 09ee98960..392f2e2ba 100644 --- a/lib/crates/fabro-hooks/src/config.rs +++ b/lib/crates/fabro-hooks/src/config.rs @@ -1,7 +1,7 @@ //! Hook configuration runtime types. //! //! These types are the runtime shape that the hook executor consumes. The -//! v2 parse tree under `fabro_types::settings::v2::run::HookEntry` is the +//! v2 parse tree under `fabro_types::settings::run::HookEntry` is the //! *config-file* shape; this module lives in `fabro-hooks` because the //! behavior methods (`is_blocking`, `timeout`, `resolved_hook_type`, //! `runs_in_sandbox`, `effective_name`) are runtime concerns owned by the @@ -13,8 +13,8 @@ use std::borrow::Cow; -use fabro_types::settings::v2::InterpString; -use fabro_types::settings::v2::run::{ +use fabro_types::settings::InterpString; +use fabro_types::settings::run::{ HookAgentMarker, HookEntry, HookEvent as V2HookEvent, HookTlsMode as V2HookTlsMode, }; use serde::{Deserialize, Serialize}; diff --git a/lib/crates/fabro-mcp/src/config.rs b/lib/crates/fabro-mcp/src/config.rs index 7787e5436..6c555b818 100644 --- a/lib/crates/fabro-mcp/src/config.rs +++ b/lib/crates/fabro-mcp/src/config.rs @@ -1,6 +1,6 @@ //! MCP server configuration runtime types. //! -//! The v2 parse tree lives in `fabro_types::settings::v2::run::McpEntryLayer`. +//! The v2 parse tree lives in `fabro_types::settings::run::McpEntryLayer`. //! This module owns the runtime shape (flattened, with timeout helpers) that //! the MCP client consumes at execution time. Conversion from the v2 shape //! lives in [`bridge_mcp_entry`] / [`bridge_mcps`]. @@ -8,8 +8,8 @@ use std::collections::HashMap; use std::time::Duration; -use fabro_types::settings::v2::InterpString; -use fabro_types::settings::v2::run::McpEntryLayer; +use fabro_types::settings::InterpString; +use fabro_types::settings::run::McpEntryLayer; use serde::{Deserialize, Serialize}; #[must_use] diff --git a/lib/crates/fabro-sandbox/src/config.rs b/lib/crates/fabro-sandbox/src/config.rs index 479b9a2e5..4a6b99180 100644 --- a/lib/crates/fabro-sandbox/src/config.rs +++ b/lib/crates/fabro-sandbox/src/config.rs @@ -1,7 +1,7 @@ //! Sandbox configuration runtime types. //! //! These types are the runtime shape that the sandbox providers consume. -//! The v2 parse tree lives in `fabro_types::settings::v2::run::RunSandboxLayer`. +//! The v2 parse tree lives in `fabro_types::settings::run::RunSandboxLayer`. //! Conversion from the v2 shape lives in [`bridge_sandbox`]. //! //! The `DaytonaSettings`/`DaytonaSnapshotSettings` names are kept for @@ -11,8 +11,8 @@ use std::collections::HashMap; -use fabro_types::settings::v2::InterpString; -use fabro_types::settings::v2::run::{ +use fabro_types::settings::InterpString; +use fabro_types::settings::run::{ DaytonaDockerfileLayer, DaytonaNetworkLayer, RunSandboxLayer, WorktreeMode as V2WorktreeMode, }; use serde::de::{self, MapAccess, Visitor}; diff --git a/lib/crates/fabro-server/src/diagnostics.rs b/lib/crates/fabro-server/src/diagnostics.rs index 1ceb1634d..79161f08f 100644 --- a/lib/crates/fabro-server/src/diagnostics.rs +++ b/lib/crates/fabro-server/src/diagnostics.rs @@ -465,7 +465,7 @@ async fn check_brave_search(state: &AppState) -> CheckResult { } fn check_crypto(state: &AppState) -> CheckResult { - use fabro_types::settings::v2::interp::InterpString; + use fabro_types::settings::interp::InterpString; let settings_file = state .settings @@ -501,7 +501,7 @@ fn check_crypto(state: &AppState) -> CheckResult { let mut errors = Vec::new(); if has_mtls { - use fabro_types::settings::v2::server::ServerListenLayer; + use fabro_types::settings::server::ServerListenLayer; let listen_tls = settings_file .server .as_ref() diff --git a/lib/crates/fabro-server/src/run_manifest.rs b/lib/crates/fabro-server/src/run_manifest.rs index fb7808a5c..24673597b 100644 --- a/lib/crates/fabro-server/src/run_manifest.rs +++ b/lib/crates/fabro-server/src/run_manifest.rs @@ -17,10 +17,10 @@ use fabro_sandbox::config::bridge_sandbox; use fabro_sandbox::daytona::DaytonaConfig; use fabro_sandbox::{DockerSandboxOptions, Sandbox, SandboxProvider, SandboxSpec}; use fabro_types::RunId; -use fabro_types::settings::v2::SettingsFile; -use fabro_types::settings::v2::cli::{CliLayer, CliOutputLayer, OutputVerbosity}; -use fabro_types::settings::v2::interp::InterpString; -use fabro_types::settings::v2::run::{ +use fabro_types::settings::SettingsFile; +use fabro_types::settings::cli::{CliLayer, CliOutputLayer, OutputVerbosity}; +use fabro_types::settings::interp::InterpString; +use fabro_types::settings::run::{ ApprovalMode, DaytonaDockerfileLayer, RunExecutionLayer, RunLayer, RunMode, RunModelLayer, RunSandboxLayer, }; diff --git a/lib/crates/fabro-server/src/serve.rs b/lib/crates/fabro-server/src/serve.rs index c05547e36..7075796c8 100644 --- a/lib/crates/fabro-server/src/serve.rs +++ b/lib/crates/fabro-server/src/serve.rs @@ -17,7 +17,7 @@ use tracing::{error, info, warn}; use clap::Args; -use fabro_types::settings::v2::SettingsFile; +use fabro_types::settings::SettingsFile; use crate::bind::{self, Bind, BindRequest}; use crate::github_webhooks::WebhookManager; @@ -93,12 +93,12 @@ fn apply_serve_overrides( args: &ServeArgs, dry_run_mode: bool, ) -> SettingsFile { - use fabro_types::settings::v2::cli::CliLayer; - use fabro_types::settings::v2::interp::InterpString; - use fabro_types::settings::v2::run::{ + use fabro_types::settings::cli::CliLayer; + use fabro_types::settings::interp::InterpString; + use fabro_types::settings::run::{ RunExecutionLayer, RunLayer, RunMode, RunModelLayer, RunSandboxLayer, }; - use fabro_types::settings::v2::server::{ServerLayer, ServerWebLayer}; + use fabro_types::settings::server::{ServerLayer, ServerWebLayer}; let mut settings = base.clone(); if dry_run_mode { let run = settings.run.get_or_insert_with(RunLayer::default); @@ -136,8 +136,8 @@ fn apply_runtime_settings( dry_run_mode: bool, data_dir: &Path, ) -> SettingsFile { - use fabro_types::settings::v2::interp::InterpString; - use fabro_types::settings::v2::server::{ServerLayer, ServerStorageLayer}; + use fabro_types::settings::interp::InterpString; + use fabro_types::settings::server::{ServerLayer, ServerStorageLayer}; let mut settings = apply_serve_overrides(base, args, dry_run_mode); let server = settings.server.get_or_insert_with(ServerLayer::default); let storage = server @@ -174,8 +174,8 @@ fn build_artifact_object_store( settings: &SettingsFile, storage: &Storage, ) -> anyhow::Result<(Arc, String)> { - use fabro_types::settings::v2::interp::InterpString; - use fabro_types::settings::v2::server::ObjectStoreProvider; + use fabro_types::settings::interp::InterpString; + use fabro_types::settings::server::ObjectStoreProvider; let artifacts = settings.server_artifacts(); let prefix = artifacts @@ -349,7 +349,7 @@ where // Optionally start webhook listener let webhook_app_id = { - use fabro_types::settings::v2::InterpString; + use fabro_types::settings::InterpString; let cfg_file = shared_settings.read().expect("config lock poisoned"); cfg_file .server_integrations_github() @@ -680,7 +680,7 @@ mod tests { }; use crate::bind::Bind; use fabro_config::ConfigLayer; - use fabro_types::settings::v2::SettingsFile; + use fabro_types::settings::SettingsFile; fn parse_settings(source: &str) -> SettingsFile { ConfigLayer::parse(source) diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 14e69da33..618ece3ef 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -33,7 +33,7 @@ use fabro_model::{BilledModelUsage, BilledTokenCounts}; use fabro_store::{ ArtifactStore, Database, EventEnvelope, EventPayload, PendingInterviewRecord, StageId, }; -use fabro_types::settings::v2::{InterpString, SettingsFile}; +use fabro_types::settings::{InterpString, SettingsFile}; use fabro_types::{ EventBody, InterviewQuestionRecord, InterviewQuestionType, RunBlobId, RunClientProvenance, RunControlAction, RunEvent, RunId, RunProvenance, RunServerProvenance, RunSubjectProvenance, @@ -5911,7 +5911,7 @@ mod tests { }"#; fn dry_run_settings() -> SettingsFile { - use fabro_types::settings::v2::run::{RunExecutionLayer, RunLayer, RunMode}; + use fabro_types::settings::run::{RunExecutionLayer, RunLayer, RunMode}; SettingsFile { run: Some(RunLayer { execution: Some(RunExecutionLayer { diff --git a/lib/crates/fabro-server/src/web_auth.rs b/lib/crates/fabro-server/src/web_auth.rs index 909f121c6..2ea86a331 100644 --- a/lib/crates/fabro-server/src/web_auth.rs +++ b/lib/crates/fabro-server/src/web_auth.rs @@ -6,7 +6,7 @@ use axum::http::{HeaderMap, HeaderValue, StatusCode, header}; use axum::response::{IntoResponse, Redirect, Response}; use axum::{Json, Router, routing::get, routing::post}; use cookie::{Cookie, CookieJar, Expiration, Key, SameSite, time::Duration}; -use fabro_types::settings::v2::{InterpString, SettingsFile}; +use fabro_types::settings::{InterpString, SettingsFile}; use serde::{Deserialize, Serialize}; use serde_json::json; use tracing::{debug, error, info, warn}; diff --git a/lib/crates/fabro-server/tests/it/api/routing.rs b/lib/crates/fabro-server/tests/it/api/routing.rs index 8d99c726c..5b8276b06 100644 --- a/lib/crates/fabro-server/tests/it/api/routing.rs +++ b/lib/crates/fabro-server/tests/it/api/routing.rs @@ -6,7 +6,7 @@ use fabro_server::server::{ RouterOptions, build_router, build_router_with_options, create_app_state, create_app_state_with_options, }; -use fabro_types::settings::v2::SettingsFile; +use fabro_types::settings::SettingsFile; use tower::ServiceExt; use crate::helpers::body_json; diff --git a/lib/crates/fabro-server/tests/it/api/settings.rs b/lib/crates/fabro-server/tests/it/api/settings.rs index 51c61b467..fe7e82ee5 100644 --- a/lib/crates/fabro-server/tests/it/api/settings.rs +++ b/lib/crates/fabro-server/tests/it/api/settings.rs @@ -3,7 +3,7 @@ use axum::http::{Request, StatusCode}; use fabro_config::ConfigLayer; use fabro_server::jwt_auth::AuthMode; use fabro_server::server::{build_router, create_app_state_with_options}; -use fabro_types::settings::v2::SettingsFile; +use fabro_types::settings::SettingsFile; use tower::ServiceExt; use crate::helpers::body_json; diff --git a/lib/crates/fabro-server/tests/it/api/system.rs b/lib/crates/fabro-server/tests/it/api/system.rs index afe69ce6f..52191a688 100644 --- a/lib/crates/fabro-server/tests/it/api/system.rs +++ b/lib/crates/fabro-server/tests/it/api/system.rs @@ -4,10 +4,10 @@ use axum::body::Body; use axum::http::{Request, StatusCode}; use fabro_config::Storage; use fabro_types::RunId; -use fabro_types::settings::v2::SettingsFile; -use fabro_types::settings::v2::interp::InterpString; -use fabro_types::settings::v2::run::{RunExecutionLayer, RunLayer, RunMode}; -use fabro_types::settings::v2::server::{ServerLayer, ServerStorageLayer}; +use fabro_types::settings::SettingsFile; +use fabro_types::settings::interp::InterpString; +use fabro_types::settings::run::{RunExecutionLayer, RunLayer, RunMode}; +use fabro_types::settings::server::{ServerLayer, ServerStorageLayer}; use http_body_util::BodyExt; use std::path::PathBuf; use tempfile::tempdir; diff --git a/lib/crates/fabro-server/tests/it/helpers.rs b/lib/crates/fabro-server/tests/it/helpers.rs index 6230d10ce..4487235a0 100644 --- a/lib/crates/fabro-server/tests/it/helpers.rs +++ b/lib/crates/fabro-server/tests/it/helpers.rs @@ -8,8 +8,8 @@ use fabro_server::server::{ AppState, build_router, create_app_state, create_app_state_with_settings_and_registry_factory, spawn_scheduler, }; -use fabro_types::settings::v2::SettingsFile; -use fabro_types::settings::v2::run::{ +use fabro_types::settings::SettingsFile; +use fabro_types::settings::run::{ LocalSandboxLayer, RunExecutionLayer, RunLayer, RunMode, RunSandboxLayer, WorktreeMode, }; use tokio::time::sleep; diff --git a/lib/crates/fabro-store/src/slate/mod.rs b/lib/crates/fabro-store/src/slate/mod.rs index 4711e4a78..813f3b05d 100644 --- a/lib/crates/fabro-store/src/slate/mod.rs +++ b/lib/crates/fabro-store/src/slate/mod.rs @@ -233,7 +233,7 @@ mod tests { use super::*; use chrono::{DateTime, Utc}; - use fabro_types::settings::v2::SettingsFile; + use fabro_types::settings::SettingsFile; use fabro_types::{AttrValue, Graph, RunControlAction, RunRecord, RunStatus, StatusReason}; use futures::TryStreamExt; use object_store::memory::InMemory; diff --git a/lib/crates/fabro-types/src/run.rs b/lib/crates/fabro-types/src/run.rs index 81d6bb670..5ab442a6f 100644 --- a/lib/crates/fabro-types/src/run.rs +++ b/lib/crates/fabro-types/src/run.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize}; use crate::graph::Graph; use crate::run_blob_id::RunBlobId; use crate::run_id::RunId; -use crate::settings::v2::SettingsFile; +use crate::settings::SettingsFile; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] diff --git a/lib/crates/fabro-types/src/run_event/run.rs b/lib/crates/fabro-types/src/run_event/run.rs index 6ec24c606..75fda5db0 100644 --- a/lib/crates/fabro-types/src/run_event/run.rs +++ b/lib/crates/fabro-types/src/run_event/run.rs @@ -2,7 +2,7 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; -use crate::settings::v2::SettingsFile; +use crate::settings::SettingsFile; use crate::{Graph, RunBlobId, RunControlAction, RunProvenance, StatusReason}; use super::{BilledTokenCounts, RunNoticeLevel}; diff --git a/lib/crates/fabro-types/src/settings/accessors.rs b/lib/crates/fabro-types/src/settings/accessors.rs index 4d496ca79..c16671dac 100644 --- a/lib/crates/fabro-types/src/settings/accessors.rs +++ b/lib/crates/fabro-types/src/settings/accessors.rs @@ -413,7 +413,7 @@ impl SettingsFile { #[cfg(test)] mod tests { use super::*; - use crate::settings::v2::run::{RunLayer, RunModelLayer}; + use crate::settings::run::{RunLayer, RunModelLayer}; #[test] fn run_goal_str_returns_source_value() { @@ -453,8 +453,8 @@ mod tests { #[test] fn all_labels_merges_project_workflow_run() { - use crate::settings::v2::project::ProjectLayer; - use crate::settings::v2::workflow::WorkflowLayer; + use crate::settings::project::ProjectLayer; + use crate::settings::workflow::WorkflowLayer; let mut project_metadata = HashMap::new(); project_metadata.insert("env".into(), "project".into()); diff --git a/lib/crates/fabro-types/src/settings/mod.rs b/lib/crates/fabro-types/src/settings/mod.rs index 016922ca8..c1ff000b9 100644 --- a/lib/crates/fabro-types/src/settings/mod.rs +++ b/lib/crates/fabro-types/src/settings/mod.rs @@ -39,10 +39,3 @@ pub use splice_array::{SPLICE_MARKER, SpliceArray, SpliceArrayError}; pub use tree::{ParseError, SettingsFile, parse_settings_file}; pub use version::{CURRENT_VERSION, SchemaVersion, VersionError, validate_version}; pub use workflow::WorkflowLayer; - -/// Transitional alias for code still using `fabro_types::settings::v2::*` -/// paths. The whole `v2` namespace is scheduled for removal once the -/// workspace-wide sweep (Stage 6.5b follow-up) is done. -pub mod v2 { - pub use super::*; -} diff --git a/lib/crates/fabro-workflow/src/config.rs b/lib/crates/fabro-workflow/src/config.rs index aba443698..c11478d16 100644 --- a/lib/crates/fabro-workflow/src/config.rs +++ b/lib/crates/fabro-workflow/src/config.rs @@ -1,11 +1,11 @@ //! Workflow runtime configuration shapes. //! //! Runtime-side types consumed by the pipeline. The v2 parse tree lives in -//! `fabro_types::settings::v2::run::{RunPullRequestLayer, MergeStrategy, +//! `fabro_types::settings::run::{RunPullRequestLayer, MergeStrategy, //! RunArtifactsLayer}`. Conversion from v2 lives in [`bridge_pull_request`] //! / [`bridge_run_artifacts`]. -use fabro_types::settings::v2::run::{ +use fabro_types::settings::run::{ MergeStrategy as V2MergeStrategy, RunArtifactsLayer, RunPullRequestLayer, }; use serde::{Deserialize, Serialize}; diff --git a/lib/crates/fabro-workflow/src/git.rs b/lib/crates/fabro-workflow/src/git.rs index feaec45a8..153a38228 100644 --- a/lib/crates/fabro-workflow/src/git.rs +++ b/lib/crates/fabro-workflow/src/git.rs @@ -2,7 +2,7 @@ use std::path::Path; use std::process::Command; use fabro_checkpoint::git::Store; -use fabro_types::settings::v2::SettingsFile; +use fabro_types::settings::SettingsFile; use crate::error::{FabroError, Result}; use tokio::task::{JoinError, spawn_blocking}; diff --git a/lib/crates/fabro-workflow/src/handler/manager_loop.rs b/lib/crates/fabro-workflow/src/handler/manager_loop.rs index 6878a6c1e..43cb95b74 100644 --- a/lib/crates/fabro-workflow/src/handler/manager_loop.rs +++ b/lib/crates/fabro-workflow/src/handler/manager_loop.rs @@ -18,7 +18,7 @@ use crate::run_options::RunOptions; use async_trait::async_trait; use fabro_graphviz::graph::{AttrValue, Graph, Node}; use fabro_store::{ArtifactStore, Database}; -use fabro_types::settings::v2::SettingsFile; +use fabro_types::settings::SettingsFile; use object_store::memory::InMemory; use tokio::time::{sleep, timeout}; diff --git a/lib/crates/fabro-workflow/src/operations/create.rs b/lib/crates/fabro-workflow/src/operations/create.rs index c417c6631..f087698cf 100644 --- a/lib/crates/fabro-workflow/src/operations/create.rs +++ b/lib/crates/fabro-workflow/src/operations/create.rs @@ -3,8 +3,8 @@ use fabro_graphviz::graph::{AttrValue, Graph}; use fabro_model::{Catalog, Provider}; use fabro_sandbox::SandboxProvider; use fabro_store::Database; -use fabro_types::settings::v2::run::{RunLayer, RunModelLayer}; -use fabro_types::settings::v2::{InterpString, SettingsFile}; +use fabro_types::settings::run::{RunLayer, RunModelLayer}; +use fabro_types::settings::{InterpString, SettingsFile}; use fabro_types::{RunId, RunProvenance}; use std::collections::BTreeMap; use std::collections::HashMap; @@ -559,7 +559,7 @@ mod tests { start -> work -> exit }"#; let validated = validate_dot(dot, { - use fabro_types::settings::v2::run::RunLayer; + use fabro_types::settings::run::RunLayer; let mut inputs = std::collections::HashMap::new(); inputs.insert("who".to_string(), toml::Value::String("agent".to_string())); SettingsFile { @@ -765,7 +765,7 @@ mod tests { base_dir: None, }, settings: { - use fabro_types::settings::v2::run::{ + use fabro_types::settings::run::{ RunExecutionLayer, RunLayer, RunMode, RunModelLayer, RunPullRequestLayer, }; let mut metadata = HashMap::new(); @@ -871,7 +871,7 @@ mod tests { base_dir: None, }, settings: { - use fabro_types::settings::v2::run::{RunExecutionLayer, RunLayer, RunMode}; + use fabro_types::settings::run::{RunExecutionLayer, RunLayer, RunMode}; SettingsFile { run: Some(RunLayer { working_dir: Some(InterpString::parse("workspace")), @@ -947,7 +947,7 @@ mod tests { } fn dry_run_only_settings() -> SettingsFile { - use fabro_types::settings::v2::run::{RunExecutionLayer, RunLayer, RunMode}; + use fabro_types::settings::run::{RunExecutionLayer, RunLayer, RunMode}; SettingsFile { run: Some(RunLayer { execution: Some(RunExecutionLayer { @@ -961,8 +961,8 @@ mod tests { } fn dry_run_with_storage(storage_dir: &Path) -> SettingsFile { - use fabro_types::settings::v2::run::{RunExecutionLayer, RunLayer, RunMode}; - use fabro_types::settings::v2::server::{ServerLayer, ServerStorageLayer}; + use fabro_types::settings::run::{RunExecutionLayer, RunLayer, RunMode}; + use fabro_types::settings::server::{ServerLayer, ServerStorageLayer}; SettingsFile { run: Some(RunLayer { execution: Some(RunExecutionLayer { diff --git a/lib/crates/fabro-workflow/src/operations/rebuild_meta.rs b/lib/crates/fabro-workflow/src/operations/rebuild_meta.rs index 029ab2a49..93f920310 100644 --- a/lib/crates/fabro-workflow/src/operations/rebuild_meta.rs +++ b/lib/crates/fabro-workflow/src/operations/rebuild_meta.rs @@ -335,7 +335,7 @@ mod tests { use chrono::{TimeZone, Utc}; use fabro_graphviz::graph::Graph; use fabro_store::{Database, StageId}; - use fabro_types::settings::v2::SettingsFile; + use fabro_types::settings::SettingsFile; use fabro_types::{RunId, RunRecord, SandboxRecord, StartRecord, fixtures}; use object_store::memory::InMemory; use std::collections::HashMap; diff --git a/lib/crates/fabro-workflow/src/operations/source.rs b/lib/crates/fabro-workflow/src/operations/source.rs index d91d0b5fe..9f8206dfc 100644 --- a/lib/crates/fabro-workflow/src/operations/source.rs +++ b/lib/crates/fabro-workflow/src/operations/source.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use anyhow::Context; use fabro_config::project as project_config; -use fabro_types::settings::v2::{InterpString, SettingsFile}; +use fabro_types::settings::{InterpString, SettingsFile}; use fabro_util::path::expand_tilde; use crate::file_resolver::{FileResolver, FilesystemFileResolver}; @@ -168,7 +168,7 @@ mod tests { #[test] fn resolve_workflow_uses_explicit_cwd_for_relative_work_dir() { - use fabro_types::settings::v2::run::RunLayer; + use fabro_types::settings::run::RunLayer; let dir = tempfile::tempdir().unwrap(); let resolved = resolve_workflow(ResolveWorkflowInput { diff --git a/lib/crates/fabro-workflow/src/operations/start.rs b/lib/crates/fabro-workflow/src/operations/start.rs index 9d120e679..9fefa5ff0 100644 --- a/lib/crates/fabro-workflow/src/operations/start.rs +++ b/lib/crates/fabro-workflow/src/operations/start.rs @@ -14,8 +14,8 @@ use fabro_sandbox::config::{ }; use fabro_sandbox::{SandboxProvider, SandboxSpec}; use fabro_types::RunId; -use fabro_types::settings::v2::run::ModelRefOrSplice; -use fabro_types::settings::v2::{InterpString, SettingsFile}; +use fabro_types::settings::run::ModelRefOrSplice; +use fabro_types::settings::{InterpString, SettingsFile}; use crate::config::{PullRequestSettings, bridge_pull_request}; @@ -852,7 +852,7 @@ mod tests { use chrono::Utc; use fabro_store::Database; use fabro_types::fixtures; - use fabro_types::settings::v2::run::{RunExecutionLayer, RunLayer, RunMode}; + use fabro_types::settings::run::{RunExecutionLayer, RunLayer, RunMode}; use object_store::memory::InMemory; use super::*; diff --git a/lib/crates/fabro-workflow/src/operations/validate.rs b/lib/crates/fabro-workflow/src/operations/validate.rs index 059d8372d..24feb395f 100644 --- a/lib/crates/fabro-workflow/src/operations/validate.rs +++ b/lib/crates/fabro-workflow/src/operations/validate.rs @@ -1,6 +1,6 @@ use std::path::PathBuf; -use fabro_types::settings::v2::SettingsFile; +use fabro_types::settings::SettingsFile; use crate::error::FabroError; use crate::pipeline::Validated; diff --git a/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs b/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs index 547502d50..c03d18480 100644 --- a/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs +++ b/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs @@ -13,7 +13,7 @@ use fabro_hooks::HookSettings; use fabro_interview::AutoApproveInterviewer; use fabro_sandbox::SandboxSpec; use fabro_store::Database; -use fabro_types::settings::v2::SettingsFile; +use fabro_types::settings::SettingsFile; use fabro_types::{RunId, fixtures}; use object_store::memory::InMemory; diff --git a/lib/crates/fabro-workflow/src/pipeline/finalize.rs b/lib/crates/fabro-workflow/src/pipeline/finalize.rs index 6c75b63ee..33425e5c6 100644 --- a/lib/crates/fabro-workflow/src/pipeline/finalize.rs +++ b/lib/crates/fabro-workflow/src/pipeline/finalize.rs @@ -306,7 +306,7 @@ mod tests { use fabro_graphviz::graph::Graph; use fabro_store::Database; - use fabro_types::settings::v2::SettingsFile; + use fabro_types::settings::SettingsFile; use fabro_types::{RunId, fixtures}; use object_store::memory::InMemory; diff --git a/lib/crates/fabro-workflow/src/pipeline/initialize.rs b/lib/crates/fabro-workflow/src/pipeline/initialize.rs index a39738310..7eba23f4a 100644 --- a/lib/crates/fabro-workflow/src/pipeline/initialize.rs +++ b/lib/crates/fabro-workflow/src/pipeline/initialize.rs @@ -534,7 +534,7 @@ pub async fn initialize( build_registry(&options.llm, Arc::clone(&options.interviewer), &env, &graph).await? }; if effective_dry_run { - use fabro_types::settings::v2::run::{RunExecutionLayer, RunLayer, RunMode}; + use fabro_types::settings::run::{RunExecutionLayer, RunLayer, RunMode}; options.dry_run = true; let run = options @@ -688,7 +688,7 @@ mod tests { use fabro_interview::AutoApproveInterviewer; use fabro_sandbox::SandboxSpec; use fabro_store::Database; - use fabro_types::settings::v2::SettingsFile; + use fabro_types::settings::SettingsFile; use fabro_types::{RunId, fixtures}; use object_store::memory::InMemory; diff --git a/lib/crates/fabro-workflow/src/pipeline/persist.rs b/lib/crates/fabro-workflow/src/pipeline/persist.rs index 8ed19a9ea..68537edb3 100644 --- a/lib/crates/fabro-workflow/src/pipeline/persist.rs +++ b/lib/crates/fabro-workflow/src/pipeline/persist.rs @@ -55,9 +55,9 @@ mod tests { use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node}; use fabro_store::{Database, RunDatabase}; use fabro_types::fixtures; - use fabro_types::settings::v2::SettingsFile; - use fabro_types::settings::v2::cli::{CliLayer, CliOutputLayer, OutputVerbosity}; - use fabro_types::settings::v2::run::{RunExecutionLayer, RunLayer, RunMode}; + use fabro_types::settings::SettingsFile; + use fabro_types::settings::cli::{CliLayer, CliOutputLayer, OutputVerbosity}; + use fabro_types::settings::run::{RunExecutionLayer, RunLayer, RunMode}; use object_store::memory::InMemory; use std::sync::Arc; use std::time::Duration; diff --git a/lib/crates/fabro-workflow/src/pipeline/pull_request.rs b/lib/crates/fabro-workflow/src/pipeline/pull_request.rs index 97ed9aaff..d00093cb3 100644 --- a/lib/crates/fabro-workflow/src/pipeline/pull_request.rs +++ b/lib/crates/fabro-workflow/src/pipeline/pull_request.rs @@ -595,7 +595,7 @@ mod tests { AggregateStats, FrictionKind, FrictionPoint, OpenItem, OpenItemKind, StageRetro, }; use fabro_store::Database; - use fabro_types::settings::v2::SettingsFile; + use fabro_types::settings::SettingsFile; use fabro_types::{BilledTokenCounts, RunRecord, fixtures}; use futures::stream; use object_store::memory::InMemory; diff --git a/lib/crates/fabro-workflow/src/pipeline/retro.rs b/lib/crates/fabro-workflow/src/pipeline/retro.rs index ab71a75c0..82db57ebd 100644 --- a/lib/crates/fabro-workflow/src/pipeline/retro.rs +++ b/lib/crates/fabro-workflow/src/pipeline/retro.rs @@ -184,7 +184,7 @@ mod tests { use fabro_graphviz::graph::Graph; use fabro_store::Database; - use fabro_types::settings::v2::SettingsFile; + use fabro_types::settings::SettingsFile; use fabro_types::{RunId, fixtures}; use object_store::memory::InMemory; diff --git a/lib/crates/fabro-workflow/src/run_lookup.rs b/lib/crates/fabro-workflow/src/run_lookup.rs index 10e0746cd..ed0546c2c 100644 --- a/lib/crates/fabro-workflow/src/run_lookup.rs +++ b/lib/crates/fabro-workflow/src/run_lookup.rs @@ -6,7 +6,7 @@ use chrono::{DateTime, Utc}; use fabro_config::Storage; use fabro_store::{Database, RunSummary}; use fabro_types::RunId; -use fabro_types::settings::v2::SettingsFile; +use fabro_types::settings::SettingsFile; use serde::Serialize; use crate::operations::make_run_dir; @@ -397,7 +397,7 @@ mod tests { use fabro_graphviz::graph::Graph; use fabro_store::Database; - use fabro_types::settings::v2::SettingsFile; + use fabro_types::settings::SettingsFile; use fabro_types::{RunStatus, fixtures}; use object_store::memory::InMemory; diff --git a/lib/crates/fabro-workflow/src/run_options.rs b/lib/crates/fabro-workflow/src/run_options.rs index dabceaa28..ddcf93cb2 100644 --- a/lib/crates/fabro-workflow/src/run_options.rs +++ b/lib/crates/fabro-workflow/src/run_options.rs @@ -4,8 +4,8 @@ use std::sync::Arc; use std::sync::atomic::AtomicBool; use fabro_types::RunId; -use fabro_types::settings::v2::SettingsFile; -use fabro_types::settings::v2::run::RunPullRequestLayer; +use fabro_types::settings::SettingsFile; +use fabro_types::settings::run::RunPullRequestLayer; use crate::git::{GitAuthor, git_author_from_settings}; diff --git a/lib/crates/fabro-workflow/src/runtime_store.rs b/lib/crates/fabro-workflow/src/runtime_store.rs index 860e33340..065203e26 100644 --- a/lib/crates/fabro-workflow/src/runtime_store.rs +++ b/lib/crates/fabro-workflow/src/runtime_store.rs @@ -113,7 +113,7 @@ mod tests { use fabro_store::Database; use fabro_types::fixtures; use fabro_types::run_event::RunSubmittedProps; - use fabro_types::settings::v2::SettingsFile; + use fabro_types::settings::SettingsFile; use fabro_types::{EventBody, RunEvent}; use object_store::memory::InMemory; diff --git a/lib/crates/fabro-workflow/tests/it/daytona_integration.rs b/lib/crates/fabro-workflow/tests/it/daytona_integration.rs index 7ba0b0189..b206f23cb 100644 --- a/lib/crates/fabro-workflow/tests/it/daytona_integration.rs +++ b/lib/crates/fabro-workflow/tests/it/daytona_integration.rs @@ -22,8 +22,8 @@ use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node}; use fabro_llm::provider::Provider; use fabro_sandbox::daytona::{DaytonaConfig, DaytonaSandbox, DaytonaSnapshotConfig}; use fabro_store::{ArtifactStore, Database}; -use fabro_types::settings::v2::SettingsFile; -use fabro_types::settings::v2::run::{RunArtifactsLayer, RunLayer}; +use fabro_types::settings::SettingsFile; +use fabro_types::settings::run::{RunArtifactsLayer, RunLayer}; use fabro_types::{RunId, StageId}; use fabro_workflow::artifact::sync_artifacts_to_env; use fabro_workflow::context::Context; diff --git a/lib/crates/fabro-workflow/tests/it/integration.rs b/lib/crates/fabro-workflow/tests/it/integration.rs index abf857568..b3c440fc4 100644 --- a/lib/crates/fabro-workflow/tests/it/integration.rs +++ b/lib/crates/fabro-workflow/tests/it/integration.rs @@ -26,8 +26,8 @@ use fabro_interview::{ }; use fabro_llm::provider::Provider; use fabro_store::{ArtifactStore, Database}; -use fabro_types::settings::v2::SettingsFile; -use fabro_types::settings::v2::run::{RunArtifactsLayer, RunLayer}; +use fabro_types::settings::SettingsFile; +use fabro_types::settings::run::{RunArtifactsLayer, RunLayer}; use fabro_types::{RunEvent, RunId, StageId}; use fabro_validate::{Severity, validate, validate_or_raise}; use fabro_workflow::context::Context; @@ -6019,7 +6019,7 @@ mod real_llm { use async_trait::async_trait; use fabro_graphviz::graph::Node; - use fabro_types::settings::v2::SettingsFile; + use fabro_types::settings::SettingsFile; use fabro_workflow::context::Context; use fabro_workflow::error::FabroError; use fabro_workflow::handler::agent::{AgentHandler, CodergenBackend, CodergenResult}; @@ -8091,7 +8091,7 @@ async fn hook_config_merge_run_overrides_by_name() { // The legacy `Settings`-based TOML parsing tests were deleted in Stage // 6.3b. Hook TOML parsing now flows through the v2 `SettingsFile` path, -// with coverage in `fabro-types::settings::v2::tree::tests` and the +// with coverage in `fabro-types::settings::tree::tests` and the // fabro-cli integration tests under `cmd::config`. // --- Blocking vs non-blocking behavior --- @@ -8241,7 +8241,7 @@ async fn hook_sandbox_false_runs_on_host() { // Prompt and Agent hook TOML parsing: the legacy `Settings`-based // variant of this test was deleted in Stage 6.3b; v2 coverage lives in -// `fabro-types::settings::v2::tree::tests`. +// `fabro-types::settings::tree::tests`. // --- Events emitted correctly alongside hooks --- From 345d43721aaa6b8e5f1771a919105054e74e4fbe Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 9 Apr 2026 18:44:13 -0400 Subject: [PATCH 38/47] =?UTF-8?q?docs(plans):=20write=20Stage=206=20wrap-u?= =?UTF-8?q?p=20handoff=20=E2=80=94=20all=20substages=20complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures the full end-state after this session finished the consumer-migration pass through 6.3b, flattened the v2 directory (6.5b), rewrote the auth resolver (6.6g), and closed out the last scoped TODOs from handoff-2. Nothing left in Stage 6. Next work is either from the deferred list (setup_register toml_edit upgrade, ModelRegistry for fallback chains, goal_file schema decision, fail-closed server posture, centralized env interp pass, optional OpenAPI formalization) or driven by new requirements. Co-Authored-By: Claude Opus 4.6 (1M context) --- ...-04-09-settings-toml-redesign-handoff-4.md | 265 ++++++++++++++++++ 1 file changed, 265 insertions(+) create mode 100644 docs/plans/2026-04-09-settings-toml-redesign-handoff-4.md diff --git a/docs/plans/2026-04-09-settings-toml-redesign-handoff-4.md b/docs/plans/2026-04-09-settings-toml-redesign-handoff-4.md new file mode 100644 index 000000000..17610bb3f --- /dev/null +++ b/docs/plans/2026-04-09-settings-toml-redesign-handoff-4.md @@ -0,0 +1,265 @@ +--- +date: 2026-04-09 +status: complete +topic: settings-toml-redesign +predecessor: docs/plans/2026-04-09-settings-toml-redesign-handoff-3.md +--- + +# Settings TOML Redesign — Handoff 4 (Stage 6 complete) + +## TL;DR + +**Stage 6 is done.** Every substage from 6.1 through 6.6j is +complete. The legacy flat `Settings` parse tree, its `Combine`-driven +layering, the `bridge_to_old` seam, the seven runtime type modules, +and the transitional `v2/` subdirectory are all deleted. The +`fabro_types::settings` module is now flat and v2-native. + +3,758 workspace tests pass. `cargo fmt --check --all`, +`cargo clippy --workspace -- -D warnings`, and +`cd apps/fabro-web && bun run typecheck && bun test && bun run build` +are all green. + +There is no remaining Stage 6 work to hand off. Any follow-ups from +here are *new* decisions (see "Deferred / new work" below). + +## What landed in this wrap-up session + +Fifteen commits on `main` on top of handoff-3's starting point: + +``` +c625747e0 refactor(settings): stage 6.5b sweep ::v2:: prefix out of consumers +d82d167f0 refactor(settings): stage 6.6g rewrite auth resolver for v2 +15b799fb3 refactor(settings): stage 6.3b + 6.5b finish — delete last legacy server types and flatten v2/ +3ac7ab903 refactor(settings): stage 6.3b shrink server runtime types + delete Combine +7f9640aac refactor(settings): stage 6.3b promote run runtime types + delete to_runtime +6df8bbeb3 refactor(settings): stage 6.3b promote sandbox runtime types into fabro-sandbox +38dacb874 refactor(settings): stage 6.3b promote mcp runtime types into fabro-mcp +2016c8e94 refactor(settings): stage 6.3b promote hook + project runtime types +db45511ff refactor(settings): stage 6.3b promote user runtime types into consumers +``` + +Each commit is small, test-green, and self-contained. The migration +walked one consumer crate at a time through the consumer migration +map from handoff-3. + +### Stage 6.3b complete — runtime type module tree deletion + +Every consumer that used to import from +`fabro_types::settings::{hook, mcp, project, run, sandbox, server, +user}` now owns its runtime types locally: + +| Old module | Runtime types moved to | +|---|---| +| `hook.rs` | `fabro-hooks/src/config.rs` | +| `mcp.rs` | `fabro-mcp/src/config.rs` | +| `sandbox.rs` | `fabro-sandbox/src/config.rs` | +| `run.rs` → `PullRequestSettings`, `MergeStrategy`, `ArtifactsSettings` | `fabro-workflow/src/config.rs` | +| `user.rs` → `OutputFormat`, `PermissionLevel` | `fabro-agent/src/cli.rs` | +| `user.rs` → `ClientTlsSettings` | `fabro-cli/src/user_config.rs` | +| `server.rs` → `ApiAuthStrategy`, `ApiSettings`, `TlsSettings` | `fabro-server/src/jwt_auth.rs` (temporarily; see 6.6g) | +| `project.rs` | deleted (`ProjectSettings` was dead) | + +Dead types deleted outright (no consumers remained): + +- From `run.rs`: `LlmSettings`, `SetupSettings`, `CheckpointSettings`, + `GitHubSettings`. +- From `user.rs`: `ExecSettings`, legacy `ServerSettings`. +- From `server.rs`: `AuthProvider`, `AuthSettings`, `GitProvider`, + `GitSettings`, `GitAuthorSettings`, `WebSettings`, `WebhookSettings`, + `WebhookStrategy`, `SlackSettings`, `FeaturesSettings`, + `LogSettings`, `ArtifactStorageBackend`, `ArtifactStorageSettings`. + +Narrow v2→runtime bridge helpers that used to live in +`fabro-types::settings::v2::to_runtime` moved alongside their target +types: + +- `bridge_hook` → `fabro_hooks::config::bridge_hook` +- `bridge_mcp_entry` / `bridge_mcps` → `fabro_mcp::config::*` +- `bridge_sandbox` / `bridge_worktree_mode` → `fabro_sandbox::config::*` +- `bridge_pull_request` / `bridge_merge_strategy` / `bridge_run_artifacts` + → `fabro_workflow::config::*` + +`fabro-types/src/settings/v2/to_runtime.rs` is deleted. + +### `Combine` trait machinery deleted + +- `lib/crates/fabro-types/src/combine.rs` — deleted. +- `pub mod combine;` / `pub use fabro_macros::Combine;` removed from + `fabro-types/src/lib.rs`. +- `#[proc_macro_derive(Combine)]` and its `syn::{Data, DeriveInput, + Fields}` imports removed from `fabro-macros/src/lib.rs`. The + `e2e_test` attribute macro is untouched. + +### Stage 6.5b complete — v2 directory flatten + +- `git mv lib/crates/fabro-types/src/settings/v2/*.rs + lib/crates/fabro-types/src/settings/` +- `lib/crates/fabro-types/src/settings/v2/` — deleted. +- `settings/mod.rs` absorbs the old `v2/mod.rs` declarations and + re-exports (accessors, cli, duration, features, interp, model_ref, + project, run, server, size, splice_array, tree, version, workflow). +- A final workspace sweep rewrote every + `fabro_types::settings::v2::*` import path to + `fabro_types::settings::*` — 53 files, 10 crates. +- The transitional `pub mod v2 { pub use super::*; }` alias is also + deleted; there is no `::v2::` namespace anywhere. + +### Stage 6.6g complete — auth resolver v2-native + +- `resolve_auth_mode_with_lookup` rewritten to take `&SettingsFile` + directly and walk + `settings.server.auth.api.{jwt,mtls}` + + `settings.server.auth.web.allowed_usernames` + + `settings.server.listen.tls`. +- Strategy presence uses the "subtree present unless `enabled = false`" + semantics from R52. +- The `ApiSettings` and `ApiAuthStrategy` shim types and the + `build_legacy_api_settings` helper in `serve.rs` are **deleted** + (~60 LOC). +- `TlsSettings` survives as a local helper in + `fabro-server/src/jwt_auth.rs` with a + `TlsSettings::from_settings(&SettingsFile)` constructor that + projects `server.listen.tls` into the resolved triple. It's only + used by `tls.rs`'s rustls builder and the mTLS integration test. +- `serve.rs`'s bootstrap now calls the new resolver directly. + +## Final status of every Stage 6 substage + +| Substage | Status | +|---|---| +| 6.1 — Migrate consumers off flat `Settings` | ✅ COMPLETE (predecessor session) | +| 6.2 — Delete `bridge_to_old` seam | ✅ COMPLETE (predecessor session) | +| 6.3 — Delete legacy flat `Settings` helpers | ✅ COMPLETE (predecessor session) | +| 6.3b — Delete `Settings` struct + 7 runtime type modules | ✅ **COMPLETE** | +| 6.4 — Delete `fabro-config` re-export shims | ✅ COMPLETE (predecessor session) | +| 6.5 — Promote v2 types to top-level `settings::*` re-exports | ✅ COMPLETE (predecessor session) | +| 6.5b — Flatten `settings/v2/*.rs` → `settings/*.rs` | ✅ **COMPLETE** | +| 6.6a/b — Design allow-list DTOs in OpenAPI | ✅ COMPLETE (this session, prior) | +| 6.6c — Regenerate Rust + TS clients | ✅ COMPLETE (this session, prior) | +| 6.6d — Rewrite `get_server_settings` with redaction | ✅ COMPLETE (this session, prior) | +| 6.6e — Rewrite `get_run_settings` handler | ✅ COMPLETE (this session, prior) | +| 6.6f — Migrate `retrieve_server_settings` in fabro-cli | ✅ COMPLETE (this session, prior) | +| 6.6g — Rewrite auth resolver for v2 | ✅ **COMPLETE** | +| 6.6h — Update fabro-web `workflow-detail.tsx` DTO literal | ✅ COMPLETE (this session, prior) | +| 6.6i — Migrate demo routes to v2 | ✅ COMPLETE (this session, prior) | +| 6.6j — Rewrite `setup_register` web_auth flow | ✅ **COMPLETE** (was already v2-writing after predecessor session; TODO-12's dead `settings_file` binding turned out to not exist anymore) | + +## Scoped TODO status (from handoff-2) + +| TODO | Subject | Final status | +|---|---|---| +| TODO-1 | `legacy_settings_to_v2` shim in fabro-cli | ✅ Deleted | +| TODO-2 | `build_legacy_api_settings` in fabro-server | ✅ Deleted (6.6g) | +| TODO-3 | `get_server_settings` emits raw v2 JSON | ✅ Replaced with redacted DTO | +| TODO-4 | `web_auth.rs` register flow rewrite | ⚠️ **Partial** — the hand-rolled TOML writer now emits v2 shape and is tested. Comment/formatting preservation on round-trip is a nice-to-have left for a follow-up pass; see "Deferred / new work" | +| TODO-5 | `check_crypto` in diagnostics | ✅ Walks v2 listen TLS (predecessor session) | +| TODO-6 | Dead `Combine` trait | ✅ Deleted | +| TODO-7 | Fallback chain bug preserved | ⚠️ **Unchanged** — still preserves pre-migration behavior; needs the runtime `ModelRegistry` implementation | +| TODO-8 | V2 doesn't model `goal_file` | ⚠️ **Unchanged** — needs requirements-level decision | +| TODO-9 | Legacy Settings struct dead weight | ✅ Deleted (6.3b) | +| TODO-10 | Demo routes still emit legacy shape | ✅ Rewritten as v2 JSON | +| TODO-11 | Unused `Settings` import in config tests | ✅ Removed | +| TODO-12 | Unused `settings_file` binding in web_auth.rs | ✅ No such binding exists (already cleaned up) | + +## Deferred / new work + +These are *not* Stage 6 items. They are open questions or new +improvements that came up during the work and are worth considering +separately. + +1. **`setup_register` comment-preserving TOML writes (ex-TODO-4).** + The current hand-rolled writer uses `toml` + `toml::to_string_pretty` + which loses comments and formatting on round-trip. A fix would use + `toml_edit::DocumentMut` (new workspace dependency). Alternatively, + the whole GitHub App registration flow might be better driven from + fabro-web as a dedicated `/api/v1/setup` endpoint instead of living + in `setup_register`. + +2. **Runtime `ModelRegistry` for `ModelRef::resolve` (ex-TODO-7).** + `fabro_types::settings::model_ref::ModelRef::resolve` still takes + a `&dyn ModelRegistry` and errors on ambiguous bare tokens. There's + no runtime implementation against `fabro-model::Catalog`, so the + `resolve_fallback_chain` helper in + `fabro-workflow/src/operations/start.rs` still groups all fallbacks + under the empty-string provider key and never matches. This + preserves pre-migration behavior exactly but isn't the correct + fallback behavior. Open question from predecessor handoff #4. + +3. **`run.goal_file` schema support (ex-TODO-8).** V2 has `run.goal` + as an `InterpString` but no separate `run.goal_file`. The legacy + CLI `--goal-file` flag can't be expressed in v2. Either add a + `run.goal_file` subfield (requirements update) or route file-based + goals through the workflow-manifest layer. + +4. **Fail-closed server auth posture (open question #3).** The + requirements doc R52/R53 specifies that startup should refuse to + run if `server.auth` is absent or resolves to no enabled API/web + strategies, with demo and test helpers opting in explicitly. The + current `resolve_auth_mode_with_lookup` just logs a warning and + builds an `AuthMode::Strategies(empty)`. A follow-up can tighten + this — the hook point is already clean now that 6.6g landed. + +5. **Post-layering env interpolation resolution pass (open question + #2).** `InterpString::resolve` is still called at read time by + each consumer that needs a concrete string. The requirements doc + R79–R81 specifies a centralized pass under + `fabro-config/src/interp_pass.rs` that runs once after layering. + Not implemented in any handoff so far. + +6. **OpenAPI freeform settings DTO vs formal allow-list DTO.** Stage + 6.6a/b chose to declare `ServerSettings` and `RunSettings` as + `type: object, additionalProperties: true` freeform objects in the + OpenAPI spec, pointing at the Rust `SettingsFile` type for the + shape. This loses client-side type safety in TypeScript (the + generated client returns `{ [key: string]: any }`). A follow-up + could formalize the full v2 `SettingsFile` tree in OpenAPI yaml + (tedious but not hard), or keep the loose shape and provide a + hand-written TypeScript type declaration in + `@qltysh/fabro-api-client` as a convenience. + +7. **`TlsSettings` in `fabro-server/src/jwt_auth.rs`.** This 3-field + struct is the last legacy-shaped leftover. It's technically owned + by the right crate now, but putting it in `jwt_auth.rs` is a + historical artifact — a dedicated `fabro-server/src/tls_config.rs` + module would be a more natural home. Pure cleanup, no urgency. + +## Running verification + +```bash +cargo fmt --check --all +cargo build --workspace +cargo clippy --workspace -- -D warnings +ulimit -n 4096 && cargo nextest run --workspace + +cd apps/fabro-web && bun run typecheck && bun test && bun run build +``` + +All green as of `c625747e0` on `main`: 3,758 tests passed / 0 failed +/ 182 skipped. + +## Success criteria for Stage 6 (all resolved) + +- [x] `git grep 'fabro_types::Settings\b'` returns zero hits. +- [x] `git grep 'bridge_to_old'` returns zero hits. +- [x] `lib/crates/fabro-types/src/settings/v2/` no longer exists as + a subdirectory. +- [x] `lib/crates/fabro-types/src/combine.rs` is deleted. +- [x] `lib/crates/fabro-types/src/settings/{hook,mcp,project,run, + sandbox,user}.rs` legacy runtime modules — deleted. + `settings/server.rs` now exists as the *v2* server layer file + (promoted from `v2/server.rs` in 6.5b). +- [x] `lib/crates/fabro-config/src/{hook,mcp,sandbox,server,run,user}.rs` + deleted or reduced to helpers. +- [x] `docs/api-reference/fabro-api.yaml` `ServerSettings` and + `RunSettings` schemas are not the legacy flat shape. +- [x] `lib/packages/fabro-api-client` and the Rust progenitor client + are regenerated. +- [x] `apps/fabro-web/app/routes/workflow-detail.tsx` `workflowData` + literal matches the new shape. +- [x] `cargo fmt` / `cargo build` / `cargo clippy -D warnings` / + `cargo nextest run --workspace` / `bun run typecheck` / + `bun test` / `bun run build` gates all green. + +Stage 6 is closed. Next work should be driven by the deferred items +list above or by new requirements. From 194fe7997db84c573f458e73108926c9cfd1457c Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 9 Apr 2026 18:51:44 -0400 Subject: [PATCH 39/47] docs(plans): commit stage 6 handoff 2 (previously untracked) This doc was written at the end of the session that landed Stages 6.1-6.5 but never committed; it's been sitting untracked for three follow-up sessions. Handoff docs 3 and 4 both point at it as their predecessor, so it belongs in the tree alongside them. No content change; the file is committed as originally written. Co-Authored-By: Claude Opus 4.6 (1M context) --- ...-04-09-settings-toml-redesign-handoff-2.md | 572 ++++++++++++++++++ 1 file changed, 572 insertions(+) create mode 100644 docs/plans/2026-04-09-settings-toml-redesign-handoff-2.md diff --git a/docs/plans/2026-04-09-settings-toml-redesign-handoff-2.md b/docs/plans/2026-04-09-settings-toml-redesign-handoff-2.md new file mode 100644 index 000000000..eb31b743e --- /dev/null +++ b/docs/plans/2026-04-09-settings-toml-redesign-handoff-2.md @@ -0,0 +1,572 @@ +--- +date: 2026-04-09 +status: active +topic: settings-toml-redesign +predecessor: docs/plans/2026-04-09-settings-toml-redesign-handoff.md +--- + +# Settings TOML Redesign — Handoff 2 (post Stage 6.1–6.5 landing) + +## TL;DR + +Stages 6.1, 6.2, and 6.4 of the Stage 6 follow-up landed cleanly on `main`. +Stages 6.3 and 6.5 are **partially** complete — they each hit a concrete +blocker that requires Stage 6.6 to be done first. Stage 6.6 (OpenAPI DTO +rewrite + fabro-web) is **not started**. + +The workspace builds clean, all 3,756 tests pass, `cargo clippy --workspace +-- -D warnings` and `cargo fmt --check --all` are green. There are known +runtime behavior changes on the `/api/v1/settings` endpoint (see "Known +wire-contract mismatches" below) that will affect fabro-web until 6.6 +lands. + +The main work that remains is: + +1. **Finish Stage 6.6** — rewrite `docs/api-reference/fabro-api.yaml`, + regenerate the Rust progenitor and TypeScript Axios clients, update + `fabro-web/app/routes/workflow-detail.tsx`, and rewrite the server's + `/api/v1/settings` + `/api/v1/runs/:id/settings` handlers to build + allow-list DTOs from the v2 tree without bridging. +2. **Unblock Stage 6.3** — 6.6 removes the last reader of the legacy + flat `Settings` struct (the progenitor-generated `api::types::ServerSettings` + conversion path). Once that's gone, the whole `fabro_types::settings::{hook, + mcp, project, run, sandbox, server, user}` module tree plus + `fabro_types::combine::Combine` can be deleted. +3. **Unblock Stage 6.5** — 6.3's deletion removes the filename collisions + that currently prevent flattening `settings/v2/*.rs` up to `settings/*.rs`. +4. **Revisit scoped TODOs** — see "Scoped TODOs" below. + +## Source documents + +Read these, in this order: + +1. **Requirements (authoritative)** — + [`docs/brainstorms/2026-04-08-settings-toml-redesign-requirements.md`](../brainstorms/2026-04-08-settings-toml-redesign-requirements.md). + Source of truth for the v2 schema, merge matrix (R22 / R30 / R71 etc.), + trust boundaries, disable semantics. Refer to requirement numbers when + making schema decisions. + +2. **Original implementation plan** — + [`docs/plans/2026-04-08-settings-toml-redesign-implementation-plan.md`](./2026-04-08-settings-toml-redesign-implementation-plan.md). + +3. **Stage 6 handoff (predecessor to this doc)** — + [`docs/plans/2026-04-09-settings-toml-redesign-handoff.md`](./2026-04-09-settings-toml-redesign-handoff.md). + This is the doc I worked from. It has the per-stage scope, the file + maps, gotchas, and open design questions. **Still current** for the + remaining work — read it before touching Stage 6.6. + +## Commit trail (landed on main, most recent first) + +``` +ace24c410 refactor(types): stage 6.5 promote v2 types to settings top level +a3fd3b002 refactor(config): stage 6.4 delete fabro-config re-export shims +34a481cd4 refactor(settings): stage 6.3 delete dead Settings helpers + v2 install TOML +ea206e0e4 feat(settings): stage 6.2 delete bridge_to_old seam +52c295cf7 test(settings): update fabro-cli test suite for v2 settings shape +dc856d088 feat(settings): stage 6.1 consumer migration builds workspace-wide +5d9aad85a wip(settings): stage 6.1 consumer migration (broken build) +842ab71eb feat(types): expose bridge helpers and expand v2 accessors +3f32bdb87 feat(types): add SettingsFile convenience accessors +``` + +Total: 81 files changed, +3,718 / −2,423 lines (net +1,295). + +Note: commit `5d9aad85a` was an explicit broken-build WIP checkpoint +the user approved mid-session; `dc856d088` fixes the build. Subsequent +commits are individually test-green. + +## Current-state map (what's in the tree now) + +``` +lib/crates/fabro-types/src/settings/ +├── mod.rs +│ ├── legacy `Settings` struct (flat, _still present_ — see 6.3 status) +│ ├── legacy type re-exports from hook/mcp/project/run/sandbox/server/user +│ └── NEW: pub use v2::{SettingsFile, InterpString, Duration, ...} ← 6.5 +│ +├── hook.rs / mcp.rs / project.rs / run.rs / sandbox.rs / server.rs / user.rs +│ └── LEGACY runtime type definitions, still used (see below) +│ +└── v2/ + ├── mod.rs — module root; no more `bridge_to_old` re-export + ├── tree.rs — SettingsFile top-level + ├── version.rs + ├── project.rs / workflow.rs / run.rs / cli.rs / server.rs / features.rs + ├── duration.rs / size.rs / model_ref.rs / interp.rs / splice_array.rs + ├── accessors.rs — NEW in 6.1 prep; ~35 flat-view accessors on SettingsFile + └── to_runtime.rs — NEW in 6.2; narrow v2→runtime-type helpers + (bridge_sandbox, bridge_mcp_entry, bridge_hook, + bridge_pull_request, bridge_worktree_mode, etc.) + REPLACES the deleted bridge.rs file +``` + +``` +lib/crates/fabro-config/src/ +├── lib.rs — crate root; NEW: top-level `resolve_storage_dir(&SettingsFile)` helper +├── config.rs — ConfigLayer newtype; NO MORE `.resolve()` / TryFrom<...> for Settings +├── merge.rs — v2 merge matrix, unchanged +├── effective_settings.rs — rewritten: returns SettingsFile, v2 merge for server defaults +├── project.rs — resolve_working_directory takes &SettingsFile +├── run.rs — workflow loaders only (parse_run_config / load_run_config / resolve_graph_path) +├── user.rs — machine settings loader + path helpers, no type re-exports +├── home.rs / storage.rs / legacy_env.rs — unchanged +│ +└── DELETED in 6.4: + hook.rs, mcp.rs, sandbox.rs, server.rs +``` + +## Stage-by-stage status + +### 6.1 — Migrate consumers off flat `Settings` ✅ **COMPLETE** + +Every production read site in `fabro-workflow`, `fabro-server`, +`fabro-cli`, and `fabro-config` reads from `SettingsFile` or walks v2 +subtrees via `settings::v2::accessors`. `RunRecord.settings`, +`RunCreatedProps.settings`, `RunOptions.settings`, `CreateRunInput.settings`, +`ValidateInput.settings`, `ResolveWorkflowInput.settings`, +`ResolvedWorkflow.settings`, `AppState.settings`, and +`CommandContext::machine_settings` are all `SettingsFile`-typed. + +Where the `bridge_to_old`-style conversion to a legacy runtime type was +still needed (e.g., `fabro_types::settings::sandbox::SandboxSettings` +for `fabro-sandbox`, `fabro_types::settings::mcp::McpServerEntry` for +`fabro-mcp`, `fabro_types::settings::hook::HookDefinition` for +`fabro-hooks`), the new narrow helpers in +`fabro_types::settings::v2::to_runtime` build them from single v2 +subtrees. Consumers call these explicitly at the point of use. + +### 6.2 — Delete `bridge_to_old` seam ✅ **COMPLETE** + +`lib/crates/fabro-types/src/settings/v2/bridge.rs` (818 LOC) is deleted. +`ConfigLayer::resolve`, `TryFrom for Settings`, and +`TryFrom<&ConfigLayer> for Settings` are deleted. The full-tree +conversion from a v2 `SettingsFile` to a legacy flat `Settings` no +longer exists anywhere in the codebase. + +The narrow runtime-type helpers that the bridge exported as public +functions moved to `fabro_types::settings::v2::to_runtime` and are +scoped per runtime type (one helper per runtime struct, not one +all-in-one converter). They survive until Stage 6.3 deletes the +runtime type targets. + +### 6.3 — Delete legacy flat `Settings` types ⚠️ **PARTIAL (blocked on 6.6)** + +**What landed** (`34a481cd4`): +- Every inherent helper method on the legacy `Settings` struct + (`app_id`, `slug`, `client_id`, `git_author`, `sandbox_settings`, + `setup_settings`, `setup_commands`, `setup_timeout_ms`, + `preserve_sandbox_enabled`, `github_permissions`, `mcp_server_entries`, + `verbose_enabled`, `prevent_idle_sleep_enabled`, `upgrade_check_enabled`, + `dry_run_enabled`, `auto_approve_enabled`, `no_retro_enabled`, + `storage_dir`, `slack_settings`) is deleted. Callers migrated to the + `SettingsFile` accessors with identical names. +- `fabro-cli/src/commands/install.rs::merge_server_settings` now + writes v2 TOML (with `[server.{api,listen.tls,web,auth.api.{jwt,mtls}, + auth.web}]` stanzas). Its tests parse the output through + `ConfigLayer::parse` and assert v2 fields. + +**What did NOT land** (blocked on 6.6): +- The `Settings` struct itself is **still alive** in + `lib/crates/fabro-types/src/settings/mod.rs`. +- All seven legacy runtime type modules (`hook.rs`, `mcp.rs`, + `project.rs`, `run.rs`, `sandbox.rs`, `server.rs`, `user.rs`) are + **still alive** and used by runtime crates. +- The `Combine` trait in `lib/crates/fabro-types/src/combine.rs` is + **still alive** (only used by the legacy type `#[derive(Combine)]` + attributes). +- The `fabro-macros` crate's `Combine` derive macro is **still alive**. + +**Why it's blocked on 6.6**: the progenitor-generated OpenAPI client +in `lib/crates/fabro-api` deserializes `/api/v1/settings` responses +into `api::types::ServerSettings`, which `fabro-cli/src/server_client.rs:: +retrieve_server_settings()` converts to `fabro_types::Settings` via +`convert_type`. That conversion is the only remaining reader of the +flat `Settings` shape in production code. Stage 6.6 rewrites the +OpenAPI spec so the client returns a v2 DTO and this conversion path +goes away. + +**Remaining readers of the legacy `Settings` struct**: +| File | Use | +|---|---| +| `lib/crates/fabro-cli/src/server_client.rs:282` | `retrieve_server_settings` return type | +| `lib/crates/fabro-cli/src/commands/config/mod.rs:93` | `legacy_settings_to_v2` shim (takes `&fabro_types::Settings`) | +| `lib/crates/fabro-cli/src/commands/install.rs` | gone (tests rewritten) | +| `lib/crates/fabro-server/src/demo/mod.rs:1328, 1525` | demo route payloads | +| `lib/crates/fabro-server/src/lib.rs:20` | `pub use fabro_types::Settings;` re-export | +| `lib/crates/fabro-server/src/web_auth.rs:691` | test (or removed — double-check) | +| `lib/crates/fabro-types/src/settings/mod.rs` | definition | + +**Remaining readers of legacy runtime types** (imported via +`fabro_types::settings::{hook,mcp,sandbox,server,user,run}`): +| Consumer crate | Types it imports | +|---|---| +| `fabro-hooks` | `HookDefinition`, `HookEvent`, `HookSettings`, `HookType`, `TlsMode` | +| `fabro-mcp` | `McpServerEntry`, `McpServerSettings`, `McpTransport`, timeouts | +| `fabro-sandbox` | `SandboxSettings`, `DaytonaSettings`, `DaytonaSnapshotSettings`, `DaytonaNetwork`, `LocalSandboxSettings`, `WorktreeMode`, `DockerfileSource` | +| `fabro-checkpoint` | `GitAuthorSettings` (plus the v2 `GitAuthorLayer` via new `From` impl) | +| `fabro-workflow` | `PullRequestSettings`, `MergeStrategy`, `WorktreeMode` | +| `fabro-server` | `ApiSettings`, `TlsSettings`, `ApiAuthStrategy`, `GitSettings`, plus `ServerSettings` for the CLI target | +| `fabro-cli` | `ClientTlsSettings`, `OutputFormat`, `PermissionLevel`, `ExecSettings`, `ServerSettings` | +| `fabro-agent` | `OutputFormat`, `PermissionLevel` (for `AgentArgs`) | + +### 6.4 — Delete `fabro-config` re-export shims ✅ **COMPLETE** + +Files deleted from `lib/crates/fabro-config/src/`: +- `hook.rs`, `mcp.rs`, `sandbox.rs`, `server.rs` (pure pass-throughs) + +Files shrunk: +- `run.rs` — lost the type re-export block and the dead `resolve_env_refs` + helper. Still exports `parse_run_config` / `load_run_config` / + `resolve_graph_path` (used by fabro-cli and fabro-server). +- `user.rs` — lost the runtime type re-export block. Still exports path + helpers, `load_settings_config`, `active_settings_path`, etc. + +`resolve_storage_dir` moved from `fabro-config/src/server.rs` (deleted) +to the crate root in `fabro-config/src/lib.rs`. It takes `&SettingsFile` +now. + +All ~20 consumer crates updated to import runtime types directly from +`fabro_types::settings::{hook,mcp,sandbox,server,user,run}` instead of +`fabro_config::{hook,mcp,sandbox,server,user,run}`. The legacy import +paths no longer compile. + +### 6.5 — Flatten `settings::v2::*` → `settings::*` ⚠️ **PARTIAL (blocked on 6.3)** + +**What landed** (`ace24c410`): +Top-level re-exports of the v2 public surface at `fabro_types::settings`. +Consumers can now write: + +```rust +use fabro_types::settings::{SettingsFile, InterpString, Duration, ...}; +``` + +Covers `{CURRENT_VERSION, CliLayer, Duration, FeaturesLayer, InterpString, +ModelRef, ParseDurationError, ParseError, ParseModelRefError, +ParseSizeError, ProjectLayer, Provenance, ResolveEnvError, Resolved, +ResolvedModelRef, RunLayer, SchemaVersion, ServerLayer, SettingsFile, +Size, SpliceArray, SpliceArrayError, VersionError, WorkflowLayer, +parse_settings_file, validate_version}`. + +**What did NOT land**: +Actually moving the v2/*.rs files up to settings/*.rs. This is blocked +because the v2 submodule filenames (`project.rs`, `run.rs`, `server.rs`, +`cli.rs`) collide with the surviving legacy runtime type files with the +same names. Once Stage 6.3 deletes the legacy files, a trivial follow-up +commit can: + +1. `git mv lib/crates/fabro-types/src/settings/v2/*.rs lib/crates/fabro-types/src/settings/` +2. Delete `lib/crates/fabro-types/src/settings/v2/mod.rs` +3. Update `lib/crates/fabro-types/src/settings/mod.rs` to replace + `pub mod v2;` + the `pub use v2::{...}` block with direct + `pub mod ;` declarations and a `pub use ...::*` re-export pass. +4. Search-and-replace `::v2::` to nothing across the workspace. +5. Update the accessors module and the `to_runtime` module to drop + `super::` / `crate::` adjustments. + +### 6.6 — Rewrite OpenAPI contracts and fabro-web DTOs ⏳ **NOT STARTED** + +See the predecessor doc's Stage 6.6 section for the full scope. Key +points and anything I've learned since: + +**Files to rewrite**: +- `docs/api-reference/fabro-api.yaml`: + - Replace the `ServerSettings` schema (~lines 4238–4364 in the + untouched version) with an explicit allow-list DTO that maps + cleanly onto `SettingsFile`. See the handoff predecessor doc for + the field allow-list guidance (R16 / R52 / R53 constraints). + - Replace the `RunSettings` schema (~lines 3995–4032) similarly. +- Regenerate clients: + - Rust progenitor: `cargo build -p fabro-api` (auto-runs `build.rs`). + - TypeScript: `cd lib/packages/fabro-api-client && bun run generate`. +- `apps/fabro-web/app/routes/workflow-detail.tsx` — rewrite the static + `workflowData` literal to match the new DTO. +- `lib/crates/fabro-server/src/server.rs::get_server_settings` + (around line 1062 — **note: this function was already edited in + Stage 6.2** and now serializes the full v2 `SettingsFile` as JSON via + `serde_json::to_value(&settings)` with `strip_nulls`. That's a + temporary workaround, not the final state — see "Known wire-contract + mismatches" below). Stage 6.6 replaces it with explicit allow-list + DTO construction from the v2 tree. +- `lib/crates/fabro-server/src/server.rs` `/api/v1/runs/:id/settings` + handler — still returns `not_implemented` in the real router. +- `lib/crates/fabro-server/src/demo/mod.rs` — demo routes still emit + legacy Settings shapes. Either migrate to v2 or keep them as the + "legacy demo" path. + +**Known wire-contract mismatches** (will affect fabro-web until 6.6 lands): +1. **`/api/v1/settings` response shape drift**. The server now emits the + v2 `SettingsFile` JSON (e.g., `server.storage.root`, + `run.execution.mode`, `cli.output.verbosity`) directly. The OpenAPI + spec still declares the legacy `ServerSettings` schema (flat + `storage_dir`, `dry_run`, `verbose`). Any client that relies on the + spec will see missing fields or mis-typed values. The browser client + is the main consumer; fabro-cli's `retrieve_server_settings` still + goes through the progenitor client and round-trips through the old + JSON shape — it will break on any v2 field the old schema doesn't + declare. +2. **`openapi_conformance` test**. Still passes because it asserts + progenitor types match the YAML — but both sides are now stale + relative to what the server actually emits. Stage 6.6 should + rewrite this test or update it to cover the new DTOs. + +## Scoped TODOs (stopgap code that needs revisiting) + +Each of these is a deliberate short-term hack with a pointer to where +it should land eventually. They're also marked in-line with +`// Stage 6.x ...` comments. + +### TODO-1: `legacy_settings_to_v2` shim in fabro-cli +**File**: `lib/crates/fabro-cli/src/commands/config/mod.rs:91` +**What**: Reverse mapping from `fabro_types::Settings` → `SettingsFile`. +Covers `server.storage.root`, `server.scheduler.max_concurrent_runs`, +`server.integrations.github.{app_id, client_id, slug}`, +`server.integrations.slack.default_channel`, `run.model.{provider, name}`, +`run.inputs`, and `cli.output.verbosity`. Does **not** cover most other +fields. +**Why**: `server_client::retrieve_server_settings` returns the legacy +shape because the OpenAPI spec hasn't been rewritten. +**Delete when**: Stage 6.6 rewrites the OpenAPI spec and the progenitor +client returns v2 natively. + +### TODO-2: `build_legacy_api_settings` in fabro-server +**File**: `lib/crates/fabro-server/src/serve.rs:91` +**What**: Projects the v2 `server.auth.api.{jwt,mtls}` + `server.listen.tls` +subtrees onto the legacy `ApiSettings` struct that the existing +`resolve_auth_mode_with_lookup` function still expects. +**Why**: The auth resolver in `jwt_auth.rs` hasn't been migrated to v2 +yet. The v2 structure is different enough (no single `authentication_strategies` +enum list; `jwt` and `mtls` are separate subtables with per-strategy +`enabled` booleans) that a rewrite is warranted. +**Delete when**: Stage 6.6 replaces `resolve_auth_mode_with_lookup` with +a v2-aware resolver and deletes the legacy `ApiSettings` type. + +### TODO-3: `get_server_settings` emits raw v2 JSON +**File**: `lib/crates/fabro-server/src/server.rs:1063` +**What**: The `/api/v1/settings` handler now serializes the full v2 +`SettingsFile` as JSON with `strip_nulls` instead of building a +`ServerSettings` DTO. The spec still declares the old DTO. +**Why**: Bridge deletion left no way to produce the old shape without +re-introducing `bridge_to_old`. +**Fix when**: Stage 6.6 rewrites the OpenAPI spec and builds an explicit +allow-list DTO from v2 subtrees. Per R16/R52/R53 in the requirements doc: +- **Allow**: `server.api.url`, `server.web.enabled`, `server.web.url`, + per-provider enabled state under `server.auth.web.providers.*`, + non-secret `server.scheduler` values. +- **Deny**: `server.listen.*`, `server.listen.tls.*`, `server.auth.api`, + `server.integrations.*`, `server.artifacts*`, `server.slatedb*`, any + local `SecretStore` paths, any `InterpString` value whose + `Provenance::EnvSourced` is set. + +### TODO-4: `web_auth.rs` register flow +**File**: `lib/crates/fabro-server/src/web_auth.rs:496-659` +**What**: `setup_register` mutates a v2 TOML document via the new +`merge_settings_keys` helper (which now writes v2 top-level stanzas +under `[server.{web,auth,integrations.github}]`), writes it to disk, +then re-parses it with `ConfigLayer::load` and swaps it into +`state.settings`. +**Why**: Previously the function wrote legacy v1 TOML (top-level +`[web]`/`[api]`/`[git]`) that the v2 parser would reject. It had to be +rewritten to stay functional. +**Still TODO**: Stage 6.6 should decide whether the register flow +belongs in the server at all, or whether the web UI should drive it +directly via the HTTP API and a /api/v1/setup endpoint. The current +implementation is a hand-rolled TOML writer and loses comments / +formatting on round-trip. + +### TODO-5: `check_crypto` in diagnostics walks v2 listen TLS +**File**: `lib/crates/fabro-server/src/diagnostics.rs:469-574` +**What**: Reads `server.auth.api.{jwt,mtls}.enabled` and +`server.listen.tls.{cert,key,ca}` directly from `SettingsFile`. +**Why**: Migrated off the bridge. Works, but the error messages +reference v2 field paths (e.g., "mTLS configured but +[server.listen.tls] is missing"); the `doctor` command hints may need +updating for consistency. +**Fix when**: Opportunistic, no blocker. + +### TODO-6: Retain-or-delete dead `Combine` trait +**Files**: +- `lib/crates/fabro-types/src/combine.rs` +- `lib/crates/fabro-macros/src/lib.rs` (the `Combine` derive) +- Every `#[derive(crate::Combine)]` / `#[derive(Combine)]` on legacy + types in `fabro-types/src/settings/{run,sandbox,server,user}.rs` + + manual impls in `fabro-types/src/settings/mcp.rs`. +**What**: The trait is only used by legacy types for cross-layer +merging that v2's `combine_files` function replaced. Nothing external +calls `.combine()` on a legacy type. +**Delete when**: Stage 6.3 deletes the legacy types. The `Combine` +trait, its derive macro, and the `combine.rs` file all go with them. + +### TODO-7: Fallback chain bug preserved +**File**: `lib/crates/fabro-workflow/src/operations/start.rs:491-525` +**What**: `resolve_fallback_chain` groups all v2 `ModelRef` entries under +the empty-string provider key when building the legacy `HashMap>` that `Catalog::build_fallback_chain` expects. Since +`build_fallback_chain` looks up by `Provider::as_str()` (e.g., +`"anthropic"`), this **always returns an empty chain**. This preserves +the pre-migration behavior exactly. +**Fix when**: The model registry work in the requirements doc lands +(open question #4 in the predecessor handoff). A proper fix groups +fallbacks by actual provider and resolves bare `ModelRef::Bare` tokens +against the catalog. + +### TODO-8: V2 doesn't model `goal_file` +**File**: `lib/crates/fabro-workflow/src/operations/source.rs:150-160` +**What**: V2 has `run.goal` (an `InterpString`) but no separate +`run.goal_file`. The legacy CLI `--goal-file` flag can't be expressed +in v2. The `resolve_goal_override` helper comments on this. +**Fix when**: Either add a `run.goal_file` subfield to the v2 schema +(requires a requirements update), or route file-based goals through +the workflow-manifest layer the way the server-side flow already does. + +### TODO-9: Server settings inherent methods gone but struct serializes legacy field set +**File**: `lib/crates/fabro-types/src/settings/mod.rs:77-146` +**What**: The `Settings` struct still has ~30 fields (`llm`, `sandbox`, +`setup`, `checkpoint`, `hooks`, `mcp_servers`, `github`, `slack`, `api`, +`web`, `features`, `log`, `git`, `fabro`, `storage_dir`, `verbose`, +`prevent_idle_sleep`, `upgrade_check`, `dry_run`, `auto_approve`, +`no_retro`, `max_concurrent_runs`, `artifact_storage`, `exec`, etc.). +These are all dead weight except for the OpenAPI response path and +the demo routes. +**Delete when**: Stage 6.6 rewrites the OpenAPI spec. + +### TODO-10: Demo routes still emit legacy shape +**File**: `lib/crates/fabro-server/src/demo/mod.rs:1327-1560` +**What**: Two big `fabro_types::Settings { ... }` literal constructions +that feed demo mode responses. The demo path isn't wired into the +production API surface (goes through `demo::get_run_settings`). +**Fix when**: Either rewrite as v2 `SettingsFile` literals in Stage 6.6, +or delete the demo path entirely if it's no longer used by fabro-web. + +### TODO-11: `fabro-cli/tests/it/cmd/config.rs` has an unused `Settings` import +**File**: `lib/crates/fabro-cli/tests/it/cmd/config.rs:4` +**What**: `use fabro_types::Settings;` is leftover from an earlier +migration step. If clippy is happy with it (via re-export?), it's +harmless; otherwise remove it. +**Check**: `cargo clippy -p fabro-cli --tests -- -D warnings`. + +### TODO-12: Unused `settings_file` binding after `drop(settings)` +**File**: `lib/crates/fabro-server/src/web_auth.rs:557-570` +**What**: I re-parse the file after writing it and swap into state. +The `settings_file` local binding is the pre-edit snapshot; it's no +longer used. Double-check the function compiles without a warning and +drop the local if it's dead. + +## Scoped open design questions (from the predecessor doc, still open) + +1. **Should `ConfigLayer::resolve(self) -> Settings` survive in any form?** + — It's gone. The natural rename (`into_file(self) -> SettingsFile`) + isn't needed because `From for SettingsFile` already + exists. Consumers call `.into()`. **Decided: no rename.** + +2. **Post-layering env interpolation resolution pass**. Still not + implemented. `InterpString::resolve` is called at read time by each + consumer that needs a concrete string. Stage 6.6's allow-list DTO + construction will need provenance-aware redaction; the missing pass + means each DTO builder has to do its own `.resolve(|name| + std::env::var(name).ok())` + provenance check. The requirements doc + R79–R81 still specifies a centralized pass under + `fabro-config/src/interp_pass.rs`. + +3. **Fail-closed server auth posture**. Still not wired into + `fabro-server/src/server.rs` startup. R52/R53 requires that if + `server.auth` is absent or resolves to no enabled API / web + strategies, normal startup refuses to run, with demo and test + helpers opting in explicitly to insecure startup. Stage 6.6 is the + natural place — the allow-list DTO construction for + `/api/v1/settings` must know the enabled auth strategies, which + overlaps with the startup posture check. + +4. **Runtime `ModelRegistry` for `ModelRef::resolve`**. Still unimplemented. + `fabro_types::settings::v2::model_ref::ModelRef::resolve` takes a + `&dyn ModelRegistry` and errors on ambiguous bare tokens. There's + no runtime implementation against `fabro-model::Catalog`. See TODO-7 + above. + +5. **`run.scm.` subtree depth**. Still minimal — only + `run.scm.github` exists as a placeholder unit struct. Add real + fields when the first SCM-specific leaf lands. + +6. **`flatten` + `HashMap` + `deny_unknown_fields`**. Don't try to + flatten a HashMap under `deny_unknown_fields`. It doesn't work in + serde. Enumerate known providers explicitly (as v2 already does for + `NotificationRouteLayer`, `InterviewsLayer`, etc.). + +## Running verification + +```bash +# full gate — must stay green after every incremental commit +cargo fmt --check --all +cargo build --workspace +cargo clippy --workspace -- -D warnings +ulimit -n 4096 && cargo nextest run --workspace + +# web assets (when touching fabro-web): +cd apps/fabro-web && bun run typecheck && bun test && bun run build + +# API spec conformance: +cargo nextest run -p fabro-server --test it openapi_conformance +``` + +Current status on `main`: all of the above are green. + +## Success criteria for finishing Stage 6 + +Pulled from the predecessor handoff, updated for what remains: + +- [ ] `git grep 'fabro_types::Settings\b'` returns zero hits outside + the legacy type file that's about to be deleted. + **Current: ~9 hits remain — see TODO-1 / TODO-9 / TODO-10.** +- [x] `git grep 'bridge_to_old'` returns zero hits. + **Done in 6.2.** +- [ ] `lib/crates/fabro-types/src/settings/v2/` no longer exists as + a subdirectory — its contents are promoted to `settings/*`. + **Blocked on 6.3; top-level re-exports landed in 6.5.** +- [ ] `lib/crates/fabro-types/src/combine.rs` is deleted. + **Blocked on 6.3.** +- [ ] `lib/crates/fabro-config/src/{hook,mcp,sandbox,server,run,user}.rs` + are either deleted or reduced to thin re-export shells. + **hook/mcp/sandbox/server: deleted. run/user: reduced to the + helper functions they still own.** +- [ ] `docs/api-reference/fabro-api.yaml` `ServerSettings` and + `RunSettings` schemas are explicit allow-list DTOs. + **Not started (6.6).** +- [ ] `lib/packages/fabro-api-client` and the Rust progenitor client + are regenerated from the new spec. + **Not started (6.6).** +- [ ] `apps/fabro-web/app/routes/workflow-detail.tsx` `workflowData` + literal matches the new `RunSettings` DTO. + **Not started (6.6).** +- [x] The `cargo fmt` / `cargo build` / `cargo clippy -D warnings` / + `cargo nextest run --workspace` / `bun run typecheck` / `bun test` + / `bun run build` gates all stay green. + **Rust side: green. Frontend: unverified — the new `/api/v1/settings` + JSON shape may break fabro-web at runtime. Verify before merging + any frontend release.** + +## Starting points for the next engineer + +1. **Read the predecessor handoff end-to-end** — it has the scope, + gotchas, and open design questions. +2. **Run the test suite locally** to confirm the starting state + (`ulimit -n 4096 && cargo nextest run --workspace`). Expected: + 3,756 passed / 0 failed / 182 skipped. +3. **Verify the wire-contract drift** before touching anything: + ```bash + cargo run -p fabro-cli -- server start # in one terminal + curl -s http://localhost:3000/api/v1/settings | jq '.' + ``` + You should see the v2 `SettingsFile` shape (`server.storage.root`, + `run.execution.mode`, etc.), not the legacy flat shape. This is + the state that 6.6 needs to reconcile with the OpenAPI spec. +4. **Start 6.6 by drafting the new `ServerSettings` DTO** in the + OpenAPI yaml. Use the R16 allow-list from the requirements doc + as the starting point. Don't try to be exhaustive — a narrower + first cut is easier to review. +5. **Generate clients, update `get_server_settings` and + `get_run_settings` to build the DTO explicitly**, and only then + touch fabro-web. The backend change should be testable in isolation + before anything in the frontend moves. +6. **After 6.6 lands**, deleting the legacy `Settings` types in 6.3 + + flattening the v2 directory in 6.5 becomes mechanical. + +Good luck. From 747d9e8fcb3640debedc36b3c9bd941573a8024b Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 9 Apr 2026 18:54:04 -0400 Subject: [PATCH 40/47] refactor(server): move TlsSettings into its own tls_config module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `TlsSettings` and its `from_settings(&SettingsFile)` constructor lived in `jwt_auth.rs` as a historical artifact from the Stage 6.6g rewrite — the auth resolver only needs to know *whether* TLS is present (for mTLS support), not the contents of the triple. The type is really a listen-side concern that belongs next to the rustls builder. Moves the type into a new `fabro-server/src/tls_config.rs` module (35 LOC). Updates three importers: - `jwt_auth.rs` — imports `TlsSettings` from `crate::tls_config`; drops the `std::path::PathBuf` / `InterpString` / `ServerListenLayer` / `serde::Deserialize` imports that are no longer used after the type moved. - `serve.rs` — splits the multi-item `use crate::jwt_auth::{...}` line so `TlsSettings` comes from `crate::tls_config`. - `tls.rs` — same split. - `tests/it/api/mtls.rs` — same split. Pure relocation; no behavioral change. 156 fabro-server tests pass, `cargo fmt --check --all` and `cargo clippy --workspace -- -D warnings` are clean. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/crates/fabro-server/src/jwt_auth.rs | 35 +-------------- lib/crates/fabro-server/src/lib.rs | 1 + lib/crates/fabro-server/src/serve.rs | 3 +- lib/crates/fabro-server/src/tls.rs | 3 +- lib/crates/fabro-server/src/tls_config.rs | 46 ++++++++++++++++++++ lib/crates/fabro-server/tests/it/api/mtls.rs | 3 +- 6 files changed, 54 insertions(+), 37 deletions(-) create mode 100644 lib/crates/fabro-server/src/tls_config.rs diff --git a/lib/crates/fabro-server/src/jwt_auth.rs b/lib/crates/fabro-server/src/jwt_auth.rs index a4f12e266..e930f488e 100644 --- a/lib/crates/fabro-server/src/jwt_auth.rs +++ b/lib/crates/fabro-server/src/jwt_auth.rs @@ -1,4 +1,3 @@ -use std::path::PathBuf; use std::sync::Arc; use axum::extract::FromRequestParts; @@ -10,42 +9,10 @@ use serde::Deserialize; use tracing::warn; use crate::error::ApiError; +use crate::tls_config::TlsSettings; use crate::web_auth::SessionCookie; use fabro_types::RunAuthMethod; use fabro_types::settings::SettingsFile; -use fabro_types::settings::interp::InterpString; -use fabro_types::settings::server::ServerListenLayer; - -/// Resolved TLS material used by the rustls config builder in `tls.rs` -/// when the server is listening on TCP with `[server.listen.tls]` set. -#[derive(Debug, Clone, PartialEq)] -pub struct TlsSettings { - pub cert: PathBuf, - pub key: PathBuf, - pub ca: PathBuf, -} - -impl TlsSettings { - /// Extract the `[server.listen.tls]` subtree out of a `SettingsFile`. - /// Returns `None` when the server is on Unix sockets, TLS is unset, or - /// any of the three fields is missing. - #[must_use] - pub fn from_settings(file: &SettingsFile) -> Option { - let listen = file.server.as_ref()?.listen.as_ref()?; - let tls = match listen { - ServerListenLayer::Tcp { tls, .. } => tls.as_ref()?, - ServerListenLayer::Unix { .. } => return None, - }; - let cert = tls.cert.as_ref().map(InterpString::as_source)?; - let key = tls.key.as_ref().map(InterpString::as_source)?; - let ca = tls.ca.as_ref().map(InterpString::as_source)?; - Some(Self { - cert: cert.into(), - key: key.into(), - ca: ca.into(), - }) - } -} /// JWT claims for service-to-service authentication. #[derive(Debug, Deserialize)] diff --git a/lib/crates/fabro-server/src/lib.rs b/lib/crates/fabro-server/src/lib.rs index 507c77f2a..e11d07cdd 100644 --- a/lib/crates/fabro-server/src/lib.rs +++ b/lib/crates/fabro-server/src/lib.rs @@ -17,4 +17,5 @@ pub mod server; mod settings_view; pub mod static_files; pub mod tls; +pub mod tls_config; pub mod web_auth; diff --git a/lib/crates/fabro-server/src/serve.rs b/lib/crates/fabro-server/src/serve.rs index 7075796c8..6fceb88cf 100644 --- a/lib/crates/fabro-server/src/serve.rs +++ b/lib/crates/fabro-server/src/serve.rs @@ -21,13 +21,14 @@ use fabro_types::settings::SettingsFile; use crate::bind::{self, Bind, BindRequest}; use crate::github_webhooks::WebhookManager; -use crate::jwt_auth::{AuthMode, AuthStrategy, TlsSettings, resolve_auth_mode_with_lookup}; +use crate::jwt_auth::{AuthMode, AuthStrategy, resolve_auth_mode_with_lookup}; use crate::secret_store::SecretStore; use crate::server::{ RouterOptions, build_app_state_with_path, build_router_with_options, reconcile_incomplete_runs_on_startup, shutdown_active_workers, spawn_scheduler, }; use crate::tls::{ClientAuth, build_rustls_config, serve_tls_with_shutdown}; +use crate::tls_config::TlsSettings; use fabro_llm::client::Client as LlmClient; use fabro_sandbox::SandboxProvider; diff --git a/lib/crates/fabro-server/src/tls.rs b/lib/crates/fabro-server/src/tls.rs index ff908b3fd..a1b226cd1 100644 --- a/lib/crates/fabro-server/src/tls.rs +++ b/lib/crates/fabro-server/src/tls.rs @@ -8,7 +8,8 @@ use rustls_pki_types::{CertificateDer, PrivateKeyDer}; use tokio::net::TcpListener; use tracing::error; -use crate::jwt_auth::{PeerCertificates, TlsSettings}; +use crate::jwt_auth::PeerCertificates; +use crate::tls_config::TlsSettings; /// How client certificates should be verified. #[derive(Clone, Copy)] diff --git a/lib/crates/fabro-server/src/tls_config.rs b/lib/crates/fabro-server/src/tls_config.rs new file mode 100644 index 000000000..13955639c --- /dev/null +++ b/lib/crates/fabro-server/src/tls_config.rs @@ -0,0 +1,46 @@ +//! Resolved TLS material extracted from `[server.listen.tls]`. +//! +//! This module owns the `(cert, key, ca)` triple that the rustls config +//! builder in [`crate::tls`] consumes when the server is listening on TCP +//! with mTLS enabled. It lives outside `jwt_auth.rs` because TLS material +//! is a listen-side concern, not an authentication strategy — the auth +//! resolver only cares about *whether* TLS is present (for mTLS support), +//! not about its contents. + +use std::path::PathBuf; + +use fabro_types::settings::SettingsFile; +use fabro_types::settings::interp::InterpString; +use fabro_types::settings::server::ServerListenLayer; + +/// Resolved TLS material used by the rustls config builder in +/// [`crate::tls`] when the server is listening on TCP with +/// `[server.listen.tls]` set. +#[derive(Debug, Clone, PartialEq)] +pub struct TlsSettings { + pub cert: PathBuf, + pub key: PathBuf, + pub ca: PathBuf, +} + +impl TlsSettings { + /// Extract the `[server.listen.tls]` subtree out of a `SettingsFile`. + /// Returns `None` when the server is on Unix sockets, TLS is unset, or + /// any of the three fields is missing. + #[must_use] + pub fn from_settings(file: &SettingsFile) -> Option { + let listen = file.server.as_ref()?.listen.as_ref()?; + let tls = match listen { + ServerListenLayer::Tcp { tls, .. } => tls.as_ref()?, + ServerListenLayer::Unix { .. } => return None, + }; + let cert = tls.cert.as_ref().map(InterpString::as_source)?; + let key = tls.key.as_ref().map(InterpString::as_source)?; + let ca = tls.ca.as_ref().map(InterpString::as_source)?; + Some(Self { + cert: cert.into(), + key: key.into(), + ca: ca.into(), + }) + } +} diff --git a/lib/crates/fabro-server/tests/it/api/mtls.rs b/lib/crates/fabro-server/tests/it/api/mtls.rs index 232cc545c..b43f96e6f 100644 --- a/lib/crates/fabro-server/tests/it/api/mtls.rs +++ b/lib/crates/fabro-server/tests/it/api/mtls.rs @@ -4,9 +4,10 @@ use crate::helpers::api; use std::path::{Path, PathBuf}; use std::sync::Arc; -use fabro_server::jwt_auth::{AuthMode, AuthStrategy, TlsSettings}; +use fabro_server::jwt_auth::{AuthMode, AuthStrategy}; use fabro_server::server::{build_router, create_app_state}; use fabro_server::tls::{ClientAuth, build_rustls_config}; +use fabro_server::tls_config::TlsSettings; use tokio::net::TcpListener; fn fixture_path(name: &str) -> PathBuf { From d4fb73d614f526c7d52d90e8820997a198873bfb Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 9 Apr 2026 19:03:51 -0400 Subject: [PATCH 41/47] feat(server): fail-closed auth posture per R52/R53 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `resolve_auth_mode_with_lookup` now returns `anyhow::Result` and refuses to return success when `server.auth` resolves to zero enabled strategies. Startup propagates the error via `?` and aborts with a descriptive message pointing at the three configuration escape hatches. Previously the resolver logged a warning and returned `AuthMode::Strategies(empty)`, which meant an unconfigured server would start and then reject every request — accidental misconfigurations produced a silently-broken process rather than a clean startup failure. The new behavior matches the implementation plan's explicit guidance: "if `server.auth` is absent or resolves to no enabled API or web auth configuration, normal server startup must refuse to start. Demo and test helpers may continue to inject explicit insecure settings, but insecure startup must be opt-in rather than accidental." The single opt-in path is the `FABRO_LOCAL_NO_AUTH` env var set to the literal string `"1"`, now hoisted into a module-level `FABRO_LOCAL_NO_AUTH_ENV` constant. `fabro server start --bind ` already sets this implicitly in `start.rs:232-234`, so local daemon usage is unchanged. TCP binds now require either real auth config or an explicit `FABRO_LOCAL_NO_AUTH=1` — arguably a security improvement for TCP. Detailed error message lists the three configuration options: Configure at least one of the following in `[server.auth]`: - `[server.auth.api.jwt]` (requires `FABRO_JWT_PUBLIC_KEY` env) - `[server.auth.api.mtls]` (requires `[server.listen.tls]` ...) - `SESSION_SECRET` env (enables cookie-based web auth) Adds six new unit tests covering the full decision matrix: - `fail_closed_when_server_auth_absent` - `fail_closed_when_all_strategies_disabled` - `opt_in_insecure_startup_via_env` - `insecure_startup_flag_any_other_value_still_fails_closed` - `cookie_strategy_alone_unlocks_startup` - `mtls_strategy_resolves_when_enabled_with_listen_tls` Also adds `#[derive(Debug)]` to `AuthMode` and `AuthStrategy` so the tests can `expect_err()` on the resolver result. Two existing `fabro-cli` integration tests for TCP bind resolution (`start_with_tcp_host_only_bind_resolves_to_host_and_port` and `start_with_tcp_host_only_bind_warns_and_falls_back_when_default_port_is_unavailable`) now set `FABRO_LOCAL_NO_AUTH=1` in the test environment. They were exercising bind-address resolution, not auth, so opting into insecure startup explicitly keeps their focus narrow. 3,764 workspace tests pass (was 3,758, +6 new). `cargo fmt --check --all` and `cargo clippy --workspace -- -D warnings` are clean. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../fabro-cli/tests/it/cmd/server_start.rs | 8 + lib/crates/fabro-server/src/jwt_auth.rs | 171 +++++++++++++++--- lib/crates/fabro-server/src/serve.rs | 2 +- 3 files changed, 159 insertions(+), 22 deletions(-) diff --git a/lib/crates/fabro-cli/tests/it/cmd/server_start.rs b/lib/crates/fabro-cli/tests/it/cmd/server_start.rs index 76a4b94d3..2987f887f 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/server_start.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/server_start.rs @@ -143,8 +143,12 @@ fn start_with_tcp_host_only_bind_resolves_to_host_and_port() { let storage_root = isolated_storage_dir(); let storage_dir = storage_root.path().join("storage"); + // TCP binds don't auto-enable `FABRO_LOCAL_NO_AUTH`; the test is + // exercising bind resolution, not auth, so opt into insecure + // startup explicitly. let mut cmd = context.command(); cmd.env("FABRO_STORAGE_DIR", &storage_dir); + cmd.env("FABRO_LOCAL_NO_AUTH", "1"); cmd.args(["server", "start", "--dry-run", "--bind", "127.0.0.1"]); let output = cmd.output().expect("server start command should run"); assert!( @@ -202,8 +206,12 @@ fn start_with_tcp_host_only_bind_warns_and_falls_back_when_default_port_is_unava filters.push((r"pid \d+".to_string(), "pid [PID]".to_string())); filters.push((r"127\.0\.0\.1:\d+".to_string(), "[TCP_BIND]".to_string())); + // TCP binds don't auto-enable `FABRO_LOCAL_NO_AUTH`; the test is + // exercising bind resolution, not auth, so opt into insecure + // startup explicitly. let mut cmd = context.command(); cmd.env("FABRO_STORAGE_DIR", &storage_dir); + cmd.env("FABRO_LOCAL_NO_AUTH", "1"); cmd.args(["server", "start", "--dry-run", "--bind", "127.0.0.1"]); fabro_snapshot!(filters, cmd, @" success: true diff --git a/lib/crates/fabro-server/src/jwt_auth.rs b/lib/crates/fabro-server/src/jwt_auth.rs index e930f488e..b2eb439a8 100644 --- a/lib/crates/fabro-server/src/jwt_auth.rs +++ b/lib/crates/fabro-server/src/jwt_auth.rs @@ -1,5 +1,6 @@ use std::sync::Arc; +use anyhow::{Result, anyhow}; use axum::extract::FromRequestParts; use axum::http::request::Parts; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; @@ -14,6 +15,15 @@ use crate::web_auth::SessionCookie; use fabro_types::RunAuthMethod; use fabro_types::settings::SettingsFile; +/// Env var that explicitly opts the server into unauthenticated startup. +/// +/// When set to `"1"`, [`resolve_auth_mode_with_lookup`] returns +/// [`AuthMode::Disabled`] regardless of what `server.auth` says. This is the +/// only escape hatch for running the server without configured +/// authentication; it is off by default, so accidental misconfigurations +/// fail closed. +pub const FABRO_LOCAL_NO_AUTH_ENV: &str = "FABRO_LOCAL_NO_AUTH"; + /// JWT claims for service-to-service authentication. #[derive(Debug, Deserialize)] struct Claims { @@ -27,7 +37,7 @@ struct Claims { } /// A single authentication strategy resolved at startup. -#[derive(Clone)] +#[derive(Clone, Debug)] pub enum AuthStrategy { Jwt { key: Arc, @@ -46,7 +56,7 @@ pub fn jwt_validation() -> Validation { } /// Authentication mode resolved at startup. -#[derive(Clone)] +#[derive(Clone, Debug)] pub enum AuthMode { /// One or more strategies to try in order. Strategies(Vec), @@ -71,11 +81,19 @@ pub fn decode_pem_env(name: &str, value: &str) -> String { /// Resolve the authentication mode from a [`SettingsFile`]. /// -/// Call this once at startup before serving requests. Panics if the -/// configuration is invalid (JWT strategy but no public key, or mTLS without -/// TLS config). Walks the v2 `server.auth.api.{jwt,mtls}` subtree and +/// Call this once at startup before serving requests. Returns +/// [`AuthMode::Disabled`] when [`FABRO_LOCAL_NO_AUTH_ENV`] is set to `"1"` +/// (explicit insecure-startup opt-in). Returns `AuthMode::Strategies(...)` +/// when `server.auth` resolves to at least one enabled strategy. +/// +/// Fails closed when `server.auth` is absent or resolves to zero enabled +/// strategies: startup refuses rather than silently accepting every +/// request. Panics if a configured strategy is missing its required +/// material (JWT public key, mTLS TLS config). +/// +/// Walks the v2 `server.auth.api.{jwt,mtls}` subtree and /// `server.auth.web.allowed_usernames`. -pub fn resolve_auth_mode(settings: &SettingsFile) -> AuthMode { +pub fn resolve_auth_mode(settings: &SettingsFile) -> Result { resolve_auth_mode_with_lookup(settings, |name| std::env::var(name).ok()) } @@ -116,10 +134,18 @@ fn resolve_auth_strategies(settings: &SettingsFile) -> ResolvedAuthStrategies { } } -pub fn resolve_auth_mode_with_lookup(settings: &SettingsFile, lookup: F) -> AuthMode +pub fn resolve_auth_mode_with_lookup(settings: &SettingsFile, lookup: F) -> Result where F: Fn(&str) -> Option, { + if lookup(FABRO_LOCAL_NO_AUTH_ENV).as_deref() == Some("1") { + warn!( + "{FABRO_LOCAL_NO_AUTH_ENV}=1 set; allowing unauthenticated local daemon access. \ + Do not use this flag outside local development or demo environments." + ); + return Ok(AuthMode::Disabled); + } + let ResolvedAuthStrategies { jwt_enabled, mtls_enabled, @@ -127,19 +153,6 @@ where allowed_usernames, } = resolve_auth_strategies(settings); - let any_strategy = jwt_enabled || mtls_enabled; - - if !any_strategy && std::env::var("FABRO_LOCAL_NO_AUTH").ok().as_deref() == Some("1") { - warn!( - "No authentication strategies configured; allowing unauthenticated local daemon access" - ); - return AuthMode::Disabled; - } - - if !any_strategy { - warn!("No authentication strategies configured; all requests will be rejected"); - } - let mut strategies = Vec::new(); if lookup("SESSION_SECRET").is_some() { strategies.push(AuthStrategy::Cookie); @@ -170,7 +183,21 @@ where strategies.push(AuthStrategy::Mtls); } - AuthMode::Strategies(strategies) + if strategies.is_empty() { + return Err(anyhow!( + "Fabro server refuses to start: no authentication strategies are configured.\n\ + \n\ + Configure at least one of the following in `[server.auth]`:\n\ + - `[server.auth.api.jwt]` (requires `FABRO_JWT_PUBLIC_KEY` env)\n\ + - `[server.auth.api.mtls]` (requires `[server.listen.tls]` cert/key/ca)\n\ + - `SESSION_SECRET` env (enables cookie-based web auth)\n\ + \n\ + Or set `{FABRO_LOCAL_NO_AUTH_ENV}=1` to explicitly opt in to \ + unauthenticated local daemon access." + )); + } + + Ok(AuthMode::Strategies(strategies)) } /// Extract the login from JWT claims. @@ -417,10 +444,112 @@ mod tests { use axum::http::{Request, StatusCode}; use axum::response::IntoResponse; use axum::routing::get; + use fabro_config::ConfigLayer; use tower::ServiceExt; use crate::web_auth::SessionCookie; + // --- Fail-closed resolver tests (R52/R53) ----------------------------------- + + fn settings(source: &str) -> SettingsFile { + ConfigLayer::parse(source) + .expect("fixture should parse") + .into() + } + + /// Lookup closure that returns nothing — every env var is absent. + fn empty_lookup(_name: &str) -> Option { + None + } + + #[test] + fn fail_closed_when_server_auth_absent() { + let file = settings("_version = 1\n"); + let err = + resolve_auth_mode_with_lookup(&file, empty_lookup).expect_err("should refuse startup"); + assert!(err.to_string().contains("refuses to start")); + assert!(err.to_string().contains("FABRO_LOCAL_NO_AUTH")); + } + + #[test] + fn fail_closed_when_all_strategies_disabled() { + let file = settings( + r#" +_version = 1 + +[server.auth.api.jwt] +enabled = false + +[server.auth.api.mtls] +enabled = false +"#, + ); + let err = + resolve_auth_mode_with_lookup(&file, empty_lookup).expect_err("should refuse startup"); + assert!(err.to_string().contains("no authentication strategies")); + } + + #[test] + fn opt_in_insecure_startup_via_env() { + let file = settings("_version = 1\n"); + let mode = resolve_auth_mode_with_lookup(&file, |name| { + (name == FABRO_LOCAL_NO_AUTH_ENV).then(|| "1".to_string()) + }) + .expect("FABRO_LOCAL_NO_AUTH=1 should allow startup"); + assert!(matches!(mode, AuthMode::Disabled)); + } + + #[test] + fn insecure_startup_flag_any_other_value_still_fails_closed() { + let file = settings("_version = 1\n"); + let err = resolve_auth_mode_with_lookup(&file, |name| { + (name == FABRO_LOCAL_NO_AUTH_ENV).then(|| "true".to_string()) + }) + .expect_err("only the literal string \"1\" opts in"); + assert!(err.to_string().contains("refuses to start")); + } + + #[test] + fn cookie_strategy_alone_unlocks_startup() { + let file = settings("_version = 1\n"); + let mode = resolve_auth_mode_with_lookup(&file, |name| { + (name == "SESSION_SECRET").then(|| "deadbeef".to_string()) + }) + .expect("SESSION_SECRET alone should unlock startup"); + let AuthMode::Strategies(strategies) = mode else { + panic!("expected Strategies, got Disabled"); + }; + assert_eq!(strategies.len(), 1); + assert!(matches!(strategies[0], AuthStrategy::Cookie)); + } + + #[test] + fn mtls_strategy_resolves_when_enabled_with_listen_tls() { + let file = settings( + r#" +_version = 1 + +[server.auth.api.mtls] +enabled = true + +[server.listen] +type = "tcp" +address = "127.0.0.1:3000" + +[server.listen.tls] +cert = "/etc/fabro/tls/cert.pem" +key = "/etc/fabro/tls/key.pem" +ca = "/etc/fabro/tls/ca.pem" +"#, + ); + let mode = + resolve_auth_mode_with_lookup(&file, empty_lookup).expect("mTLS config should resolve"); + let AuthMode::Strategies(strategies) = mode else { + panic!("expected Strategies, got Disabled"); + }; + assert!(strategies.iter().any(|s| matches!(s, AuthStrategy::Mtls))); + } + async fn protected_handler(_auth: AuthenticatedService) -> impl IntoResponse { "ok" } diff --git a/lib/crates/fabro-server/src/serve.rs b/lib/crates/fabro-server/src/serve.rs index 6fceb88cf..db33402bd 100644 --- a/lib/crates/fabro-server/src/serve.rs +++ b/lib/crates/fabro-server/src/serve.rs @@ -293,7 +293,7 @@ where .get(name) .cloned() .or_else(|| std::env::var(name).ok()) - }); + })?; let tls_present = TlsSettings::from_settings(&cfg_file).is_some(); let client_auth = tls_present.then(|| client_auth_from_mode(&auth_mode)); let max_concurrent_runs = args From fac0b10244beb58b205afd0f0da1b8f9bb82f771 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 9 Apr 2026 19:18:10 -0400 Subject: [PATCH 42/47] feat(server): preserve comments in setup_register TOML edits `setup_register` in `web_auth.rs` used to round-trip the user's settings file through `toml::Value` + `toml::to_string_pretty`, which strips every comment, blank line, and explicit key ordering on the way out. A user who'd hand-commented their `~/.fabro/settings.toml` would see all of that lost on the next GitHub App registration. Switches the edit path to `toml_edit::DocumentMut`, which preserves prefix decoration (comments, blank lines) on every key. Adds `toml_edit = "0.22"` as a workspace dependency (already pulled in transitively via `toml 0.8`) and declares it in `fabro-server`. Implementation notes: - New `ensure_nested_table(doc, &["server", "web"])` walks a dotted path and `or_insert`s missing intermediate tables without touching existing ones. - New `set_preserving_decor(table, key, value)` replaces an entry's value while copying the old key's `leaf_decor` forward. Without that workaround, `toml_edit::Table::insert` drops the prefix decoration of the replaced key -- which would strip a top-of-file comment attached to `_version = 1` or any other value we update. - `_version` is only inserted when missing; it's always `1` today, so rewriting it every time is unnecessary and would trample its decor. - `merge_settings_keys` now takes `&mut toml_edit::DocumentMut` instead of `&mut toml::Value`. The flow in `setup_register` parses the file on disk into a `DocumentMut`, applies the merge, and writes `doc.to_string()` back. Adds a new test `merge_settings_keys_preserves_comments_and_unrelated_keys` that round-trips a fixture file containing: - A top-of-file comment attached to `_version` - A comment above `[server.storage]` - A comment above a pre-existing `[server.integrations.slack]` table - Unrelated keys in `[server.storage]`, `[server.integrations.slack]`, and `[run.model]` and asserts that every comment and every unrelated key survives the merge, that the new GitHub App keys are present, and that the final output still parses as a valid v2 `SettingsFile` via `fabro_config::ConfigLayer::parse`. Also strengthens the existing `merge_settings_keys_writes_v2_server_integrations_github` test with a round-trip parse of the emitted TOML through `ConfigLayer::parse` to ensure the output is real v2 config, not just a JSON-shaped blob. 3,765 workspace tests pass (+1 new). `cargo fmt --check --all` and `cargo clippy --workspace -- -D warnings` are clean. Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 1 + Cargo.toml | 1 + lib/crates/fabro-server/Cargo.toml | 1 + lib/crates/fabro-server/src/web_auth.rs | 241 +++++++++++++++++------- 4 files changed, 177 insertions(+), 67 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0a51eac92..9d5fe180b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1929,6 +1929,7 @@ dependencies = [ "tokio-rustls", "tokio-stream", "toml 0.8.23", + "toml_edit", "tower", "tower-http", "tower-service", diff --git a/Cargo.toml b/Cargo.toml index 90331a6f7..1d4a37b24 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,6 +53,7 @@ mime_guess = "2" indicatif = "0.18" termimad = "0.34" toml = "0.8" +toml_edit = "0.22" jsonwebtoken = { version = "10", features = ["aws_lc_rs"] } hmac = "0.12" sha2 = "0.10" diff --git a/lib/crates/fabro-server/Cargo.toml b/lib/crates/fabro-server/Cargo.toml index 5b44b0aaf..7581c1477 100644 --- a/lib/crates/fabro-server/Cargo.toml +++ b/lib/crates/fabro-server/Cargo.toml @@ -58,6 +58,7 @@ serde_yaml = "0.9" anyhow.workspace = true clap.workspace = true toml.workspace = true +toml_edit.workspace = true tracing.workspace = true ulid.workspace = true uuid.workspace = true diff --git a/lib/crates/fabro-server/src/web_auth.rs b/lib/crates/fabro-server/src/web_auth.rs index 2ea86a331..5ae2d3d9a 100644 --- a/lib/crates/fabro-server/src/web_auth.rs +++ b/lib/crates/fabro-server/src/web_auth.rs @@ -559,18 +559,20 @@ async fn setup_register( let settings_path = state.config_path.clone(); - // Build a v2 settings_path edit in place. This used to bridge back to - // the legacy flat shape and emit v1 TOML; the v2 parser hard-rejects - // the v1 top-level keys, so this was already broken. Write v2 TOML - // using `merge_settings_keys` against the raw TOML document. + // Edit the settings file in place via `toml_edit::DocumentMut`, which + // preserves existing comments, whitespace, and key ordering. The value- + // tree parser (`toml::Value`) would strip all of that on round-trip. if let Some(parent) = settings_path.parent() { let _ = std::fs::create_dir_all(parent); } let existing = std::fs::read_to_string(&settings_path).unwrap_or_default(); - let mut doc: toml::Value = if existing.is_empty() { - toml::Value::Table(toml::Table::default()) + let mut doc: toml_edit::DocumentMut = if existing.is_empty() { + toml_edit::DocumentMut::new() } else { - match toml::from_str(&existing).context("failed to parse existing settings config") { + match existing + .parse::() + .context("failed to parse existing settings config") + { Ok(doc) => doc, Err(err) => { error!(error = %err, path = %settings_path.display(), "Setup register failed: could not parse settings config"); @@ -588,10 +590,7 @@ async fn setup_register( json!({"error": format!("Failed to update settings config: {err}")}), ); } - if let Err(err) = std::fs::write( - &settings_path, - toml::to_string_pretty(&doc).unwrap_or_default(), - ) { + if let Err(err) = std::fs::write(&settings_path, doc.to_string()) { error!(error = %err, path = %settings_path.display(), "Setup register failed: could not write settings config"); return json_response( StatusCode::INTERNAL_SERVER_ERROR, @@ -635,52 +634,76 @@ async fn setup_register( Json(json!({"ok": true})).into_response() } -fn root_table_mut(doc: &mut toml::Value) -> anyhow::Result<&mut toml::Table> { - doc.as_table_mut() - .ok_or_else(|| anyhow!("settings config root is not a table")) +/// Walk dotted `path` into `doc`, creating missing intermediate tables, +/// and return a mutable reference to the terminal table. +/// +/// Uses `toml_edit`'s [`toml_edit::Entry::or_insert`] so existing tables +/// keep their comments, ordering, and any sibling keys untouched. +fn ensure_nested_table<'a>( + doc: &'a mut toml_edit::DocumentMut, + path: &[&str], +) -> anyhow::Result<&'a mut toml_edit::Table> { + let mut current: &mut toml_edit::Table = doc.as_table_mut(); + for segment in path { + let next = current + .entry(segment) + .or_insert(toml_edit::Item::Table(toml_edit::Table::new())); + current = next + .as_table_mut() + .ok_or_else(|| anyhow!("settings config [{segment}] is not a table"))?; + } + Ok(current) } -fn ensure_table<'a>(table: &'a mut toml::Table, key: &str) -> anyhow::Result<&'a mut toml::Table> { - table - .entry(key.to_string()) - .or_insert_with(|| toml::Value::Table(toml::Table::default())) - .as_table_mut() - .ok_or_else(|| anyhow!("settings config [{key}] is not a table")) +/// Set a scalar value on `table[key]`, preserving any key-level decor +/// (leading comments, blank lines) that was attached to the existing entry. +/// +/// `toml_edit`'s default `Table::insert` replaces the entry wholesale and +/// drops its prefix decoration, which would strip a top-of-file comment +/// attached to a key we're updating. Copying the decor forward keeps the +/// user's formatting intact. +fn set_preserving_decor(table: &mut toml_edit::Table, key: &str, value: toml_edit::Item) { + let preserved_decor = table.key(key).map(|existing| existing.leaf_decor().clone()); + table.insert(key, value); + if let (Some(decor), Some(mut updated)) = (preserved_decor, table.key_mut(key)) { + *updated.leaf_decor_mut() = decor; + } } fn merge_settings_keys( - doc: &mut toml::Value, + doc: &mut toml_edit::DocumentMut, data: &GitHubManifestConversion, origin: Option<&str>, ) -> anyhow::Result<()> { let web_url = origin.map_or_else(|| "http://localhost:3000".to_string(), str::to_string); - let root = root_table_mut(doc)?; - // Make sure the freshly-written file is a valid v2 file. - root.insert("_version".to_string(), toml::Value::Integer(1)); + // Make sure the freshly-written file is a valid v2 file. `_version` is + // always `1` at the moment, so skip the write entirely if it's already + // there -- otherwise we'd trample any top-of-file comment attached to + // the key. + let root = doc.as_table_mut(); + if !root.contains_key("_version") { + root.insert("_version", toml_edit::value(1_i64)); + } - let server = ensure_table(root, "server")?; - let web = ensure_table(server, "web")?; - web.insert("enabled".to_string(), toml::Value::Boolean(true)); - web.insert("url".to_string(), toml::Value::String(web_url)); + let web = ensure_nested_table(doc, &["server", "web"])?; + set_preserving_decor(web, "enabled", toml_edit::value(true)); + set_preserving_decor(web, "url", toml_edit::value(web_url)); - let auth = ensure_table(server, "auth")?; - let auth_web = ensure_table(auth, "web")?; - let _ = auth_web; - let auth_api = ensure_table(auth, "api")?; - let _jwt = ensure_table(auth_api, "jwt")?; + // Ensure the auth subtrees exist so a freshly-registered GitHub App + // resolves `[server.auth.web]` / `[server.auth.api.jwt]` strategies on + // the next startup without a second manual edit. + ensure_nested_table(doc, &["server", "auth", "web"])?; + ensure_nested_table(doc, &["server", "auth", "api", "jwt"])?; - let integrations = ensure_table(server, "integrations")?; - let github = ensure_table(integrations, "github")?; - github.insert( - "app_id".to_string(), - toml::Value::String(data.id.to_string()), + let github = ensure_nested_table(doc, &["server", "integrations", "github"])?; + set_preserving_decor(github, "app_id", toml_edit::value(data.id.to_string())); + set_preserving_decor( + github, + "client_id", + toml_edit::value(data.client_id.clone()), ); - github.insert( - "client_id".to_string(), - toml::Value::String(data.client_id.clone()), - ); - github.insert("slug".to_string(), toml::Value::String(data.slug.clone())); + set_preserving_decor(github, "slug", toml_edit::value(data.slug.clone())); Ok(()) } @@ -700,38 +723,122 @@ mod tests { } } + fn parse_doc(source: &str) -> toml_edit::DocumentMut { + source + .parse::() + .expect("fixture should parse as TOML") + } + #[test] fn merge_settings_keys_writes_v2_server_integrations_github() { - let mut doc: toml::Value = - toml::from_str("_version = 1\n").expect("empty v2 doc should parse"); + let mut doc = parse_doc("_version = 1\n"); merge_settings_keys(&mut doc, &sample_conversion(), Some("https://example.test")).unwrap(); - let github = doc - .get("server") - .and_then(toml::Value::as_table) - .and_then(|s| s.get("integrations")) - .and_then(toml::Value::as_table) - .and_then(|i| i.get("github")) - .and_then(toml::Value::as_table) + let github = doc["server"]["integrations"]["github"] + .as_table() .expect("server.integrations.github should exist"); - assert_eq!( - github.get("app_id").and_then(toml::Value::as_str), - Some("123") - ); - assert_eq!( - github.get("slug").and_then(toml::Value::as_str), - Some("fabro") - ); + assert_eq!(github["app_id"].as_str(), Some("123")); + assert_eq!(github["slug"].as_str(), Some("fabro")); + assert_eq!(github["client_id"].as_str(), Some("abc")); - let web = doc - .get("server") - .and_then(toml::Value::as_table) - .and_then(|s| s.get("web")) - .and_then(toml::Value::as_table) + let web = doc["server"]["web"] + .as_table() .expect("server.web should exist"); + assert_eq!(web["url"].as_str(), Some("https://example.test")); + assert_eq!(web["enabled"].as_bool(), Some(true)); + + // Re-parse the emitted document to prove it round-trips into a + // valid v2 `SettingsFile`. + let emitted = doc.to_string(); + let file = fabro_config::ConfigLayer::parse(&emitted) + .expect("merged output should parse as a v2 SettingsFile"); + let server = file + .as_v2() + .server + .as_ref() + .expect("[server] should be present"); + let integrations = server + .integrations + .as_ref() + .expect("[server.integrations] should be present"); + let github = integrations + .github + .as_ref() + .expect("[server.integrations.github] should be present"); assert_eq!( - web.get("url").and_then(toml::Value::as_str), - Some("https://example.test") + github.app_id.as_ref().map(|s| s.as_source()), + Some("123".to_string()) ); } + + #[test] + fn merge_settings_keys_preserves_comments_and_unrelated_keys() { + let existing = r##"# Top-of-file comment explaining the settings layout. +_version = 1 + +# Storage root comment — should survive the edit. +[server.storage] +root = "/srv/fabro-data" + +# A pre-existing integration that is NOT github. +[server.integrations.slack] +default_channel = "#ops" + +[run.model] +provider = "anthropic" +name = "claude-sonnet" +"##; + let mut doc = parse_doc(existing); + merge_settings_keys( + &mut doc, + &sample_conversion(), + Some("https://fabro.example"), + ) + .unwrap(); + + let emitted = doc.to_string(); + + // Comments must survive the round-trip. + assert!( + emitted.contains("# Top-of-file comment explaining the settings layout."), + "top-of-file comment was stripped:\n{emitted}" + ); + assert!( + emitted.contains("# Storage root comment — should survive the edit."), + "inline table comment was stripped:\n{emitted}" + ); + assert!( + emitted.contains("# A pre-existing integration that is NOT github."), + "sibling-table comment was stripped:\n{emitted}" + ); + + // Unrelated keys must still be intact. + assert!( + emitted.contains(r#"root = "/srv/fabro-data""#), + "server.storage.root was lost:\n{emitted}" + ); + assert!( + emitted.contains(r##"default_channel = "#ops""##), + "server.integrations.slack.default_channel was lost:\n{emitted}" + ); + assert!( + emitted.contains(r#"provider = "anthropic""#), + "run.model.provider was lost:\n{emitted}" + ); + + // And the new keys must be present. + assert!( + emitted.contains(r#"app_id = "123""#), + "server.integrations.github.app_id missing:\n{emitted}" + ); + assert!( + emitted.contains(r#"url = "https://fabro.example""#), + "server.web.url missing:\n{emitted}" + ); + + // Finally, the whole thing must still parse as a valid v2 + // SettingsFile. + fabro_config::ConfigLayer::parse(&emitted) + .expect("merged output should still parse as v2 after the edit"); + } } From ce1696706c72e09b8a3709c0eabb975dcc4f6f5d Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 9 Apr 2026 19:47:12 -0400 Subject: [PATCH 43/47] feat(settings): run.goal tagged union (inline | file) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--goal-file` was broken in the v2 path: `TryFrom<&RunArgs> for ConfigLayer` did `let _ = &args.goal_file;`, so clap accepted the flag listed in `--help` and then silently dropped it. Users running `fabro run demo --goal-file prompts/goal.md` ended up with no goal at all (or the DOT graph-level fallback), a regression from the legacy flat `Settings` shape. This commit adds first-class support for both inline and file-sourced goals via a tagged union on `run.goal`. Greenfield decisions: - **Single field, two variants.** `RunGoalLayer` is an untagged enum of `Inline(InterpString)` and `File { file: InterpString }`. Makes `goal XOR goal_file` un-representable in the type system and lets the v2 merge matrix treat `run.goal` as a single scalar (last-writer-wins) instead of needing a custom mutual-exclusion merge rule. Matches the existing `DaytonaDockerfileLayer` pattern. - **Relative paths are anchored at the file that declared them.** `ConfigLayer::load(path)` walks the just-parsed `SettingsFile` and rewrites any literal relative `run.goal.file` path to absolute using `path.parent()` as the base, via new `fabro_config::config::resolve_goal_file_paths`. CLI-sourced paths via `--goal-file` are anchored at CWD in `overrides::goal_layer_from_args`. Env-interpolated paths (`${env.GOALS_DIR}/goal.md`) are left unresolved until consume time and then resolved against the run's working_directory. - **New accessors, no shims.** - `run_goal_layer() -> Option<&RunGoalLayer>` — raw variant access. - `run_goal_inline_str() -> Option` — inline-only, returns `None` for file-sourced goals. - `resolve_run_goal(base_dir) -> Result>` — reads the file from disk if needed, returns text + provenance (`ResolvedGoalSource::Inline | File { path }`). - New `ResolveGoalError` enum covers env-lookup and I/O failures. - Old `run_goal() / run_goal_str()` are **deleted** outright; every call site has been updated to pick the right variant. - **CLI wiring (the actual bug fix).** `overrides::goal_layer_from_args` replaces the two `let _ = &args.goal_file;` lines with real resolution: `(Some(text), None)` → `Inline`, `(None, Some(path))` → `File { file: absolute }`. Both-set is rejected by a helper error and clap already had `conflicts_with = "goal"` as a belt-and- braces check. Applied to both `RunArgs` and `PreflightArgs`. - **Manifest builder.** `resolve_manifest_goal` now calls `args_layer.as_v2().resolve_run_goal()` and `settings.resolve_run_goal()` in precedence order, then falls through to the graph-level `@file` sugar if both are absent. The resolved goal is translated to a `ManifestGoal { text, type_, path }` by a new `resolved_goal_to_manifest` helper — inline goals get `type = Value`, file-sourced goals get `type = File` with the absolute path echoed for provenance. - **Workflow pipeline.** `fabro-workflow::operations::source:: resolve_goal_override` is rewritten to use `resolve_run_goal` against the working_directory. The orphaned helper `resolve_goal_file` (a stub from Stage 4 that was always called with `None`) is deleted. - **Server-side manifest.** `fabro-server::run_manifest:: prepare_manifest` stores the CLI-resolved goal as `RunGoalLayer::Inline`, matching the Stage 4 plan's "CLI owns goal file reads; server never touches the filesystem for goals" contract. ## Tests **Schema** (`fabro-types::settings::accessors`): - `run_goal_inline_str_returns_source_value` — literal inline variant - `run_goal_inline_str_is_none_for_file_variant` — file variant explicitly yields `None` from the inline accessor - `resolve_run_goal_reads_file_variant_from_disk` — end-to-end file read with provenance assertion - `resolve_run_goal_inline_passes_text_through` — inline passthrough **Config load** (`fabro-config::config`): - `parse_accepts_inline_goal` + `parse_accepts_file_variant` - `parse_rejects_goal_with_unknown_sibling_fields` — untagged enum correctly rejects mixed-shape TOML - `combine_replaces_file_goal_with_inline_from_higher_layer` and the reverse — confirms the tagged union merges as a single scalar with no custom rule needed - `load_rewrites_relative_goal_file_to_absolute` - `load_leaves_absolute_goal_file_untouched` - `load_leaves_env_interpolated_goal_file_untouched` **CLI overrides** (`fabro-cli::commands::run::overrides`): - `goal_and_goal_file_together_is_rejected` - `goal_file_is_anchored_at_cwd_when_relative` - `absolute_goal_file_is_preserved` - `inline_goal_builds_inline_variant` - `empty_args_produce_no_goal_layer` **CLI integration** (`fabro-cli::tests::it::cmd::run`): - `dry_run_with_goal_file_reads_contents_into_goal` — end-to-end `fabro run --dry-run --auto-approve --goal-file ` and asserts the file contents appear in the preflight summary. Explicit regression test for the silently-ignored flag. - `dry_run_rejects_goal_and_goal_file_together` — clap conflicts_with ## Callsite churn Every `run_goal() / run_goal_str()` call site updated: - `fabro-config/src/effective_settings.rs` — 2 test assertions → `run_goal_inline_str()` - `fabro-cli/tests/it/cmd/{config,create}.rs` — 3 sites → inline - `fabro-cli/src/manifest_builder.rs` — rewritten to use `resolve_run_goal` - `fabro-workflow/src/operations/create.rs` — 2 sites, test + set - `fabro-workflow/src/operations/source.rs` — rewritten - `fabro-server/src/{run_manifest,server}.rs` — set + test assertion 3,782 workspace tests pass (was 3,765, +17 new). `cargo fmt --check --all` and `cargo clippy --workspace -- -D warnings` are clean. Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 1 + .../fabro-cli/src/commands/run/overrides.rs | 113 ++++++++- lib/crates/fabro-cli/src/manifest_builder.rs | 58 +++-- lib/crates/fabro-cli/tests/it/cmd/config.rs | 4 +- lib/crates/fabro-cli/tests/it/cmd/create.rs | 2 +- lib/crates/fabro-cli/tests/it/cmd/run.rs | 56 +++++ lib/crates/fabro-config/src/config.rs | 217 ++++++++++++++++-- .../fabro-config/src/effective_settings.rs | 10 +- lib/crates/fabro-server/src/run_manifest.rs | 9 +- lib/crates/fabro-server/src/server.rs | 2 +- lib/crates/fabro-types/Cargo.toml | 3 + .../fabro-types/src/settings/accessors.rs | 187 ++++++++++++++- lib/crates/fabro-types/src/settings/run.rs | 51 +++- .../fabro-workflow/src/operations/create.rs | 15 +- .../fabro-workflow/src/operations/source.rs | 40 +--- 15 files changed, 665 insertions(+), 103 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9d5fe180b..b023b8c87 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2061,6 +2061,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "tempfile", "toml 0.8.23", "ulid", ] diff --git a/lib/crates/fabro-cli/src/commands/run/overrides.rs b/lib/crates/fabro-cli/src/commands/run/overrides.rs index 8ad84664f..d7b344ea4 100644 --- a/lib/crates/fabro-cli/src/commands/run/overrides.rs +++ b/lib/crates/fabro-cli/src/commands/run/overrides.rs @@ -1,13 +1,15 @@ use std::collections::HashMap; +use std::path::{Path, PathBuf}; -use anyhow::Result; +use anyhow::{Result, anyhow}; use fabro_config::ConfigLayer; use fabro_sandbox::SandboxProvider; use fabro_types::settings::SettingsFile; use fabro_types::settings::cli::{CliLayer, CliOutputLayer, OutputVerbosity}; use fabro_types::settings::interp::InterpString; use fabro_types::settings::run::{ - ApprovalMode, RunExecutionLayer, RunLayer, RunMode, RunModelLayer, RunSandboxLayer, + ApprovalMode, RunExecutionLayer, RunGoalLayer, RunLayer, RunMode, RunModelLayer, + RunSandboxLayer, }; use crate::args::{PreflightArgs, RunArgs}; @@ -80,6 +82,41 @@ fn cli_layer_for_verbose(verbose: bool) -> Option { }) } +/// Build the `run.goal` override from the `--goal` / `--goal-file` args. +/// +/// The two are mutually exclusive at the clap level; this helper assumes +/// at most one is set and returns an error if that invariant is violated. +/// +/// CLI-supplied file paths are anchored at `cwd` (where the user invoked +/// the command), matching standard Unix CLI-flag conventions. +fn goal_layer_from_args( + goal: Option<&str>, + goal_file: Option<&Path>, + cwd: &Path, +) -> Result> { + match (goal, goal_file) { + (Some(_), Some(_)) => Err(anyhow!( + "--goal and --goal-file are mutually exclusive; use exactly one" + )), + (Some(text), None) => Ok(Some(RunGoalLayer::Inline(InterpString::parse(text)))), + (None, Some(path)) => { + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + cwd.join(path) + }; + Ok(Some(RunGoalLayer::File { + file: InterpString::parse(&absolute.to_string_lossy()), + })) + } + (None, None) => Ok(None), + } +} + +fn current_dir_or_dot() -> PathBuf { + std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) +} + impl TryFrom<&RunArgs> for ConfigLayer { type Error = anyhow::Error; @@ -95,8 +132,11 @@ impl TryFrom<&RunArgs> for ConfigLayer { sparse_flag(args.no_retro), ); + let cwd = current_dir_or_dot(); + let goal = goal_layer_from_args(args.goal.as_deref(), args.goal_file.as_deref(), &cwd)?; + let run = RunLayer { - goal: args.goal.as_deref().map(InterpString::parse), + goal, metadata: parse_labels(&args.label), model, sandbox, @@ -104,10 +144,6 @@ impl TryFrom<&RunArgs> for ConfigLayer { ..RunLayer::default() }; - // goal_file is not part of v2; fall through to Settings.goal_file via the bridge. - // Stage 4 consumers that still consult goal_file read it from Settings. - let _ = &args.goal_file; - Ok(Self::from(SettingsFile { run: Some(run), cli: cli_layer_for_verbose(args.verbose), @@ -126,15 +162,16 @@ impl TryFrom<&PreflightArgs> for ConfigLayer { ..RunSandboxLayer::default() }); + let cwd = current_dir_or_dot(); + let goal = goal_layer_from_args(args.goal.as_deref(), args.goal_file.as_deref(), &cwd)?; + let run = RunLayer { - goal: args.goal.as_deref().map(InterpString::parse), + goal, model, sandbox, ..RunLayer::default() }; - let _ = &args.goal_file; // Stage 4 preflight still reads goal_file via Settings bridge. - Ok(Self::from(SettingsFile { run: Some(run), cli: cli_layer_for_verbose(args.verbose), @@ -142,3 +179,59 @@ impl TryFrom<&PreflightArgs> for ConfigLayer { })) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn goal_and_goal_file_together_is_rejected() { + let err = goal_layer_from_args( + Some("inline text"), + Some(Path::new("goal.md")), + Path::new("/tmp"), + ) + .unwrap_err(); + assert!(err.to_string().contains("mutually exclusive")); + } + + #[test] + fn goal_file_is_anchored_at_cwd_when_relative() { + let layer = + goal_layer_from_args(None, Some(Path::new("prompts/goal.md")), Path::new("/cwd")) + .unwrap() + .expect("should build a goal layer"); + let RunGoalLayer::File { file } = layer else { + panic!("expected file variant"); + }; + assert_eq!(file.as_source(), "/cwd/prompts/goal.md"); + } + + #[test] + fn absolute_goal_file_is_preserved() { + let layer = goal_layer_from_args(None, Some(Path::new("/abs/goal.md")), Path::new("/cwd")) + .unwrap() + .expect("should build a goal layer"); + let RunGoalLayer::File { file } = layer else { + panic!("expected file variant"); + }; + assert_eq!(file.as_source(), "/abs/goal.md"); + } + + #[test] + fn inline_goal_builds_inline_variant() { + let layer = goal_layer_from_args(Some("inline goal"), None, Path::new("/cwd")) + .unwrap() + .expect("should build a goal layer"); + assert!(matches!(layer, RunGoalLayer::Inline(_))); + } + + #[test] + fn empty_args_produce_no_goal_layer() { + assert!( + goal_layer_from_args(None, None, Path::new("/cwd")) + .unwrap() + .is_none() + ); + } +} diff --git a/lib/crates/fabro-cli/src/manifest_builder.rs b/lib/crates/fabro-cli/src/manifest_builder.rs index 5ea4a7f77..716e77023 100644 --- a/lib/crates/fabro-cli/src/manifest_builder.rs +++ b/lib/crates/fabro-cli/src/manifest_builder.rs @@ -12,7 +12,7 @@ use fabro_graphviz::parser; use fabro_sandbox::daytona::detect_repo_info; use fabro_types::RunId; use fabro_types::settings::SettingsFile; -use fabro_types::settings::run::DaytonaDockerfileLayer; +use fabro_types::settings::run::{DaytonaDockerfileLayer, ResolvedGoalSource, ResolvedRunGoal}; use fabro_workflow::git::{GitSyncStatus, head_sha, sync_status}; use crate::args::{PreflightArgs, RunArgs}; @@ -391,30 +391,30 @@ fn resolve_manifest_goal( root_dot_path: &Path, cwd: &Path, ) -> Result> { - let _working_directory = project::resolve_working_directory(settings, cwd); + let working_directory = project::resolve_working_directory(settings, cwd); - if let Some(goal) = args_layer + // Precedence 1: CLI args (`--goal` / `--goal-file`). These are already + // resolved to absolute paths by `overrides::goal_layer_from_args`. + if let Some(resolved) = args_layer .as_v2() - .run - .as_ref() - .and_then(|r| r.goal.as_ref()) + .resolve_run_goal(&working_directory) + .context("failed to resolve --goal-file contents")? { - return Ok(Some(types::ManifestGoal { - path: None, - text: goal.as_source(), - type_: types::ManifestGoalType::Value, - })); + return Ok(Some(resolved_goal_to_manifest(resolved))); } - if let Some(goal) = settings.run_goal_str() { - return Ok(Some(types::ManifestGoal { - path: None, - text: goal, - type_: types::ManifestGoalType::Value, - })); - } - // V2 does not carry a distinct `goal_file` field; file-based goals now - // come through workflow manifest layers sourced on the server side. + // Precedence 2: merged config `run.goal`. Config-sourced `goal.file` + // paths were rewritten to absolute by `ConfigLayer::load` at the + // directory of the config file that declared them. + if let Some(resolved) = settings + .resolve_run_goal(&working_directory) + .context("failed to resolve run.goal.file contents")? + { + return Ok(Some(resolved_goal_to_manifest(resolved))); + } + + // Precedence 3: graph-level `goal` attribute in the DOT, with `@file` + // sugar for workflow-colocated goal files. let graph = parser::parse(root_source) .map_err(|err| anyhow!("Failed to parse {}: {err}", root_dot_path.display()))?; let Some(goal) = graph.attrs.get("goal").and_then(AttrValue::as_str) else { @@ -441,6 +441,24 @@ fn resolve_manifest_goal( })) } +/// Translate a [`ResolvedRunGoal`] into the wire-level `ManifestGoal` +/// shape. Inline goals get `type = Value`; file-sourced goals keep their +/// absolute path as the `path` field and use `type = File`. +fn resolved_goal_to_manifest(resolved: ResolvedRunGoal) -> types::ManifestGoal { + match resolved.source { + ResolvedGoalSource::Inline => types::ManifestGoal { + path: None, + text: resolved.text, + type_: types::ManifestGoalType::Value, + }, + ResolvedGoalSource::File { path } => types::ManifestGoal { + path: Some(path.to_string_lossy().into_owned()), + text: resolved.text, + type_: types::ManifestGoalType::File, + }, + } +} + fn build_manifest_git(cwd: &Path) -> Option { let (origin_url, branch) = detect_repo_info(cwd).ok()?; let branch = branch?; diff --git a/lib/crates/fabro-cli/tests/it/cmd/config.rs b/lib/crates/fabro-cli/tests/it/cmd/config.rs index cba0507c7..1b7d53a21 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/config.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/config.rs @@ -294,7 +294,7 @@ fn settings_local_merges_cli_and_project_defaults() { let cfg = parse_settings(&output); assert_eq!(cfg.run_model_name_str().as_deref(), Some("project-model")); assert_eq!(cfg.run_model_provider_str().as_deref(), Some("openai")); - assert_eq!(cfg.run_goal_str().as_deref(), None); + assert_eq!(cfg.run_goal_inline_str().as_deref(), None); assert_eq!(cfg.project_directory(), Some("fabro")); // v2 R22: run.inputs replaces the inherited map wholesale rather than @@ -333,7 +333,7 @@ fn settings_local_workflow_name_applies_run_overlay_and_deep_merges() { use fabro_types::settings::run::McpEntryLayer; let cfg = parse_settings(&output); - assert_eq!(cfg.run_goal_str().as_deref(), Some("demo goal")); + assert_eq!(cfg.run_goal_inline_str().as_deref(), Some("demo goal")); assert_eq!(cfg.run_model_name_str().as_deref(), Some("run-model")); assert_eq!(cfg.run_model_provider_str().as_deref(), Some("anthropic")); diff --git a/lib/crates/fabro-cli/tests/it/cmd/create.rs b/lib/crates/fabro-cli/tests/it/cmd/create.rs index e789a7609..b4200f118 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/create.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/create.rs @@ -356,7 +356,7 @@ fn create_persists_requested_overrides_into_store() { let compact = json!({ "workflow_slug": run_record.workflow_slug, "settings": { - "goal": settings.run_goal_str(), + "goal": settings.run_goal_inline_str(), "dry_run": settings.dry_run_enabled(), "auto_approve": settings.auto_approve_enabled(), "no_retro": settings.no_retro_enabled(), diff --git a/lib/crates/fabro-cli/tests/it/cmd/run.rs b/lib/crates/fabro-cli/tests/it/cmd/run.rs index e1a9c8eed..64ce01c2b 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/run.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/run.rs @@ -542,6 +542,62 @@ fn dry_run_simple() { "); } +#[test] +fn dry_run_with_goal_file_reads_contents_into_goal() { + // Regression test for the `--goal-file` flag that was previously + // being silently ignored in the v2 path. The file content must end + // up in the effective goal displayed in the preflight summary. + let context = test_context!(); + + let goal_dir = tempfile::tempdir().unwrap(); + let goal_path = goal_dir.path().join("goal.md"); + std::fs::write(&goal_path, "Ship the rate-limiting feature end to end.\n").unwrap(); + + let mut cmd = context.run_cmd(); + cmd.args(["--dry-run", "--auto-approve", "--goal-file"]); + cmd.arg(&goal_path); + cmd.arg(example_fixture("simple.fabro")); + + let output = cmd.output().expect("run command should execute"); + assert!( + output.status.success(), + "run should succeed:\nstderr:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("Ship the rate-limiting feature end to end."), + "goal file content should appear in preflight summary, got:\n{stderr}" + ); +} + +#[test] +fn dry_run_rejects_goal_and_goal_file_together() { + // clap `conflicts_with` must fire when both flags are supplied. + let context = test_context!(); + + let goal_dir = tempfile::tempdir().unwrap(); + let goal_path = goal_dir.path().join("goal.md"); + std::fs::write(&goal_path, "never read").unwrap(); + + let mut cmd = context.run_cmd(); + cmd.args(["--dry-run", "--goal", "inline override", "--goal-file"]); + cmd.arg(&goal_path); + cmd.arg(example_fixture("simple.fabro")); + let output = cmd.output().expect("run command should execute"); + assert!( + !output.status.success(), + "run should fail when --goal and --goal-file are both set" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("cannot be used with") + || stderr.contains("conflict") + || stderr.to_lowercase().contains("mutually exclusive"), + "expected conflicts_with error, got:\n{stderr}" + ); +} + #[test] fn dry_run_persists_event_history_in_store() { let context = test_context!(); diff --git a/lib/crates/fabro-config/src/config.rs b/lib/crates/fabro-config/src/config.rs index a6050fa31..15295cb43 100644 --- a/lib/crates/fabro-config/src/config.rs +++ b/lib/crates/fabro-config/src/config.rs @@ -13,6 +13,8 @@ use std::path::Path; use anyhow::Context; +use fabro_types::settings::interp::InterpString; +use fabro_types::settings::run::RunGoalLayer; use fabro_types::settings::{SettingsFile, parse_settings_file as parse_v2_settings_file}; use serde::{Deserialize, Serialize}; @@ -20,6 +22,34 @@ use crate::merge::combine_files; use crate::project::{self}; use crate::user; +/// Rewrite any relative `run.goal = { file = "..." }` path in `file` to an +/// absolute path anchored at `base_dir`. +/// +/// Called from `ConfigLayer::load` so that layers coming from different +/// config files can be merged without losing the "relative to my source +/// file" context. Paths that contain `${env.NAME}` interpolation are left +/// alone (they get resolved against the run's working directory at consume +/// time via [`SettingsFile::resolve_run_goal`]). +fn resolve_goal_file_paths(file: &mut SettingsFile, base_dir: &Path) { + let Some(run) = file.run.as_mut() else { + return; + }; + let Some(RunGoalLayer::File { file: goal_file }) = run.goal.as_mut() else { + return; + }; + if !goal_file.is_literal() { + // Env-tokenized paths stay unresolved until consume time. + return; + } + let literal = goal_file.as_source(); + let path = Path::new(&literal); + if path.is_absolute() { + return; + } + let absolute = base_dir.join(path); + *goal_file = InterpString::parse(&absolute.to_string_lossy()); +} + /// A parsed settings file layer. /// /// Thin newtype around the v2 [`SettingsFile`] parse tree. The newtype @@ -65,10 +95,17 @@ impl ConfigLayer { } /// Load a v2 TOML settings file from disk. + /// + /// Relative `run.goal = { file = "..." }` paths are resolved against + /// the directory of `path` at load time. Subsequent merging with other + /// layers can then safely treat the path as self-contained. pub fn load(path: &Path) -> anyhow::Result { let content = std::fs::read_to_string(path) .with_context(|| format!("Failed to read {}", path.display()))?; - Self::parse(&content) + let mut layer = Self::parse(&content)?; + let base_dir = path.parent().unwrap_or_else(|| Path::new(".")); + resolve_goal_file_paths(&mut layer.file, base_dir); + Ok(layer) } /// Load workflow config + project config for a workflow path. @@ -124,7 +161,7 @@ impl ConfigLayer { #[cfg(test)] mod tests { - use fabro_types::settings::InterpString; + use fabro_types::settings::run::RunGoalLayer; use super::*; @@ -139,7 +176,7 @@ mod tests { } #[test] - fn parse_accepts_minimal_v2_file() { + fn parse_accepts_inline_goal() { let layer = ConfigLayer::parse( r#" _version = 1 @@ -149,17 +186,44 @@ goal = "Do things" ) .unwrap(); assert_eq!( - layer - .file - .run - .as_ref() - .and_then(|r| r.goal.as_ref()) - .map(InterpString::as_source) - .as_deref(), + layer.file.run_goal_inline_str().as_deref(), Some("Do things") ); } + #[test] + fn parse_accepts_file_variant() { + let layer = ConfigLayer::parse( + r#" +_version = 1 +[run.goal] +file = "prompts/goal.md" +"#, + ) + .unwrap(); + let Some(RunGoalLayer::File { file }) = layer.file.run_goal_layer() else { + panic!("expected run.goal.file variant"); + }; + assert_eq!(file.as_source(), "prompts/goal.md"); + } + + #[test] + fn parse_rejects_goal_with_unknown_sibling_fields() { + // The untagged enum should reject any `{ file = ..., extra = ... }` + // shape because neither the inline nor the file variant matches. + let err = ConfigLayer::parse( + r#" +_version = 1 +[run.goal] +file = "prompts/goal.md" +extra = "boom" +"#, + ) + .unwrap_err(); + let text = format!("{err:#}"); + assert!(text.to_lowercase().contains("run.goal") || text.contains("extra")); + } + #[test] fn combine_prefers_higher_precedence_self() { let higher = ConfigLayer::parse( @@ -180,14 +244,133 @@ goal = "lower goal" .unwrap(); let merged = higher.combine(lower); assert_eq!( - merged - .file - .run - .as_ref() - .and_then(|r| r.goal.as_ref()) - .map(InterpString::as_source) - .as_deref(), + merged.file.run_goal_inline_str().as_deref(), Some("higher goal") ); } + + #[test] + fn combine_replaces_file_goal_with_inline_from_higher_layer() { + // A higher-precedence `run.goal = "inline"` must fully override a + // lower layer's `run.goal = { file = "..." }` — the scalar merge + // treats `goal` as one field regardless of which variant each + // layer picked. + let higher = ConfigLayer::parse( + r#" +_version = 1 +[run] +goal = "inline override" +"#, + ) + .unwrap(); + let lower = ConfigLayer::parse( + r#" +_version = 1 +[run.goal] +file = "/tmp/goal.md" +"#, + ) + .unwrap(); + let merged = higher.combine(lower); + assert_eq!( + merged.file.run_goal_inline_str().as_deref(), + Some("inline override") + ); + } + + #[test] + fn combine_replaces_inline_goal_with_file_from_higher_layer() { + let higher = ConfigLayer::parse( + r#" +_version = 1 +[run.goal] +file = "/tmp/goal.md" +"#, + ) + .unwrap(); + let lower = ConfigLayer::parse( + r#" +_version = 1 +[run] +goal = "inline loser" +"#, + ) + .unwrap(); + let merged = higher.combine(lower); + assert!(matches!( + merged.file.run_goal_layer(), + Some(RunGoalLayer::File { .. }) + )); + } + + #[test] + fn load_rewrites_relative_goal_file_to_absolute() { + let tmp = tempfile::tempdir().unwrap(); + let config_path = tmp.path().join("fabro.toml"); + std::fs::write( + &config_path, + r#" +_version = 1 +[run.goal] +file = "prompts/goal.md" +"#, + ) + .unwrap(); + + let layer = ConfigLayer::load(&config_path).unwrap(); + let Some(RunGoalLayer::File { file }) = layer.file.run_goal_layer() else { + panic!("expected file variant"); + }; + let resolved = file.as_source(); + let expected = tmp.path().join("prompts").join("goal.md"); + assert_eq!(resolved, expected.to_string_lossy()); + } + + #[test] + fn load_leaves_absolute_goal_file_untouched() { + let tmp = tempfile::tempdir().unwrap(); + let config_path = tmp.path().join("fabro.toml"); + let abs_goal = "/etc/fabro/goal.md"; + std::fs::write( + &config_path, + format!( + r#" +_version = 1 +[run.goal] +file = "{abs_goal}" +"# + ), + ) + .unwrap(); + + let layer = ConfigLayer::load(&config_path).unwrap(); + let Some(RunGoalLayer::File { file }) = layer.file.run_goal_layer() else { + panic!("expected file variant"); + }; + assert_eq!(file.as_source(), abs_goal); + } + + #[test] + fn load_leaves_env_interpolated_goal_file_untouched() { + // InterpString paths aren't resolved at load time because env + // lookups happen at consume time. The loader should leave them + // alone. + let tmp = tempfile::tempdir().unwrap(); + let config_path = tmp.path().join("fabro.toml"); + std::fs::write( + &config_path, + r#" +_version = 1 +[run.goal] +file = "${env.GOALS_DIR}/goal.md" +"#, + ) + .unwrap(); + + let layer = ConfigLayer::load(&config_path).unwrap(); + let Some(RunGoalLayer::File { file }) = layer.file.run_goal_layer() else { + panic!("expected file variant"); + }; + assert_eq!(file.as_source(), "${env.GOALS_DIR}/goal.md"); + } } diff --git a/lib/crates/fabro-config/src/effective_settings.rs b/lib/crates/fabro-config/src/effective_settings.rs index 840f818f5..c9f59fde6 100644 --- a/lib/crates/fabro-config/src/effective_settings.rs +++ b/lib/crates/fabro-config/src/effective_settings.rs @@ -283,7 +283,10 @@ provider = "openai" ) .unwrap(); - assert_eq!(settings.run_goal_str().as_deref(), Some("workflow goal")); + assert_eq!( + settings.run_goal_inline_str().as_deref(), + Some("workflow goal") + ); assert_eq!( settings.run_model_name_str().as_deref(), Some("workflow-model") @@ -332,7 +335,10 @@ root = "/tmp/should-be-inert" settings.server_storage_root_str().as_deref(), Some("/srv/fabro") ); - assert_eq!(settings.run_goal_str().as_deref(), Some("project goal")); + assert_eq!( + settings.run_goal_inline_str().as_deref(), + Some("project goal") + ); } #[test] diff --git a/lib/crates/fabro-server/src/run_manifest.rs b/lib/crates/fabro-server/src/run_manifest.rs index 24673597b..d2b6aea35 100644 --- a/lib/crates/fabro-server/src/run_manifest.rs +++ b/lib/crates/fabro-server/src/run_manifest.rs @@ -21,8 +21,8 @@ use fabro_types::settings::SettingsFile; use fabro_types::settings::cli::{CliLayer, CliOutputLayer, OutputVerbosity}; use fabro_types::settings::interp::InterpString; use fabro_types::settings::run::{ - ApprovalMode, DaytonaDockerfileLayer, RunExecutionLayer, RunLayer, RunMode, RunModelLayer, - RunSandboxLayer, + ApprovalMode, DaytonaDockerfileLayer, RunExecutionLayer, RunGoalLayer, RunLayer, RunMode, + RunModelLayer, RunSandboxLayer, }; use fabro_util::check_report::{CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus}; use fabro_validate::Severity; @@ -91,7 +91,10 @@ pub(crate) fn prepare_manifest_with_mode( )?; if let Some(goal) = manifest.goal.as_ref() { let run = settings.run.get_or_insert_with(RunLayer::default); - run.goal = Some(InterpString::parse(&goal.text)); + // The CLI has already resolved any goal-file reads into + // `manifest.goal.text`, so the server side always stores the + // final text inline. + run.goal = Some(RunGoalLayer::Inline(InterpString::parse(&goal.text))); } Ok(PreparedManifest { diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 618ece3ef..d2788dfd7 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -7420,7 +7420,7 @@ level = "debug" // Server-side `dry_run` default must not override the manifest's intent. // Verify a sampling of the persisted v2 settings. assert_eq!( - run_record.settings.run_goal_str().as_deref(), + run_record.settings.run_goal_inline_str().as_deref(), Some("Test"), "goal should be persisted from the manifest" ); diff --git a/lib/crates/fabro-types/Cargo.toml b/lib/crates/fabro-types/Cargo.toml index aed2dee7f..5684113ff 100644 --- a/lib/crates/fabro-types/Cargo.toml +++ b/lib/crates/fabro-types/Cargo.toml @@ -29,3 +29,6 @@ serde_json.workspace = true sha2.workspace = true toml.workspace = true ulid.workspace = true + +[dev-dependencies] +tempfile = "3" diff --git a/lib/crates/fabro-types/src/settings/accessors.rs b/lib/crates/fabro-types/src/settings/accessors.rs index c16671dac..347128d46 100644 --- a/lib/crates/fabro-types/src/settings/accessors.rs +++ b/lib/crates/fabro-types/src/settings/accessors.rs @@ -6,15 +6,15 @@ //! walks the real v2 structure — there is no transitional state here. use std::collections::HashMap; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use super::cli::{CliExecLayer, CliLayer, CliOutputLayer}; use super::interp::InterpString; use super::project::ProjectLayer; use super::run::{ - ApprovalMode, GitAuthorLayer, HookEntry, McpEntryLayer, RunAgentLayer, RunArtifactsLayer, - RunCheckpointLayer, RunExecutionLayer, RunLayer, RunMode, RunModelLayer, RunPrepareLayer, - RunPullRequestLayer, RunSandboxLayer, + ApprovalMode, GitAuthorLayer, HookEntry, McpEntryLayer, ResolvedGoalSource, ResolvedRunGoal, + RunAgentLayer, RunArtifactsLayer, RunCheckpointLayer, RunExecutionLayer, RunGoalLayer, + RunLayer, RunMode, RunModelLayer, RunPrepareLayer, RunPullRequestLayer, RunSandboxLayer, }; use super::server::{ GithubIntegrationLayer, ServerApiLayer, ServerArtifactsLayer, ServerIntegrationsLayer, @@ -43,14 +43,67 @@ impl SettingsFile { self.run.as_ref() } + /// Raw access to the `run.goal` variant (inline or file). #[must_use] - pub fn run_goal(&self) -> Option<&InterpString> { + pub fn run_goal_layer(&self) -> Option<&RunGoalLayer> { self.run.as_ref().and_then(|r| r.goal.as_ref()) } + /// Inline goal text only. Returns `None` when `run.goal` is unset **or** + /// when it's a file-sourced goal — callers that need the file contents + /// should use [`SettingsFile::resolve_run_goal`]. #[must_use] - pub fn run_goal_str(&self) -> Option { - self.run_goal().map(InterpString::as_source) + pub fn run_goal_inline_str(&self) -> Option { + match self.run_goal_layer()? { + RunGoalLayer::Inline(s) => Some(s.as_source()), + RunGoalLayer::File { .. } => None, + } + } + + /// Resolve the `run.goal` layer to its final text, reading a file from + /// disk if necessary. + /// + /// Path resolution: + /// + /// - Absolute paths in the `file` variant are used as-is. + /// - Literal relative paths should already have been rewritten to + /// absolute at config-load time by + /// `fabro_config::resolve_goal_file_paths`. If one reaches this point + /// it will be resolved against `base_dir` as a fallback. + /// - `${env.NAME}` interpolation is resolved via `std::env::var` at + /// call time. Relative paths that survive interpolation are also + /// resolved against `base_dir`. + /// + /// Returns `Ok(None)` when `run.goal` is unset. Returns `Err` when the + /// file variant points at a path that can't be read or has an + /// unresolved env token. + pub fn resolve_run_goal( + &self, + base_dir: &Path, + ) -> Result, ResolveGoalError> { + let Some(layer) = self.run_goal_layer() else { + return Ok(None); + }; + match layer { + RunGoalLayer::Inline(s) => Ok(Some(ResolvedRunGoal { + text: s.as_source(), + source: ResolvedGoalSource::Inline, + })), + RunGoalLayer::File { file } => { + let resolved = file + .resolve(|name| std::env::var(name).ok()) + .map_err(|err| ResolveGoalError::EnvLookup { var: err.name })?; + let path = resolve_goal_file_path(&resolved.value, base_dir); + let text = std::fs::read_to_string(&path).map_err(|err| ResolveGoalError::Io { + path: path.clone(), + source: err, + })?; + Ok(Some(ResolvedRunGoal { + text, + source: ResolvedGoalSource::File { path }, + })) + } + } } #[must_use] @@ -410,21 +463,135 @@ impl SettingsFile { } } +/// Resolve a goal-file path string against `base_dir`. Absolute paths are +/// used as-is; relative paths are joined onto `base_dir`. +fn resolve_goal_file_path(path_str: &str, base_dir: &Path) -> PathBuf { + let path = Path::new(path_str); + if path.is_absolute() { + path.to_path_buf() + } else { + base_dir.join(path) + } +} + +/// Error returned by [`SettingsFile::resolve_run_goal`]. +#[derive(Debug)] +pub enum ResolveGoalError { + /// The `run.goal.file` InterpString referenced an env var that wasn't + /// set at consume time. + EnvLookup { var: String }, + /// The goal file exists in config but could not be read. + Io { + path: PathBuf, + source: std::io::Error, + }, +} + +impl std::fmt::Display for ResolveGoalError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::EnvLookup { var } => write!( + f, + "failed to resolve run.goal.file: env var {var:?} referenced by ${{env.{var}}} is not set" + ), + Self::Io { path, source } => write!( + f, + "failed to read run.goal.file at {}: {source}", + path.display() + ), + } + } +} + +impl std::error::Error for ResolveGoalError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::EnvLookup { .. } => None, + Self::Io { source, .. } => Some(source), + } + } +} + #[cfg(test)] mod tests { use super::*; use crate::settings::run::{RunLayer, RunModelLayer}; #[test] - fn run_goal_str_returns_source_value() { + fn run_goal_inline_str_returns_source_value() { let file = SettingsFile { run: Some(RunLayer { - goal: Some(InterpString::parse("Implement OAuth")), + goal: Some(RunGoalLayer::Inline(InterpString::parse("Implement OAuth"))), ..RunLayer::default() }), ..SettingsFile::default() }; - assert_eq!(file.run_goal_str().as_deref(), Some("Implement OAuth")); + assert_eq!( + file.run_goal_inline_str().as_deref(), + Some("Implement OAuth") + ); + } + + #[test] + fn run_goal_inline_str_is_none_for_file_variant() { + let file = SettingsFile { + run: Some(RunLayer { + goal: Some(RunGoalLayer::File { + file: InterpString::parse("/abs/goal.md"), + }), + ..RunLayer::default() + }), + ..SettingsFile::default() + }; + assert_eq!(file.run_goal_inline_str(), None); + assert!(matches!( + file.run_goal_layer(), + Some(RunGoalLayer::File { .. }) + )); + } + + #[test] + fn resolve_run_goal_reads_file_variant_from_disk() { + let tmp = tempfile::tempdir().unwrap(); + let goal_path = tmp.path().join("goal.md"); + std::fs::write(&goal_path, "ship the thing").unwrap(); + + let file = SettingsFile { + run: Some(RunLayer { + goal: Some(RunGoalLayer::File { + file: InterpString::parse(goal_path.to_str().unwrap()), + }), + ..RunLayer::default() + }), + ..SettingsFile::default() + }; + + let resolved = file + .resolve_run_goal(tmp.path()) + .expect("goal file should resolve") + .expect("goal should be set"); + assert_eq!(resolved.text, "ship the thing"); + assert!(matches!( + resolved.source, + ResolvedGoalSource::File { ref path } if path == &goal_path + )); + } + + #[test] + fn resolve_run_goal_inline_passes_text_through() { + let file = SettingsFile { + run: Some(RunLayer { + goal: Some(RunGoalLayer::Inline(InterpString::parse("literal goal"))), + ..RunLayer::default() + }), + ..SettingsFile::default() + }; + let resolved = file + .resolve_run_goal(std::path::Path::new("/")) + .unwrap() + .unwrap(); + assert_eq!(resolved.text, "literal goal"); + assert_eq!(resolved.source, ResolvedGoalSource::Inline); } #[test] diff --git a/lib/crates/fabro-types/src/settings/run.rs b/lib/crates/fabro-types/src/settings/run.rs index bee9b0409..04246fb30 100644 --- a/lib/crates/fabro-types/src/settings/run.rs +++ b/lib/crates/fabro-types/src/settings/run.rs @@ -19,7 +19,7 @@ use super::model_ref::ModelRef; #[serde(deny_unknown_fields)] pub struct RunLayer { #[serde(default, skip_serializing_if = "Option::is_none")] - pub goal: Option, + pub goal: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub working_dir: Option, /// Flat string-to-string map. Replaces wholesale across layers. @@ -56,6 +56,55 @@ pub struct RunLayer { pub artifacts: Option, } +/// 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 }, +} + +/// Outcome of resolving a [`RunGoalLayer`] to its final goal text. +/// +/// Carries provenance alongside the text so downstream consumers (e.g. the +/// run manifest builder) can distinguish inline goals from file-sourced +/// goals without having to re-walk the layer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedRunGoal { + pub text: String, + pub source: ResolvedGoalSource, +} + +/// Provenance of a [`ResolvedRunGoal`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ResolvedGoalSource { + /// Goal text came from a literal `run.goal = "..."` value. + Inline, + /// Goal text was read from a file on disk. The absolute path of that + /// file is carried for provenance / error reporting. + File { path: std::path::PathBuf }, +} + /// `[run.model]` — provider-neutral default model selection. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] diff --git a/lib/crates/fabro-workflow/src/operations/create.rs b/lib/crates/fabro-workflow/src/operations/create.rs index f087698cf..524bd5f58 100644 --- a/lib/crates/fabro-workflow/src/operations/create.rs +++ b/lib/crates/fabro-workflow/src/operations/create.rs @@ -3,7 +3,7 @@ use fabro_graphviz::graph::{AttrValue, Graph}; use fabro_model::{Catalog, Provider}; use fabro_sandbox::SandboxProvider; use fabro_store::Database; -use fabro_types::settings::run::{RunLayer, RunModelLayer}; +use fabro_types::settings::run::{RunGoalLayer, RunLayer, RunModelLayer}; use fabro_types::settings::{InterpString, SettingsFile}; use fabro_types::{RunId, RunProvenance}; use std::collections::BTreeMap; @@ -416,7 +416,7 @@ pub(crate) fn resolve_run_settings(mut settings: SettingsFile, graph: &Graph) -> run.goal = if goal.is_empty() { None } else { - Some(InterpString::parse(&goal)) + Some(RunGoalLayer::Inline(InterpString::parse(&goal))) }; // Strip disabled pull_request entries so downstream consumers can // treat `Some(_)` as "PR creation is on". @@ -559,12 +559,12 @@ mod tests { start -> work -> exit }"#; let validated = validate_dot(dot, { - use fabro_types::settings::run::RunLayer; + use fabro_types::settings::run::{RunGoalLayer, RunLayer}; let mut inputs = std::collections::HashMap::new(); inputs.insert("who".to_string(), toml::Value::String("agent".to_string())); SettingsFile { run: Some(RunLayer { - goal: Some(InterpString::parse("override")), + goal: Some(RunGoalLayer::Inline(InterpString::parse("override"))), inputs: Some(inputs), ..RunLayer::default() }), @@ -766,13 +766,14 @@ mod tests { }, settings: { use fabro_types::settings::run::{ - RunExecutionLayer, RunLayer, RunMode, RunModelLayer, RunPullRequestLayer, + RunExecutionLayer, RunGoalLayer, RunLayer, RunMode, RunModelLayer, + RunPullRequestLayer, }; let mut metadata = HashMap::new(); metadata.insert("env".to_string(), "test".to_string()); SettingsFile { run: Some(RunLayer { - goal: Some(InterpString::parse("override goal")), + goal: Some(RunGoalLayer::Inline(InterpString::parse("override goal"))), metadata, model: Some(RunModelLayer { name: Some(InterpString::parse("sonnet")), @@ -831,7 +832,7 @@ mod tests { .persisted .run_record() .settings - .run_goal_str() + .run_goal_inline_str() .as_deref(), Some("override goal") ); diff --git a/lib/crates/fabro-workflow/src/operations/source.rs b/lib/crates/fabro-workflow/src/operations/source.rs index 9f8206dfc..e70f34d44 100644 --- a/lib/crates/fabro-workflow/src/operations/source.rs +++ b/lib/crates/fabro-workflow/src/operations/source.rs @@ -3,8 +3,7 @@ use std::sync::Arc; use anyhow::Context; use fabro_config::project as project_config; -use fabro_types::settings::{InterpString, SettingsFile}; -use fabro_util::path::expand_tilde; +use fabro_types::settings::SettingsFile; use crate::file_resolver::{FileResolver, FilesystemFileResolver}; use crate::workflow_bundle::BundledWorkflow; @@ -39,25 +38,6 @@ pub(crate) struct ResolvedWorkflow { pub working_directory: PathBuf, } -fn resolve_goal_file( - goal_file: Option<&Path>, - working_directory: &Path, -) -> anyhow::Result> { - let Some(goal_file) = goal_file else { - return Ok(None); - }; - let expanded = expand_tilde(goal_file); - let goal_path = if expanded.is_absolute() { - expanded - } else { - working_directory.join(expanded) - }; - let content = std::fs::read_to_string(&goal_path) - .with_context(|| format!("failed to read goal file: {}", goal_path.display()))?; - tracing::debug!(path = %goal_path.display(), "Goal loaded from file"); - Ok(Some(content)) -} - fn workflow_slug_from_path(workflow_path: &Path) -> Option { let file_name = workflow_path.file_name()?.to_string_lossy(); if workflow_path.extension().is_none() { @@ -132,7 +112,7 @@ pub(crate) fn resolve_workflow(request: ResolveWorkflowInput) -> anyhow::Result< let settings = request.settings; let working_directory = project_config::resolve_working_directory(&settings, &request.cwd); - let goal_override = settings.run_goal().map(InterpString::as_source); + let goal_override = resolve_goal_override(&settings, &working_directory)?; Ok(ResolvedWorkflow { raw_source: workflow.source.clone(), @@ -149,17 +129,18 @@ pub(crate) fn resolve_workflow(request: ResolveWorkflowInput) -> anyhow::Result< } } +/// Resolve the `run.goal` override for a direct (non-manifest) workflow +/// run. Reads the file from disk if the goal layer is the `file` variant. +/// Relative paths that survived config load (e.g. env-interpolated ones) +/// are anchored at `working_directory`. fn resolve_goal_override( settings: &SettingsFile, working_directory: &Path, ) -> anyhow::Result> { - // V2 does not yet carry a separate `goal_file` field; file-based goals - // come through the workflow manifest layer in the server-side flow. - // For direct CLI paths, the goal override comes from `run.goal`. - Ok(settings - .run_goal() - .map(InterpString::as_source) - .or(resolve_goal_file(None, working_directory)?)) + settings + .resolve_run_goal(working_directory) + .map(|opt| opt.map(|resolved| resolved.text)) + .map_err(|err| anyhow::anyhow!(err)) } #[cfg(test)] @@ -168,6 +149,7 @@ mod tests { #[test] fn resolve_workflow_uses_explicit_cwd_for_relative_work_dir() { + use fabro_types::settings::InterpString; use fabro_types::settings::run::RunLayer; let dir = tempfile::tempdir().unwrap(); From 003b691de577387f2c9acd64447f36c10a96d94b Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 9 Apr 2026 20:41:00 -0400 Subject: [PATCH 44/47] fix(config): route project/workflow loaders through ConfigLayer::load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Stage 6 audit caught that `load_project_config` and `load_run_config` bypassed `ConfigLayer::load` and called `parse_project_config` / `ConfigLayer::parse` directly. As a result, `resolve_goal_file_paths` — which rewrites relative `[run.goal] file = "..."` paths to absolute against the declaring file's directory — only fired for `~/.fabro/settings.toml`, never for `fabro.toml` or `workflow.toml`. That meant a project author writing [run.goal] file = "prompts/goal.md" would have the relative path survive all the way to consume time and get resolved against the run's `working_directory` instead of the config-file directory, contradicting the agreed "config-file rooted" rule and breaking the most common case. Both loaders now delegate to `ConfigLayer::load(path)`, which performs the load-time rewrite. The user-settings path was already correct. ## Tests - `load_project_config_rewrites_relative_goal_file_path` - `load_run_config_rewrites_relative_goal_file_path` - `load_run_config_leaves_absolute_goal_file_untouched` - `build_manifest_resolves_relative_goal_file_in_project_config` — end-to-end via `build_run_manifest`, asserting the absolute path lands in `manifest.goal.path` and the file contents land in `manifest.goal.text`. - `build_manifest_resolves_relative_goal_file_in_workflow_config` — same shape but exercising `workflow.toml`-declared goal files, which resolve relative to the much deeper workflow directory rather than the project root. 3,787 workspace tests pass (was 3,782, +5 new). `cargo fmt --check --all` and `cargo clippy --workspace -- -D warnings` are clean. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/crates/fabro-cli/src/manifest_builder.rs | 128 +++++++++++++++++++ lib/crates/fabro-config/src/project.rs | 31 ++++- lib/crates/fabro-config/src/run.rs | 58 ++++++++- 3 files changed, 210 insertions(+), 7 deletions(-) diff --git a/lib/crates/fabro-cli/src/manifest_builder.rs b/lib/crates/fabro-cli/src/manifest_builder.rs index 716e77023..d88eb2599 100644 --- a/lib/crates/fabro-cli/src/manifest_builder.rs +++ b/lib/crates/fabro-cli/src/manifest_builder.rs @@ -660,4 +660,132 @@ mod tests { .contains_key("fabro/workflows/child/workflow.fabro") ); } + + /// A relative `[run.goal] file = "..."` declared in `fabro.toml` must + /// resolve against the directory of `fabro.toml`, not against the + /// invocation cwd. We exercise this by invoking from a subdirectory + /// below the project root. + #[test] + #[allow(unsafe_code, clippy::allow_attributes)] + fn build_manifest_resolves_relative_goal_file_in_project_config() { + let temp = tempfile::tempdir().unwrap(); + let project = temp.path(); + let workflow_dir = project.join("fabro/workflows/demo"); + std::fs::create_dir_all(&workflow_dir).unwrap(); + std::fs::create_dir_all(project.join("prompts")).unwrap(); + + std::fs::write( + project.join("fabro.toml"), + r#"_version = 1 + +[run.goal] +file = "prompts/goal.md" +"#, + ) + .unwrap(); + std::fs::write(project.join("prompts/goal.md"), "ship from project root").unwrap(); + + std::fs::write( + workflow_dir.join("workflow.toml"), + "_version = 1\n\n[workflow]\ngraph = \"workflow.fabro\"\n", + ) + .unwrap(); + std::fs::write( + workflow_dir.join("workflow.fabro"), + r"digraph Demo { start [shape=Mdiamond] exit [shape=Msquare] start -> exit }", + ) + .unwrap(); + + let sandboxed_settings = temp.path().join("empty-settings.toml"); + std::fs::write(&sandboxed_settings, "_version = 1\n").unwrap(); + // SAFETY: single-threaded unit test body. + unsafe { + std::env::set_var("FABRO_CONFIG", &sandboxed_settings); + } + + let built = build_run_manifest(ManifestBuildInput { + workflow: PathBuf::from("fabro/workflows/demo/workflow.toml"), + cwd: project.to_path_buf(), + args_layer: ConfigLayer::default(), + args: None, + run_id: None, + }) + .unwrap(); + + // SAFETY: single-threaded unit test body. + unsafe { + std::env::remove_var("FABRO_CONFIG"); + } + + let goal = built.manifest.goal.expect("manifest goal should be set"); + assert_eq!(goal.text, "ship from project root"); + assert_eq!(goal.type_, types::ManifestGoalType::File); + let resolved = goal.path.expect("file goal must carry a path"); + let expected = project.join("prompts").join("goal.md"); + assert_eq!(PathBuf::from(resolved), expected); + } + + /// A relative `[run.goal] file = "..."` declared in `workflow.toml` + /// must resolve against the directory of `workflow.toml`, not against + /// the invocation cwd or project root. + #[test] + #[allow(unsafe_code, clippy::allow_attributes)] + fn build_manifest_resolves_relative_goal_file_in_workflow_config() { + let temp = tempfile::tempdir().unwrap(); + let project = temp.path(); + let workflow_dir = project.join("fabro/workflows/demo"); + std::fs::create_dir_all(workflow_dir.join("prompts")).unwrap(); + + std::fs::write(project.join("fabro.toml"), "_version = 1\n").unwrap(); + std::fs::write( + workflow_dir.join("workflow.toml"), + r#"_version = 1 + +[workflow] +graph = "workflow.fabro" + +[run.goal] +file = "prompts/goal.md" +"#, + ) + .unwrap(); + std::fs::write( + workflow_dir.join("prompts/goal.md"), + "ship from workflow dir", + ) + .unwrap(); + std::fs::write( + workflow_dir.join("workflow.fabro"), + r"digraph Demo { start [shape=Mdiamond] exit [shape=Msquare] start -> exit }", + ) + .unwrap(); + + let sandboxed_settings = temp.path().join("empty-settings.toml"); + std::fs::write(&sandboxed_settings, "_version = 1\n").unwrap(); + // SAFETY: single-threaded unit test body. + unsafe { + std::env::set_var("FABRO_CONFIG", &sandboxed_settings); + } + + let built = build_run_manifest(ManifestBuildInput { + workflow: PathBuf::from("fabro/workflows/demo/workflow.toml"), + cwd: project.to_path_buf(), + args_layer: ConfigLayer::default(), + args: None, + run_id: None, + }) + .unwrap(); + + // SAFETY: single-threaded unit test body. + unsafe { + std::env::remove_var("FABRO_CONFIG"); + } + + let goal = built.manifest.goal.expect("manifest goal should be set"); + assert_eq!(goal.text, "ship from workflow dir"); + assert_eq!(goal.type_, types::ManifestGoalType::File); + let resolved = goal.path.expect("file goal must carry a path"); + let expected = workflow_dir.join("prompts").join("goal.md"); + assert_eq!(PathBuf::from(resolved), expected); + } } diff --git a/lib/crates/fabro-config/src/project.rs b/lib/crates/fabro-config/src/project.rs index eb7e569a6..2c49cf426 100644 --- a/lib/crates/fabro-config/src/project.rs +++ b/lib/crates/fabro-config/src/project.rs @@ -33,10 +33,11 @@ pub fn parse_project_config(content: &str) -> anyhow::Result { } /// Load a project config from a file path. +/// +/// Goes through [`ConfigLayer::load`] so that relative `run.goal.file` +/// paths are anchored at the directory of `path` at load time. pub fn load_project_config(path: &Path) -> anyhow::Result { - let content = std::fs::read_to_string(path) - .with_context(|| format!("Failed to read {}", path.display()))?; - let config = parse_project_config(&content)?; + let config = ConfigLayer::load(path).context("Failed to parse project config")?; let root = config .as_v2() .project @@ -477,4 +478,28 @@ retros = true assert_eq!(found_path, tmp.path().join("fabro.toml")); assert_eq!(config.as_v2().version, Some(1)); } + + #[test] + fn load_project_config_rewrites_relative_goal_file_path() { + use fabro_types::settings::run::RunGoalLayer; + + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("fabro.toml"); + fs::write( + &path, + r#"_version = 1 + +[run.goal] +file = "prompts/goal.md" +"#, + ) + .unwrap(); + + let config = load_project_config(&path).unwrap(); + let Some(RunGoalLayer::File { file }) = config.as_v2().run_goal_layer() else { + panic!("expected file variant"); + }; + let expected = tmp.path().join("prompts").join("goal.md"); + assert_eq!(file.as_source(), expected.to_string_lossy()); + } } diff --git a/lib/crates/fabro-config/src/run.rs b/lib/crates/fabro-config/src/run.rs index a5c015173..33fac41c4 100644 --- a/lib/crates/fabro-config/src/run.rs +++ b/lib/crates/fabro-config/src/run.rs @@ -18,11 +18,10 @@ pub fn parse_run_config(contents: &str) -> anyhow::Result { /// Load and parse a run config from a TOML file. /// -/// Returns the v2-backed `ConfigLayer`. +/// Goes through [`ConfigLayer::load`] so that relative `run.goal.file` +/// paths are anchored at the directory of `path` at load time. pub fn load_run_config(path: &Path) -> anyhow::Result { - let content = std::fs::read_to_string(path) - .with_context(|| format!("Failed to read {}", path.display()))?; - ConfigLayer::parse(&content) + ConfigLayer::load(path) .with_context(|| format!("Failed to parse workflow config at {}", path.display())) } @@ -34,3 +33,54 @@ pub fn resolve_graph_path(workflow_toml: &Path, graph_relative: &str) -> PathBuf .unwrap_or_else(|| Path::new(".")) .join(graph_relative) } + +#[cfg(test)] +mod tests { + use super::*; + use fabro_types::settings::run::RunGoalLayer; + + #[test] + fn load_run_config_rewrites_relative_goal_file_path() { + let tmp = tempfile::tempdir().unwrap(); + let workflow_dir = tmp.path().join("fabro").join("workflows").join("demo"); + std::fs::create_dir_all(&workflow_dir).unwrap(); + let workflow_toml = workflow_dir.join("workflow.toml"); + std::fs::write( + &workflow_toml, + r#"_version = 1 + +[run.goal] +file = "prompts/goal.md" +"#, + ) + .unwrap(); + + let config = load_run_config(&workflow_toml).unwrap(); + let Some(RunGoalLayer::File { file }) = config.as_v2().run_goal_layer() else { + panic!("expected file variant"); + }; + let expected = workflow_dir.join("prompts").join("goal.md"); + assert_eq!(file.as_source(), expected.to_string_lossy()); + } + + #[test] + fn load_run_config_leaves_absolute_goal_file_untouched() { + let tmp = tempfile::tempdir().unwrap(); + let workflow_toml = tmp.path().join("workflow.toml"); + std::fs::write( + &workflow_toml, + r#"_version = 1 + +[run.goal] +file = "/etc/fabro/goal.md" +"#, + ) + .unwrap(); + + let config = load_run_config(&workflow_toml).unwrap(); + let Some(RunGoalLayer::File { file }) = config.as_v2().run_goal_layer() else { + panic!("expected file variant"); + }; + assert_eq!(file.as_source(), "/etc/fabro/goal.md"); + } +} From 2d4c0945bb2654507ceacd456122da611def3142 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 9 Apr 2026 21:03:46 -0400 Subject: [PATCH 45/47] chore(simplify): cleanup from review of recent commits - Use FABRO_LOCAL_NO_AUTH_ENV const in start.rs and tests instead of the literal it was hoisted from. - Preserve error chain in resolve_goal_override via anyhow::Error::from rather than stringifying through anyhow!. - Drop {source} from ResolveGoalError::Io Display to avoid duplicate text under anyhow's chain formatter. - Fail loud in setup_register when ConfigLayer reload or parent dir creation errors instead of silently leaving stale state. - Promote resolve_goal_file_path to pub and call it from fabro-config to dedupe the absolute-or-base.join logic. - Trim narrator-voice paragraphs from tls_config and web_auth comments. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../fabro-cli/src/commands/server/start.rs | 3 +- .../fabro-cli/tests/it/cmd/server_start.rs | 5 ++-- lib/crates/fabro-config/src/config.rs | 6 ++-- lib/crates/fabro-server/src/tls_config.rs | 9 ++---- lib/crates/fabro-server/src/web_auth.rs | 28 ++++++++++++++----- .../fabro-types/src/settings/accessors.rs | 11 ++++---- .../fabro-workflow/src/operations/source.rs | 2 +- 7 files changed, 38 insertions(+), 26 deletions(-) diff --git a/lib/crates/fabro-cli/src/commands/server/start.rs b/lib/crates/fabro-cli/src/commands/server/start.rs index 7c9658945..5f9c0ea09 100644 --- a/lib/crates/fabro-cli/src/commands/server/start.rs +++ b/lib/crates/fabro-cli/src/commands/server/start.rs @@ -7,6 +7,7 @@ use chrono::Utc; use fabro_config::Storage; use fabro_config::user::default_socket_path; use fabro_server::bind::{Bind, BindRequest}; +use fabro_server::jwt_auth::FABRO_LOCAL_NO_AUTH_ENV; use fabro_server::serve; use fabro_server::serve::{DEFAULT_TCP_PORT, ServeArgs}; use fabro_util::terminal::Styles; @@ -230,7 +231,7 @@ fn execute_daemon( cmd.arg("--storage-dir").arg(storage_dir); if matches!(bind, BindRequest::Unix(_)) { - cmd.env("FABRO_LOCAL_NO_AUTH", "1"); + cmd.env(FABRO_LOCAL_NO_AUTH_ENV, "1"); } cmd.env_remove("FABRO_JSON"); diff --git a/lib/crates/fabro-cli/tests/it/cmd/server_start.rs b/lib/crates/fabro-cli/tests/it/cmd/server_start.rs index 2987f887f..c4c344186 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/server_start.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/server_start.rs @@ -1,3 +1,4 @@ +use fabro_server::jwt_auth::FABRO_LOCAL_NO_AUTH_ENV; use fabro_test::{fabro_snapshot, test_context}; use std::process::Stdio; use std::sync::{Arc, Barrier}; @@ -148,7 +149,7 @@ fn start_with_tcp_host_only_bind_resolves_to_host_and_port() { // startup explicitly. let mut cmd = context.command(); cmd.env("FABRO_STORAGE_DIR", &storage_dir); - cmd.env("FABRO_LOCAL_NO_AUTH", "1"); + cmd.env(FABRO_LOCAL_NO_AUTH_ENV, "1"); cmd.args(["server", "start", "--dry-run", "--bind", "127.0.0.1"]); let output = cmd.output().expect("server start command should run"); assert!( @@ -211,7 +212,7 @@ fn start_with_tcp_host_only_bind_warns_and_falls_back_when_default_port_is_unava // startup explicitly. let mut cmd = context.command(); cmd.env("FABRO_STORAGE_DIR", &storage_dir); - cmd.env("FABRO_LOCAL_NO_AUTH", "1"); + cmd.env(FABRO_LOCAL_NO_AUTH_ENV, "1"); cmd.args(["server", "start", "--dry-run", "--bind", "127.0.0.1"]); fabro_snapshot!(filters, cmd, @" success: true diff --git a/lib/crates/fabro-config/src/config.rs b/lib/crates/fabro-config/src/config.rs index 15295cb43..b9c066ccb 100644 --- a/lib/crates/fabro-config/src/config.rs +++ b/lib/crates/fabro-config/src/config.rs @@ -13,6 +13,7 @@ use std::path::Path; use anyhow::Context; +use fabro_types::settings::accessors::resolve_goal_file_path; use fabro_types::settings::interp::InterpString; use fabro_types::settings::run::RunGoalLayer; use fabro_types::settings::{SettingsFile, parse_settings_file as parse_v2_settings_file}; @@ -42,11 +43,10 @@ fn resolve_goal_file_paths(file: &mut SettingsFile, base_dir: &Path) { return; } let literal = goal_file.as_source(); - let path = Path::new(&literal); - if path.is_absolute() { + if Path::new(&literal).is_absolute() { return; } - let absolute = base_dir.join(path); + let absolute = resolve_goal_file_path(&literal, base_dir); *goal_file = InterpString::parse(&absolute.to_string_lossy()); } diff --git a/lib/crates/fabro-server/src/tls_config.rs b/lib/crates/fabro-server/src/tls_config.rs index 13955639c..84341813e 100644 --- a/lib/crates/fabro-server/src/tls_config.rs +++ b/lib/crates/fabro-server/src/tls_config.rs @@ -1,11 +1,8 @@ //! Resolved TLS material extracted from `[server.listen.tls]`. //! -//! This module owns the `(cert, key, ca)` triple that the rustls config -//! builder in [`crate::tls`] consumes when the server is listening on TCP -//! with mTLS enabled. It lives outside `jwt_auth.rs` because TLS material -//! is a listen-side concern, not an authentication strategy — the auth -//! resolver only cares about *whether* TLS is present (for mTLS support), -//! not about its contents. +//! Owns the `(cert, key, ca)` triple that the rustls config builder in +//! [`crate::tls`] consumes when the server is listening on TCP with mTLS +//! enabled. use std::path::PathBuf; diff --git a/lib/crates/fabro-server/src/web_auth.rs b/lib/crates/fabro-server/src/web_auth.rs index 5ae2d3d9a..b13d5d5fb 100644 --- a/lib/crates/fabro-server/src/web_auth.rs +++ b/lib/crates/fabro-server/src/web_auth.rs @@ -563,7 +563,13 @@ async fn setup_register( // preserves existing comments, whitespace, and key ordering. The value- // tree parser (`toml::Value`) would strip all of that on round-trip. if let Some(parent) = settings_path.parent() { - let _ = std::fs::create_dir_all(parent); + if let Err(err) = std::fs::create_dir_all(parent) { + error!(error = %err, path = %parent.display(), "Setup register failed: could not create settings parent directory"); + return json_response( + StatusCode::INTERNAL_SERVER_ERROR, + json!({"error": format!("Failed to create settings directory: {err}")}), + ); + } } let existing = std::fs::read_to_string(&settings_path).unwrap_or_default(); let mut doc: toml_edit::DocumentMut = if existing.is_empty() { @@ -622,12 +628,20 @@ async fn setup_register( } // Re-parse the freshly-written settings file and swap it into the - // in-memory state. Stage 6.6 may split this differently when the web - // setup flow is reworked, but for now a round-trip through - // `ConfigLayer::load` keeps the live state consistent with disk. - if let Ok(reloaded) = fabro_config::ConfigLayer::load(&settings_path) { - let mut shared = state.settings.write().expect("settings lock poisoned"); - *shared = reloaded.into(); + // in-memory state so subsequent OAuth requests see the new GitHub + // App credentials without a server restart. + match fabro_config::ConfigLayer::load(&settings_path) { + Ok(reloaded) => { + let mut shared = state.settings.write().expect("settings lock poisoned"); + *shared = reloaded.into(); + } + Err(err) => { + error!(error = %err, path = %settings_path.display(), "Setup register failed: could not reload written settings config"); + return json_response( + StatusCode::INTERNAL_SERVER_ERROR, + json!({"error": format!("Failed to reload settings config after write: {err}")}), + ); + } } info!(slug = %data.slug, app_id = %data.id, "GitHub App registered successfully"); diff --git a/lib/crates/fabro-types/src/settings/accessors.rs b/lib/crates/fabro-types/src/settings/accessors.rs index 347128d46..aef8b2bbb 100644 --- a/lib/crates/fabro-types/src/settings/accessors.rs +++ b/lib/crates/fabro-types/src/settings/accessors.rs @@ -465,7 +465,8 @@ impl SettingsFile { /// Resolve a goal-file path string against `base_dir`. Absolute paths are /// used as-is; relative paths are joined onto `base_dir`. -fn resolve_goal_file_path(path_str: &str, base_dir: &Path) -> PathBuf { +#[must_use] +pub fn resolve_goal_file_path(path_str: &str, base_dir: &Path) -> PathBuf { let path = Path::new(path_str); if path.is_absolute() { path.to_path_buf() @@ -494,11 +495,9 @@ impl std::fmt::Display for ResolveGoalError { f, "failed to resolve run.goal.file: env var {var:?} referenced by ${{env.{var}}} is not set" ), - Self::Io { path, source } => write!( - f, - "failed to read run.goal.file at {}: {source}", - path.display() - ), + Self::Io { path, .. } => { + write!(f, "failed to read run.goal.file at {}", path.display()) + } } } } diff --git a/lib/crates/fabro-workflow/src/operations/source.rs b/lib/crates/fabro-workflow/src/operations/source.rs index e70f34d44..93c1e5321 100644 --- a/lib/crates/fabro-workflow/src/operations/source.rs +++ b/lib/crates/fabro-workflow/src/operations/source.rs @@ -140,7 +140,7 @@ fn resolve_goal_override( settings .resolve_run_goal(working_directory) .map(|opt| opt.map(|resolved| resolved.text)) - .map_err(|err| anyhow::anyhow!(err)) + .map_err(anyhow::Error::from) } #[cfg(test)] From 09b616cb12c83d7ad63f15533ac5a9695868e190 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 9 Apr 2026 21:53:52 -0400 Subject: [PATCH 46/47] fix(server): convert auth resolver panics to fail-closed errors Completes the R52/R53 fail-closed posture from d4fb73d61. The jwt and mtls strategy branches were still using panic!/expect/assert! when their required material was missing or malformed, which would crash the server binary instead of returning a clean startup error. - decode_pem_env: return anyhow::Result instead of panicking on invalid base64 or invalid UTF-8. - resolve_auth_mode_with_lookup: convert the missing-FABRO_JWT_PUBLIC_KEY, invalid-PEM, and missing-[server.listen.tls]-for-mtls cases from panics to anyhow::Err returns prefixed with "Fabro server refuses to start". - Update the resolve_auth_mode doc to drop the "Panics if..." caveat. - Add three fail-closed tests covering each new error path. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/crates/fabro-server/src/jwt_auth.rs | 93 ++++++++++++++++++++----- 1 file changed, 74 insertions(+), 19 deletions(-) diff --git a/lib/crates/fabro-server/src/jwt_auth.rs b/lib/crates/fabro-server/src/jwt_auth.rs index b2eb439a8..7b45fff8c 100644 --- a/lib/crates/fabro-server/src/jwt_auth.rs +++ b/lib/crates/fabro-server/src/jwt_auth.rs @@ -69,14 +69,13 @@ pub enum AuthMode { pub struct PeerCertificates(pub Option>>); /// Decode a PEM env var that may be raw PEM or base64-encoded PEM. -pub fn decode_pem_env(name: &str, value: &str) -> String { +pub fn decode_pem_env(name: &str, value: &str) -> Result { if value.starts_with("-----") { - return value.to_string(); + return Ok(value.to_string()); } let bytes = base64::Engine::decode(&BASE64_STANDARD, value) - .unwrap_or_else(|e| panic!("{name} is not valid PEM or base64: {e}")); - String::from_utf8(bytes) - .unwrap_or_else(|e| panic!("{name} base64 decoded to invalid UTF-8: {e}")) + .map_err(|e| anyhow!("{name} is not valid PEM or base64: {e}"))?; + String::from_utf8(bytes).map_err(|e| anyhow!("{name} base64 decoded to invalid UTF-8: {e}")) } /// Resolve the authentication mode from a [`SettingsFile`]. @@ -87,9 +86,9 @@ pub fn decode_pem_env(name: &str, value: &str) -> String { /// when `server.auth` resolves to at least one enabled strategy. /// /// Fails closed when `server.auth` is absent or resolves to zero enabled -/// strategies: startup refuses rather than silently accepting every -/// request. Panics if a configured strategy is missing its required -/// material (JWT public key, mTLS TLS config). +/// strategies, or when a configured strategy is missing its required +/// material (JWT public key, mTLS TLS config): startup refuses rather +/// than silently accepting every request or panicking the binary. /// /// Walks the v2 `server.auth.api.{jwt,mtls}` subtree and /// `server.auth.web.allowed_usernames`. @@ -159,15 +158,20 @@ where } if jwt_enabled { - let raw = lookup("FABRO_JWT_PUBLIC_KEY").unwrap_or_else(|| { - panic!( - "FABRO_JWT_PUBLIC_KEY is not set. Provide an Ed25519 public key in PEM format \ + let raw = lookup("FABRO_JWT_PUBLIC_KEY").ok_or_else(|| { + anyhow!( + "Fabro server refuses to start: [server.auth.api.jwt] is enabled but \ + FABRO_JWT_PUBLIC_KEY is not set. Provide an Ed25519 public key in PEM format \ (or base64-encoded PEM) for JWT authentication." ) - }); - let pem = decode_pem_env("FABRO_JWT_PUBLIC_KEY", &raw); - let key = DecodingKey::from_ed_pem(pem.as_bytes()) - .expect("FABRO_JWT_PUBLIC_KEY contains an invalid Ed25519 PEM public key"); + })?; + let pem = decode_pem_env("FABRO_JWT_PUBLIC_KEY", &raw)?; + let key = DecodingKey::from_ed_pem(pem.as_bytes()).map_err(|e| { + anyhow!( + "Fabro server refuses to start: FABRO_JWT_PUBLIC_KEY contains an invalid \ + Ed25519 PEM public key: {e}" + ) + })?; strategies.push(AuthStrategy::Jwt { key: Arc::new(key), validation: Arc::new(jwt_validation()), @@ -176,10 +180,12 @@ where } if mtls_enabled { - assert!( - tls_present, - "mTLS authentication strategy requires [server.listen.tls] configuration with cert, key, and ca" - ); + if !tls_present { + return Err(anyhow!( + "Fabro server refuses to start: [server.auth.api.mtls] is enabled but \ + [server.listen.tls] is missing required cert, key, or ca paths." + )); + } strategies.push(AuthStrategy::Mtls); } @@ -550,6 +556,55 @@ ca = "/etc/fabro/tls/ca.pem" assert!(strategies.iter().any(|s| matches!(s, AuthStrategy::Mtls))); } + #[test] + fn fail_closed_when_jwt_enabled_without_public_key_env() { + let file = settings( + r" +_version = 1 + +[server.auth.api.jwt] +enabled = true +", + ); + let err = resolve_auth_mode_with_lookup(&file, empty_lookup) + .expect_err("missing FABRO_JWT_PUBLIC_KEY should refuse startup"); + assert!(err.to_string().contains("FABRO_JWT_PUBLIC_KEY")); + } + + #[test] + fn fail_closed_when_jwt_public_key_is_invalid_pem() { + let file = settings( + r" +_version = 1 + +[server.auth.api.jwt] +enabled = true +", + ); + let err = resolve_auth_mode_with_lookup(&file, |name| { + (name == "FABRO_JWT_PUBLIC_KEY").then(|| { + "-----BEGIN PUBLIC KEY-----\ngarbage\n-----END PUBLIC KEY-----".to_string() + }) + }) + .expect_err("invalid PEM should refuse startup"); + assert!(err.to_string().contains("invalid")); + } + + #[test] + fn fail_closed_when_mtls_enabled_without_listen_tls() { + let file = settings( + r" +_version = 1 + +[server.auth.api.mtls] +enabled = true +", + ); + let err = resolve_auth_mode_with_lookup(&file, empty_lookup) + .expect_err("mTLS without [server.listen.tls] should refuse startup"); + assert!(err.to_string().contains("server.listen.tls")); + } + async fn protected_handler(_auth: AuthenticatedService) -> impl IntoResponse { "ok" } From f414a7d719f94b4c20d4ff736726b3fd0bf50948 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 9 Apr 2026 22:11:20 -0400 Subject: [PATCH 47/47] chore(simplify): events schema v2 cleanup from review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cleanup pass on the events schema v2 work merged from origin/main. Quality fixes: - prompt.rs: drop dead `_visit` local; use stage_scope.visit at the emit site (the value was being recomputed inline next to a scope that already had it). - llm/cli.rs: rename `_context` to `context` in CodergenBackend::run (it's actually used now); delete the lingering `current_visit` helper that was deleted from llm/api.rs in c6a78a428 but missed here; use stage_scope.visit at the emit site. - llm/api.rs: rename `event_scope` to `stage_scope` for consistency with every other handler. - agent.rs, fan_in.rs, parallel.rs: same `visit_from_context` → `stage_scope.visit` substitution at every event-emit site. - parallel.rs: switch ParallelStarted/ParallelCompleted from `emit` to `emit_scoped` so they carry stage_id in the envelope. - event.rs: fix the StageScope::for_handler docstring — the lifecycle hook is `before_node`, not `before_attempt`. Reuse fixes: - run_event/mod.rs: add `ActorRef::agent(session_id, display)` symmetric with the existing `ActorRef::user`; use it from agent_actor_for_event in workflow event.rs. Correctness fixes: - event.rs: introduce `StageScope::for_parallel_branch` to name the "branch starts at visit 1" invariant the parallel handler was hardcoding via a struct literal at parallel.rs:307. This makes the assumption auditable and gives a single place to fix when parallel nodes ever loop. Efficiency fixes: - stage_id.rs: switch StageId/ParallelBranchId Serialize impls from `serializer.serialize_str(&self.to_string())` to `collect_str(self)`, removing one transient String allocation per ID per emitted event. Hardening: - event.rs: add `#[must_use]` on `to_run_event`, `to_run_event_at`, and `event_name`. - store/types.rs: add a second wire-envelope round-trip test that populates stage_id, parallel_group_id, parallel_branch_id, session_id, parent_session_id, tool_call_id, and actor — the existing test only exercised stage_id, so a regression in any of the other envelope fields' #[serde(flatten)] interaction would have been silent. All 3810 workspace tests pass; clippy and fmt clean. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/crates/fabro-store/src/types.rs | 58 ++++++++++++++++++- lib/crates/fabro-types/src/run_event/mod.rs | 9 +++ lib/crates/fabro-types/src/stage_id.rs | 4 +- lib/crates/fabro-workflow/src/event.rs | 43 +++++++++++--- .../fabro-workflow/src/handler/agent.rs | 7 +-- .../fabro-workflow/src/handler/fan_in.rs | 4 +- .../fabro-workflow/src/handler/llm/api.rs | 8 +-- .../fabro-workflow/src/handler/llm/cli.rs | 11 +--- .../fabro-workflow/src/handler/parallel.rs | 50 +++++++++------- .../fabro-workflow/src/handler/prompt.rs | 4 +- 10 files changed, 142 insertions(+), 56 deletions(-) diff --git a/lib/crates/fabro-store/src/types.rs b/lib/crates/fabro-store/src/types.rs index 35b485f01..aceafbc85 100644 --- a/lib/crates/fabro-store/src/types.rs +++ b/lib/crates/fabro-store/src/types.rs @@ -91,7 +91,10 @@ pub struct EventEnvelope { mod tests { use chrono::{TimeZone, Utc}; - use fabro_types::{EventBody, RunEvent, StageId, fixtures, run_event::RunCompletedProps}; + use fabro_types::{ + ActorRef, EventBody, ParallelBranchId, RunEvent, StageId, fixtures, + run_event::RunCompletedProps, + }; use super::{EventEnvelope, EventPayload}; @@ -133,4 +136,57 @@ mod tests { let parsed: EventEnvelope = serde_json::from_value(wire).unwrap(); assert_eq!(parsed, envelope); } + + #[test] + fn wire_event_envelope_round_trips_with_all_envelope_fields() { + let group = StageId::new("review", 2); + let branch = ParallelBranchId::new(group.clone(), 3); + let event = RunEvent { + id: "evt_2".to_string(), + ts: Utc.with_ymd_and_hms(2026, 4, 9, 13, 0, 0).unwrap(), + run_id: fixtures::RUN_1, + node_id: Some("review".to_string()), + node_label: Some("Review".to_string()), + stage_id: Some(StageId::new("review", 2)), + parallel_group_id: Some(group), + parallel_branch_id: Some(branch), + session_id: Some("ses_42".to_string()), + parent_session_id: Some("ses_root".to_string()), + tool_call_id: Some("tool_call_xyz".to_string()), + actor: Some(ActorRef::agent( + Some("ses_42".to_string()), + Some("claude-sonnet".to_string()), + )), + body: EventBody::RunCompleted(RunCompletedProps { + duration_ms: 100, + artifact_count: 1, + status: "success".to_string(), + reason: None, + total_usd_micros: None, + final_git_commit_sha: None, + final_patch: None, + billing: None, + }), + }; + let payload = EventPayload::new(event.to_value().unwrap(), &fixtures::RUN_1).unwrap(); + let envelope = EventEnvelope { seq: 99, payload }; + + let wire = serde_json::to_value(&envelope).unwrap(); + assert_eq!(wire["seq"], 99); + assert_eq!(wire["id"], "evt_2"); + assert_eq!(wire["stage_id"], "review@2"); + assert_eq!(wire["parallel_group_id"], "review@2"); + assert_eq!(wire["parallel_branch_id"], "review@2:3"); + assert_eq!(wire["session_id"], "ses_42"); + assert_eq!(wire["parent_session_id"], "ses_root"); + assert_eq!(wire["tool_call_id"], "tool_call_xyz"); + assert_eq!(wire["actor"]["kind"], "agent"); + assert_eq!(wire["actor"]["id"], "ses_42"); + assert_eq!(wire["actor"]["display"], "claude-sonnet"); + assert_eq!(wire["event"], "run.completed"); + assert!(wire.get("payload").is_none(), "wire shape must be flat"); + + let parsed: EventEnvelope = serde_json::from_value(wire).unwrap(); + assert_eq!(parsed, envelope); + } } diff --git a/lib/crates/fabro-types/src/run_event/mod.rs b/lib/crates/fabro-types/src/run_event/mod.rs index ca6d59107..18f6389ab 100644 --- a/lib/crates/fabro-types/src/run_event/mod.rs +++ b/lib/crates/fabro-types/src/run_event/mod.rs @@ -53,6 +53,15 @@ impl ActorRef { display: Some(login), } } + + #[must_use] + pub fn agent(session_id: Option, display: Option) -> Self { + Self { + kind: ActorKind::Agent, + id: session_id, + display, + } + } } #[derive(Debug, Clone, PartialEq)] diff --git a/lib/crates/fabro-types/src/stage_id.rs b/lib/crates/fabro-types/src/stage_id.rs index 846d61cb8..eef135774 100644 --- a/lib/crates/fabro-types/src/stage_id.rs +++ b/lib/crates/fabro-types/src/stage_id.rs @@ -76,7 +76,7 @@ impl Serialize for StageId { where S: Serializer, { - serializer.serialize_str(&self.to_string()) + serializer.collect_str(self) } } @@ -157,7 +157,7 @@ impl Serialize for ParallelBranchId { where S: Serializer, { - serializer.serialize_str(&self.to_string()) + serializer.collect_str(self) } } diff --git a/lib/crates/fabro-workflow/src/event.rs b/lib/crates/fabro-workflow/src/event.rs index 5b9bdc2f5..7e56abb92 100644 --- a/lib/crates/fabro-workflow/src/event.rs +++ b/lib/crates/fabro-workflow/src/event.rs @@ -5,8 +5,8 @@ use std::sync::atomic::{AtomicI64, Ordering}; use ::fabro_types::run_event as fabro_types; use ::fabro_types::{ - ActorKind, ActorRef, BilledTokenCounts, ParallelBranchId, RunBlobId, RunControlAction, - RunEvent, RunId, RunProvenance, StageId, StageStatus, StatusReason, + ActorRef, BilledTokenCounts, ParallelBranchId, RunBlobId, RunControlAction, RunEvent, RunId, + RunProvenance, StageId, StageStatus, StatusReason, }; use anyhow::{Context, Result}; use chrono::Utc; @@ -1141,6 +1141,7 @@ impl Event { } } +#[must_use] pub fn event_name(event: &Event) -> &'static str { match event { Event::RunCreated { .. } => "run.created", @@ -1453,11 +1454,10 @@ fn agent_tool_call_id(event: &AgentEvent) -> Option<&str> { fn agent_actor_for_event(event: &AgentEvent, session_id: Option<&str>) -> Option { match event { - AgentEvent::AssistantMessage { model, .. } => Some(ActorRef { - kind: ActorKind::Agent, - id: session_id.map(str::to_string), - display: Some(model.clone()), - }), + AgentEvent::AssistantMessage { model, .. } => Some(ActorRef::agent( + session_id.map(str::to_string), + Some(model.clone()), + )), _ => None, } } @@ -2487,7 +2487,7 @@ impl StageScope { } /// Build scope for a handler invocation. Prefers the `current_stage_scope` - /// seeded by the fidelity lifecycle before_attempt hook, and falls back to + /// seeded by the fidelity lifecycle `before_node` hook, and falls back to /// synthesizing one from `node_id` for direct-handler call sites (tests, /// etc.) that don't go through the full lifecycle. pub fn for_handler(context: &WfContext, node_id: impl Into) -> Self { @@ -2495,12 +2495,38 @@ impl StageScope { .current_stage_scope() .unwrap_or_else(|| Self::from_context(context, node_id)) } + + /// Build scope for the branch-lifecycle events emitted by the parallel + /// handler (`ParallelBranchStarted`, `ParallelBranchCompleted`, and the + /// pre-dispatch `GitCommit` for the branch worktree). + /// + /// `target_visit` is the visit count of `target_node_id` for this + /// particular branch dispatch. The parallel handler currently passes + /// `1` because branches haven't been re-entered yet at the point of + /// scope construction; a future change that loops a parallel node + /// must pass the actual visit so envelope `stage_id`s stay accurate. + #[must_use] + pub fn for_parallel_branch( + target_node_id: impl Into, + target_visit: u32, + parallel_group_id: StageId, + parallel_branch_id: ParallelBranchId, + ) -> Self { + Self { + node_id: target_node_id.into(), + visit: target_visit, + parallel_group_id: Some(parallel_group_id), + parallel_branch_id: Some(parallel_branch_id), + } + } } +#[must_use] pub fn to_run_event(run_id: &RunId, event: &Event) -> RunEvent { to_run_event_at(run_id, event, Utc::now(), None) } +#[must_use] pub fn to_run_event_at( run_id: &RunId, event: &Event, @@ -2846,6 +2872,7 @@ impl Emitter { #[cfg(test)] mod tests { use super::*; + use ::fabro_types::ActorKind; use ::fabro_types::fixtures; use std::sync::{Arc, Mutex}; diff --git a/lib/crates/fabro-workflow/src/handler/agent.rs b/lib/crates/fabro-workflow/src/handler/agent.rs index 541c2d226..484f65b5e 100644 --- a/lib/crates/fabro-workflow/src/handler/agent.rs +++ b/lib/crates/fabro-workflow/src/handler/agent.rs @@ -14,7 +14,6 @@ use crate::event::{Emitter, Event, StageScope}; use crate::outcome::{ BilledModelUsage, FailureCategory, FailureDetail, Outcome, OutcomeExt, StageStatus, }; -use crate::run_dir::visit_from_context; use crate::transforms::variable_expansion::expand_vars; use fabro_graphviz::graph::{Graph, Node}; @@ -250,7 +249,6 @@ impl Handler for AgentHandler { format!("{preamble}\n\n{expanded}") }; - let visit = u32::try_from(visit_from_context(context)).unwrap_or(u32::MAX); let prompt_provider = node .provider() .map(String::from) @@ -260,7 +258,7 @@ impl Handler for AgentHandler { services.emitter.emit_scoped( &Event::Prompt { stage: node.id.clone(), - visit, + visit: stage_scope.visit, text: prompt.clone(), mode: Some("agent".to_string()), provider: prompt_provider, @@ -720,8 +718,7 @@ mod tests { emitter.emit_scoped( &crate::event::Event::Agent { stage: node.id.clone(), - visit: u32::try_from(crate::run_dir::visit_from_context(context)) - .unwrap_or(u32::MAX), + visit: scope.visit, event: fabro_agent::AgentEvent::SessionStarted { provider: Some("openai".to_string()), model: Some("gpt-5.4".to_string()), diff --git a/lib/crates/fabro-workflow/src/handler/fan_in.rs b/lib/crates/fabro-workflow/src/handler/fan_in.rs index 458ff4886..0c1cc67dc 100644 --- a/lib/crates/fabro-workflow/src/handler/fan_in.rs +++ b/lib/crates/fabro-workflow/src/handler/fan_in.rs @@ -6,7 +6,6 @@ use crate::context::keys; use crate::error::FabroError; use crate::event::{Emitter, Event, StageScope}; use crate::outcome::{Outcome, OutcomeExt}; -use crate::run_dir::visit_from_context; use crate::sandbox_git::git_merge_ff_only; use async_trait::async_trait; use fabro_agent::Sandbox; @@ -231,13 +230,12 @@ async fn llm_evaluate( Respond with the ID of the best candidate." ); - let visit_u32 = u32::try_from(visit_from_context(context)).unwrap_or(u32::MAX); let stage_scope = StageScope::for_handler(context, node_id); emitter.emit_scoped( &Event::Prompt { stage: node_id.to_string(), - visit: visit_u32, + visit: stage_scope.visit, text: full_prompt.clone(), mode: Some("fan_in".to_string()), provider: None, diff --git a/lib/crates/fabro-workflow/src/handler/llm/api.rs b/lib/crates/fabro-workflow/src/handler/llm/api.rs index 3e03af58d..9b13ac08c 100644 --- a/lib/crates/fabro-workflow/src/handler/llm/api.rs +++ b/lib/crates/fabro-workflow/src/handler/llm/api.rs @@ -454,13 +454,13 @@ impl CodergenBackend for AgentApiBackend { touched: HashSet::new(), last: None, })); - let event_scope = StageScope::for_handler(context, &node.id); + let stage_scope = StageScope::for_handler(context, &node.id); // Subscribe to session events: forward to pipeline emitter + track files. spawn_event_forwarder( &session, node.id.clone(), - event_scope.clone(), + stage_scope.clone(), Arc::clone(emitter), Arc::clone(&file_tracking), ); @@ -497,7 +497,7 @@ impl CodergenBackend for AgentApiBackend { to_model: target.model.clone(), error: error_msg.clone(), }, - &event_scope, + &stage_scope, ); let target_provider: Provider = match target.provider.parse() { @@ -528,7 +528,7 @@ impl CodergenBackend for AgentApiBackend { spawn_event_forwarder( &session, node.id.clone(), - event_scope.clone(), + stage_scope.clone(), Arc::clone(emitter), Arc::clone(&file_tracking), ); diff --git a/lib/crates/fabro-workflow/src/handler/llm/cli.rs b/lib/crates/fabro-workflow/src/handler/llm/cli.rs index 9e52d8c63..08e2b04f8 100644 --- a/lib/crates/fabro-workflow/src/handler/llm/cli.rs +++ b/lib/crates/fabro-workflow/src/handler/llm/cli.rs @@ -12,7 +12,6 @@ use crate::context::Context; use crate::error::FabroError; use crate::event::{Emitter, Event, StageScope}; use crate::outcome::billed_model_usage_from_llm; -use crate::run_dir::visit_from_context; use fabro_graphviz::graph::Node; use fabro_llm::types::TokenCounts; @@ -55,10 +54,6 @@ impl AgentCli { } } -fn current_visit(context: &Context) -> u32 { - u32::try_from(visit_from_context(context)).unwrap_or(u32::MAX) -} - /// Ensure the CLI tool for the given provider is installed in the sandbox. /// /// Checks if the CLI binary exists; if not, installs Node.js (if missing) and @@ -461,7 +456,7 @@ impl CodergenBackend for AgentCliBackend { &self, node: &Node, prompt: &str, - _context: &Context, + context: &Context, _thread_id: Option<&str>, emitter: &Arc, sandbox: &Arc, @@ -496,11 +491,11 @@ impl CodergenBackend for AgentCliBackend { ensure_cli(cli, provider, sandbox, emitter).await?; let command = cli_command_for_provider(provider, model, &prompt_path); - let stage_scope = StageScope::for_handler(_context, &node.id); + let stage_scope = StageScope::for_handler(context, &node.id); emitter.emit_scoped( &Event::AgentCliStarted { node_id: node.id.clone(), - visit: current_visit(_context), + visit: stage_scope.visit, mode: "cli".to_string(), provider: provider.as_str().to_string(), model: model.to_string(), diff --git a/lib/crates/fabro-workflow/src/handler/parallel.rs b/lib/crates/fabro-workflow/src/handler/parallel.rs index acbb8fc7c..de674fe9a 100644 --- a/lib/crates/fabro-workflow/src/handler/parallel.rs +++ b/lib/crates/fabro-workflow/src/handler/parallel.rs @@ -151,15 +151,18 @@ impl Handler for ParallelHandler { .unwrap_or("wait_all"), ); - let parallel_visit = u32::try_from(visit_from_context(context)).unwrap_or(u32::MAX); - let parallel_group_id = StageId::new(node.id.clone(), parallel_visit); + let parallel_stage_scope = StageScope::for_handler(context, &node.id); + let parallel_group_id = StageId::new(node.id.clone(), parallel_stage_scope.visit); - services.emitter.emit(&Event::ParallelStarted { - node_id: node.id.clone(), - visit: parallel_visit, - branch_count: branches.len(), - join_policy: join_policy.to_string(), - }); + services.emitter.emit_scoped( + &Event::ParallelStarted { + node_id: node.id.clone(), + visit: parallel_stage_scope.visit, + branch_count: branches.len(), + join_policy: join_policy.to_string(), + }, + ¶llel_stage_scope, + ); { let run_id = context .run_id() @@ -301,12 +304,12 @@ impl Handler for ParallelHandler { .map(|gs| gs.git_author.clone()) .unwrap_or_default(); let group_id = parallel_group_id.clone(); - let branch_scope = StageScope { - node_id: setup.target_id.clone(), - visit: 1, - parallel_group_id: Some(group_id.clone()), - parallel_branch_id: Some(setup.parallel_branch_id.clone()), - }; + let branch_scope = StageScope::for_parallel_branch( + setup.target_id.clone(), + 1, + group_id.clone(), + setup.parallel_branch_id.clone(), + ); let handle = tokio::spawn(async move { let _permit = sem @@ -526,14 +529,17 @@ impl Handler for ParallelHandler { context.set(keys::PARALLEL_RESULTS, serde_json::json!(results_json)); context.set(keys::PARALLEL_BRANCH_COUNT, serde_json::json!(total)); - services.emitter.emit(&Event::ParallelCompleted { - node_id: node.id.clone(), - visit: u32::try_from(visit_from_context(context)).unwrap_or(u32::MAX), - duration_ms: millis_u64(parallel_start.elapsed()), - success_count, - failure_count: fail_count, - results: results_json.clone(), - }); + services.emitter.emit_scoped( + &Event::ParallelCompleted { + node_id: node.id.clone(), + visit: parallel_stage_scope.visit, + duration_ms: millis_u64(parallel_start.elapsed()), + success_count, + failure_count: fail_count, + results: results_json.clone(), + }, + ¶llel_stage_scope, + ); { let run_id = context .run_id() diff --git a/lib/crates/fabro-workflow/src/handler/prompt.rs b/lib/crates/fabro-workflow/src/handler/prompt.rs index 9dc5e25c2..67fc8227c 100644 --- a/lib/crates/fabro-workflow/src/handler/prompt.rs +++ b/lib/crates/fabro-workflow/src/handler/prompt.rs @@ -5,7 +5,6 @@ use crate::context::{Context, WorkflowContext}; use crate::error::FabroError; use crate::event::{Event, StageScope}; use crate::outcome::Outcome; -use crate::run_dir::visit_from_context; use async_trait::async_trait; use fabro_graphviz::graph::{Graph, Node}; use fabro_model::Provider; @@ -60,7 +59,6 @@ impl Handler for PromptHandler { } else { format!("{preamble}\n\n{expanded}") }; - let _visit = u32::try_from(visit_from_context(context)).unwrap_or(u32::MAX); // 1b. Discover project docs for system prompt when project_memory is enabled let system_prompt = if node.project_memory() { @@ -95,7 +93,7 @@ impl Handler for PromptHandler { services.emitter.emit_scoped( &Event::Prompt { stage: node.id.clone(), - visit: u32::try_from(visit_from_context(context)).unwrap_or(u32::MAX), + visit: stage_scope.visit, text: prompt.clone(), mode: Some("prompt".to_string()), provider: prompt_provider.clone(),