refactor(settings): stage 6.3b delete legacy flat Settings struct

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) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-04-09 17:32:03 -04:00
parent 999f2a11c3
commit fb04e17329
8 changed files with 127 additions and 650 deletions

View file

@ -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()
}
}

View file

@ -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;

View file

@ -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<String> {
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<String>,
) {
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<String> = 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<String> = 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.

View file

@ -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": {},

View file

@ -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::{

View file

@ -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",

View file

@ -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<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub goal: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub goal_file: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub graph: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub labels: HashMap<String, String>,
#[serde(default, alias = "directory", skip_serializing_if = "Option::is_none")]
pub work_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub llm: Option<LlmSettings>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub setup: Option<SetupSettings>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sandbox: Option<SandboxSettings>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vars: Option<HashMap<String, String>>,
#[serde(default, skip_serializing_if = "is_default_checkpoint")]
pub checkpoint: CheckpointSettings,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pull_request: Option<PullRequestSettings>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub artifacts: Option<ArtifactsSettings>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub hooks: Vec<HookDefinition>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub mcp_servers: HashMap<String, McpServerEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub github: Option<GitHubSettings>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub server: Option<ServerSettings>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exec: Option<ExecSettings>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prevent_idle_sleep: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub verbose: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub upgrade_check: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dry_run: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_approve: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub no_retro: Option<bool>,
#[serde(default, alias = "data_dir", skip_serializing_if = "Option::is_none")]
pub storage_dir: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_concurrent_runs: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub artifact_storage: Option<ArtifactStorageSettings>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub web: Option<WebSettings>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub slack: Option<SlackSettings>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api: Option<ApiSettings>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub features: Option<FeaturesSettings>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub log: Option<LogSettings>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub git: Option<GitSettings>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fabro: Option<ProjectSettings>,
}
// 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.

View file

@ -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 ---