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
This commit is contained in:
Bryan Helmkamp 2026-04-09 09:17:37 -04:00
parent 288e733213
commit bb228643e7
No known key found for this signature in database
6 changed files with 1978 additions and 14 deletions

View file

@ -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<String, String> = 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<String, Vec<String>> = 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<String> = 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::<Vec<_>>()
.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<String, McpEntryLayer>) -> HashMap<String, McpServerEntry> {
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<String> = 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<String> = 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<OldHookType> {
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::<Vec<_>>()
.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<ApiAuthStrategy> {
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<String, String>, src: &HashMap<String, String>) {
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));
}
}

View file

@ -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<CliTargetLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth: Option<CliAuthLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exec: Option<CliExecLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output: Option<CliOutputLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub updates: Option<CliUpdatesLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub logging: Option<CliLoggingLayer>,
}
/// `[cli.target]` — explicit transport selection.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, tag = "type", rename_all = "lowercase")]
pub enum CliTargetLayer {
Http {
#[serde(default)]
url: Option<InterpString>,
#[serde(default)]
tls: Option<CliTargetTlsLayer>,
},
Unix {
#[serde(default)]
path: Option<InterpString>,
},
}
#[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<InterpString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key: Option<InterpString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ca: Option<InterpString>,
}
/// `[cli.auth]` — explicit auth strategy selection.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CliAuthLayer {
/// `none` explicitly disables inherited auth.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub strategy: Option<CliAuthStrategy>,
}
#[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<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<CliExecModelLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent: Option<CliExecAgentLayer>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CliExecModelLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<InterpString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<InterpString>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CliExecAgentLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub permissions: Option<AgentPermissions>,
/// Agent-scoped MCP entries for `fabro exec`.
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub mcps: HashMap<String, McpEntryLayer>,
}
/// `[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<OutputFormat>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub verbosity: Option<OutputVerbosity>,
}
#[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<bool>,
}
/// `[cli.logging]` — process-owned logging configuration for the CLI.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CliLoggingLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub level: Option<String>,
}

View file

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

View file

@ -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<String>,
pub goal: Option<InterpString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub working_dir: Option<String>,
pub working_dir: Option<InterpString>,
/// Flat string-to-string map. Replaces wholesale across layers.
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub metadata: HashMap<String, String>,
/// 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<HashMap<String, toml::Value>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<RunModelLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub git: Option<RunGitLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prepare: Option<RunPrepareLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub execution: Option<RunExecutionLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub checkpoint: Option<RunCheckpointLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sandbox: Option<RunSandboxLayer>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub notifications: HashMap<String, NotificationRouteLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub interviews: Option<InterviewsLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent: Option<RunAgentLayer>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub hooks: Vec<HookEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scm: Option<RunScmLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pull_request: Option<RunPullRequestLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub artifacts: Option<RunArtifactsLayer>,
}
/// `[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<InterpString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<InterpString>,
/// Ordered list of fallback model references. Supports `...` splice marker
/// at layering time — see [`super::splice_array`].
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub fallbacks: Vec<ModelRefOrSplice>,
}
/// A single `fallbacks` entry: either a parsed `ModelRef` or the splice marker.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ModelRefOrSplice {
ModelRef(ModelRef),
Splice,
}
impl Serialize for ModelRefOrSplice {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
match self {
Self::ModelRef(m) => m.serialize(serializer),
Self::Splice => serializer.serialize_str(super::splice_array::SPLICE_MARKER),
}
}
}
impl<'de> Deserialize<'de> for ModelRefOrSplice {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
use serde::de::Error;
let raw = String::deserialize(deserializer)?;
if raw == super::splice_array::SPLICE_MARKER {
return Ok(Self::Splice);
}
let model = raw.parse::<ModelRef>().map_err(D::Error::custom)?;
Ok(Self::ModelRef(model))
}
}
/// `[run.git]` — local git behavior such as commit author.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RunGitLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub author: Option<GitAuthorLayer>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GitAuthorLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<InterpString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub email: Option<InterpString>,
}
/// `[run.prepare]` — ordered list of preparation steps. Whole list replaces
/// across layers.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RunPrepareLayer {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub steps: Vec<PrepareStep>,
/// Optional timeout applied to each prepare step.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout: Option<Duration>,
}
/// A single prepare step. Exactly one of `script` or `command` must be set.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PrepareStep {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub script: Option<InterpString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub command: Option<Vec<InterpString>>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub env: HashMap<String, InterpString>,
}
/// `[run.execution]` — run posture knobs.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RunExecutionLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mode: Option<RunMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub approval: Option<ApprovalMode>,
/// Positive-form: `true` runs retros, `false` skips them.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retros: Option<bool>,
}
#[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<String>,
}
/// `[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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preserve: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub devcontainer: Option<bool>,
/// Sticky merge-by-key across layers.
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub env: HashMap<String, InterpString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub local: Option<LocalSandboxLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub daytona: Option<DaytonaSandboxLayer>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LocalSandboxLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worktree_mode: Option<WorktreeMode>,
}
#[derive(Debug, Clone, 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<i32>,
/// Sticky merge-by-key (provider-native labels).
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub labels: HashMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub snapshot: Option<DaytonaSnapshotLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub network: Option<DaytonaNetworkLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub skip_clone: Option<bool>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DaytonaSnapshotLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cpu: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memory: Option<super::size::Size>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub disk: Option<super::size::Size>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dockerfile: Option<DaytonaDockerfileLayer>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged, deny_unknown_fields)]
pub enum DaytonaDockerfileLayer {
Inline(String),
Path { path: String },
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum DaytonaNetworkLayer {
Block,
AllowAll,
AllowList { allow_list: Vec<String> },
}
/// `[run.notifications.<name>]` — 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<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
/// Raw Fabro event names. Splice marker supported at layering time.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub events: Vec<StringOrSplice>,
/// Provider-specific destination subtables. First-pass chat providers.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub slack: Option<NotificationProviderLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub discord: Option<NotificationProviderLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub teams: Option<NotificationProviderLayer>,
}
/// A single string array entry that may be the splice marker.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StringOrSplice {
Value(String),
Splice,
}
impl Serialize for StringOrSplice {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
match self {
Self::Value(s) => serializer.serialize_str(s),
Self::Splice => serializer.serialize_str(super::splice_array::SPLICE_MARKER),
}
}
}
impl<'de> Deserialize<'de> for StringOrSplice {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
if s == 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<InterpString>,
}
/// `[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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub slack: Option<InterviewProviderLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub discord: Option<InterviewProviderLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub teams: Option<InterviewProviderLayer>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct InterviewProviderLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub channel: Option<InterpString>,
}
/// `[run.agent]` — agent knobs only (permissions, MCPs).
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RunAgentLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub permissions: Option<AgentPermissions>,
/// Agent-scoped MCP server entries, keyed by name.
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub mcps: HashMap<String, McpEntryLayer>,
}
#[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<bool>,
url: InterpString,
#[serde(default)]
headers: HashMap<String, InterpString>,
#[serde(default)]
startup_timeout: Option<Duration>,
#[serde(default)]
tool_timeout: Option<Duration>,
},
Stdio {
#[serde(default)]
enabled: Option<bool>,
#[serde(default)]
script: Option<InterpString>,
#[serde(default)]
command: Option<Vec<InterpString>>,
#[serde(default)]
env: HashMap<String, InterpString>,
#[serde(default)]
startup_timeout: Option<Duration>,
#[serde(default)]
tool_timeout: Option<Duration>,
},
Sandbox {
#[serde(default)]
enabled: Option<bool>,
#[serde(default)]
script: Option<InterpString>,
#[serde(default)]
command: Option<Vec<InterpString>>,
port: u16,
#[serde(default)]
env: HashMap<String, InterpString>,
#[serde(default)]
startup_timeout: Option<Duration>,
#[serde(default)]
tool_timeout: Option<Duration>,
},
}
/// A run hook entry. Exactly one of `script`, `command`, `url`, `prompt`, or
/// `agent` fields determines the hook behavior. The `id` field, when set, is
/// used for cross-layer replace-by-id merging.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct HookEntry {
/// Optional merge identity. Hooks with the same `id` replace in place.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
/// Display-only human name.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
pub event: HookEvent,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub matcher: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub blocking: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout: Option<Duration>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sandbox: Option<bool>,
// Exactly one of the following groups is expected:
#[serde(default, skip_serializing_if = "Option::is_none")]
pub script: Option<InterpString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub command: Option<Vec<InterpString>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<InterpString>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub headers: HashMap<String, InterpString>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allowed_env_vars: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tls: Option<HookTlsMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prompt: Option<InterpString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<InterpString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_tool_rounds: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent: Option<HookAgentMarker>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HookTlsMode {
#[default]
Verify,
NoVerify,
Off,
}
/// Reserved marker for hook entries that use the `agent` hook type. Having
/// this as its own field rather than a flag lets `HookEntry` remain a flat
/// struct without a discriminator.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HookAgentMarker {
#[default]
Enabled,
}
#[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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub owner: Option<InterpString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repository: Option<InterpString>,
/// Provider-specific SCM leaves. First-pass providers.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub github: Option<ScmGitHubLayer>,
}
/// `[run.scm.github]` — GitHub-specific SCM leaf. Intentionally minimal in
/// the first pass; additional branch/checkout context stays on `run` or
/// `run.pull_request` until a concrete use case lands.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ScmGitHubLayer;
/// `[run.pull_request]` — provider-neutral PR behavior.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RunPullRequestLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub draft: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_merge: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub merge_strategy: Option<MergeStrategy>,
}
#[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<String>,
}

View file

@ -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<ServerListenLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api: Option<ServerApiLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub web: Option<ServerWebLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth: Option<ServerAuthLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub storage: Option<ServerStorageLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub artifacts: Option<ServerArtifactsLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub slatedb: Option<ServerSlateDbLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scheduler: Option<ServerSchedulerLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub logging: Option<ServerLoggingLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub integrations: Option<ServerIntegrationsLayer>,
}
/// `[server.listen]` — shared bind transport. 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<InterpString>,
#[serde(default)]
tls: Option<ServerListenTlsLayer>,
},
Unix {
#[serde(default)]
path: Option<InterpString>,
},
}
#[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<InterpString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key: Option<InterpString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ca: Option<InterpString>,
}
/// `[server.api]` — API surface settings.
///
/// `url` is an optional public URL; it is **not** derived from `server.listen`.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ServerApiLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<InterpString>,
}
/// `[server.web]` — web surface settings.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ServerWebLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<InterpString>,
}
/// `[server.auth]` — cohesive server auth surface.
///
/// When absent or resolved to no enabled API or web auth configuration, the
/// default server startup posture is fail-closed. Demo and test helpers may
/// explicitly opt in to insecure configurations.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ServerAuthLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api: Option<ServerAuthApiLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub web: Option<ServerAuthWebLayer>,
}
/// `[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<ServerAuthApiJwtLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mtls: Option<ServerAuthApiMtlsLayer>,
}
/// `[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<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub issuer: Option<InterpString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub audience: Option<InterpString>,
}
/// `[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<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ca: Option<InterpString>,
}
/// `[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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub providers: Option<ServerAuthWebProvidersLayer>,
}
/// `[server.auth.web.providers.<provider>]` — 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<ServerAuthWebGithubLayer>,
}
/// `[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<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_id: Option<InterpString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_secret: Option<InterpString>,
}
/// `[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<InterpString>,
}
/// `[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<ObjectStoreProvider>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prefix: Option<InterpString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub local: Option<ObjectStoreLocalLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub s3: Option<ObjectStoreS3Layer>,
}
/// `[server.slatedb]` — SlateDB bottomless storage plus tunables.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ServerSlateDbLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<ObjectStoreProvider>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prefix: Option<InterpString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub flush_interval: Option<Duration>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub local: Option<ObjectStoreLocalLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub s3: Option<ObjectStoreS3Layer>,
}
/// 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<InterpString>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ObjectStoreS3Layer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bucket: Option<InterpString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub region: Option<InterpString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub endpoint: Option<InterpString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path_style: Option<bool>,
}
/// `[server.scheduler]` — server-managed execution policy.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ServerSchedulerLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_concurrent_runs: Option<usize>,
}
/// `[server.logging]` — process-owned logging configuration for the server.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ServerLoggingLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub level: Option<String>,
}
/// `[server.integrations.<provider>]` — cohesive integration surface for chat
/// platforms and git providers (GitHub App, webhooks, etc.). First-pass
/// integrations enumerate known providers rather than using a flatten-HashMap
/// shape so strict unknown-field validation still holds.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ServerIntegrationsLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub github: Option<GithubIntegrationLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub slack: Option<SlackIntegrationLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub discord: Option<DiscordIntegrationLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub teams: Option<TeamsIntegrationLayer>,
}
/// `[server.integrations.github]` — GitHub App, credentials, and inbound webhooks.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GithubIntegrationLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub app_id: Option<InterpString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_id: Option<InterpString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub slug: Option<InterpString>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub permissions: HashMap<String, InterpString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub webhooks: Option<IntegrationWebhooksLayer>,
}
/// `[server.integrations.slack]` — Slack workspace credentials and defaults.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SlackIntegrationLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_channel: Option<InterpString>,
}
/// `[server.integrations.discord]` — Discord workspace configuration.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DiscordIntegrationLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
}
/// `[server.integrations.teams]` — Microsoft Teams configuration.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TeamsIntegrationLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct IntegrationWebhooksLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub strategy: Option<WebhookStrategy>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WebhookStrategy {
TailscaleFunnel,
}

View file

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