mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-15 23:32:46 +00:00
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:
parent
288e733213
commit
bb228643e7
6 changed files with 1978 additions and 14 deletions
809
lib/crates/fabro-types/src/settings/v2/bridge.rs
Normal file
809
lib/crates/fabro-types/src/settings/v2/bridge.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue