diff --git a/lib/crates/fabro-types/src/settings/v2/bridge.rs b/lib/crates/fabro-types/src/settings/v2/bridge.rs new file mode 100644 index 000000000..59d671969 --- /dev/null +++ b/lib/crates/fabro-types/src/settings/v2/bridge.rs @@ -0,0 +1,809 @@ +//! Temporary bridge from the v2 parse tree to the old flat [`Settings`] shape. +//! +//! This module exists only to keep consumers compiling while Stages 3 and 4 +//! migrate parsers and consumers across the workspace. Field mappings are +//! best-effort and deliberately lossy for anything the old shape does not +//! have a slot for. **This entire module is deleted in Stage 6.** +//! +//! Env var interpolation is not performed here; `${env.NAME}` tokens are +//! emitted verbatim via [`InterpString::as_source`]. The post-layering +//! interpolation pass runs in `fabro-config` during Stage 3, after layering +//! is already complete. + +use std::collections::HashMap; + +use super::cli::{CliExecLayer, CliLayer, CliOutputLayer, CliTargetLayer, OutputVerbosity}; +use super::interp::InterpString; +use super::project::ProjectLayer; +use super::run::{ + AgentPermissions as V2AgentPermissions, ApprovalMode, HookEntry as V2HookEntry, + HookEvent as V2HookEvent, McpEntryLayer, MergeStrategy as V2MergeStrategy, ModelRefOrSplice, + RunLayer, RunMode, WorktreeMode as V2WorktreeMode, +}; +use super::server::{ + ObjectStoreProvider, ServerArtifactsLayer, ServerIntegrationsLayer, ServerLayer, + ServerSchedulerLayer, ServerStorageLayer, ServerWebLayer, +}; +use super::tree::SettingsFile; +use super::workflow::WorkflowLayer; +use crate::settings::Settings; +use crate::settings::hook::{ + HookDefinition, HookEvent as OldHookEvent, HookType as OldHookType, TlsMode as OldTlsMode, +}; +use crate::settings::mcp::{McpServerEntry, McpTransport}; +use crate::settings::project::ProjectSettings; +use crate::settings::run::{ + ArtifactsSettings, CheckpointSettings, LlmSettings, MergeStrategy as OldMergeStrategy, + PullRequestSettings, SetupSettings, +}; +use crate::settings::sandbox::{ + DaytonaNetwork, DaytonaSettings, DaytonaSnapshotSettings, DockerfileSource, + LocalSandboxSettings, SandboxSettings, WorktreeMode as OldWorktreeMode, +}; +use crate::settings::server::{ + ApiAuthStrategy, ApiSettings, ArtifactStorageBackend, ArtifactStorageSettings, AuthProvider, + AuthSettings, FeaturesSettings, GitAuthorSettings, GitProvider, GitSettings, LogSettings, + SlackSettings, WebSettings, +}; +use crate::settings::user::{ + ExecSettings, OutputFormat, PermissionLevel, ServerSettings as UserServer, +}; + +/// Convert a v2 `SettingsFile` into the legacy flat [`Settings`] shape. +/// +/// This is a temporary seam. All v2 fields that do not map cleanly are +/// dropped; callers that need those should read the v2 tree directly. +#[must_use] +pub fn bridge_to_old(file: &SettingsFile) -> Settings { + let mut out = Settings { + version: file.version, + ..Settings::default() + }; + + if let Some(project) = &file.project { + bridge_project(project, &mut out); + } + if let Some(workflow) = &file.workflow { + bridge_workflow(workflow, &mut out); + } + if let Some(run) = &file.run { + bridge_run(run, &mut out); + } + if let Some(cli) = &file.cli { + bridge_cli(cli, &mut out); + } + if let Some(server) = &file.server { + bridge_server(server, &mut out); + } + if let Some(features) = &file.features { + out.features = Some(FeaturesSettings { + session_sandboxes: features.session_sandboxes.unwrap_or(false), + retros: false, // v2 moves retros to run.execution.retros + }); + } + + out +} + +fn bridge_project(project: &ProjectLayer, out: &mut Settings) { + if let Some(directory) = &project.directory { + out.fabro = Some(ProjectSettings { + root: directory.clone(), + }); + } + if !project.metadata.is_empty() { + merge_labels(&mut out.labels, &project.metadata); + } +} + +fn bridge_workflow(workflow: &WorkflowLayer, out: &mut Settings) { + if let Some(graph) = &workflow.graph { + out.graph = Some(graph.clone()); + } + if !workflow.metadata.is_empty() { + merge_labels(&mut out.labels, &workflow.metadata); + } +} + +fn bridge_run(run: &RunLayer, out: &mut Settings) { + if let Some(goal) = &run.goal { + out.goal = Some(interp_to_string(goal)); + } + if let Some(wd) = &run.working_dir { + out.work_dir = Some(interp_to_string(wd)); + } + if !run.metadata.is_empty() { + merge_labels(&mut out.labels, &run.metadata); + } + + if let Some(inputs) = &run.inputs { + let mut vars: HashMap = HashMap::new(); + for (k, v) in inputs { + vars.insert(k.clone(), toml_value_to_string(v)); + } + out.vars = Some(vars); + } + + if let Some(model) = &run.model { + let mut llm = LlmSettings::default(); + if let Some(p) = &model.provider { + llm.provider = Some(interp_to_string(p)); + } + if let Some(n) = &model.name { + llm.model = Some(interp_to_string(n)); + } + if !model.fallbacks.is_empty() { + let mut fallbacks_by_provider: HashMap> = HashMap::new(); + for entry in &model.fallbacks { + match entry { + ModelRefOrSplice::ModelRef(model_ref) => { + let s = model_ref.to_string(); + fallbacks_by_provider + .entry(String::new()) + .or_default() + .push(s); + } + ModelRefOrSplice::Splice => {} + } + } + if !fallbacks_by_provider.is_empty() { + llm.fallbacks = Some(fallbacks_by_provider); + } + } + out.llm = Some(llm); + } + + if let Some(prepare) = &run.prepare { + let commands: Vec = prepare + .steps + .iter() + .filter_map(|step| { + if let Some(script) = &step.script { + Some(interp_to_string(script)) + } else { + step.command.as_ref().map(|argv| { + argv.iter() + .map(interp_to_string) + .collect::>() + .join(" ") + }) + } + }) + .collect(); + let timeout_ms = prepare + .timeout + .map(|d| u64::try_from(d.as_std().as_millis()).unwrap_or(u64::MAX)); + out.setup = Some(SetupSettings { + commands, + timeout_ms, + }); + } + + if let Some(execution) = &run.execution { + out.dry_run = match execution.mode { + Some(RunMode::DryRun) => Some(true), + Some(RunMode::Normal) => Some(false), + None => None, + }; + out.auto_approve = match execution.approval { + Some(ApprovalMode::Auto) => Some(true), + Some(ApprovalMode::Prompt) => Some(false), + None => None, + }; + out.no_retro = execution.retros.map(|r| !r); + } + + if let Some(cp) = &run.checkpoint { + out.checkpoint = CheckpointSettings { + exclude_globs: cp.exclude_globs.clone(), + }; + } + + if let Some(sb) = &run.sandbox { + out.sandbox = Some(bridge_sandbox(sb)); + } + + if let Some(agent) = &run.agent { + let map = bridge_mcps(&agent.mcps); + if !map.is_empty() { + out.mcp_servers = map; + } + } + + if !run.hooks.is_empty() { + out.hooks = run.hooks.iter().map(bridge_hook).collect(); + } + + if let Some(pr) = &run.pull_request { + out.pull_request = Some(PullRequestSettings { + enabled: pr.enabled.unwrap_or(false), + draft: pr.draft.unwrap_or(true), + auto_merge: pr.auto_merge.unwrap_or(false), + merge_strategy: pr + .merge_strategy + .map(bridge_merge_strategy) + .unwrap_or_default(), + }); + } + + if let Some(art) = &run.artifacts { + out.artifacts = Some(ArtifactsSettings { + include: art.include.clone(), + }); + } + + // Slack notifications feed the old flat SlackSettings.default_channel. + for route in run.notifications.values() { + if let Some(slack) = &route.slack { + if let Some(channel) = &slack.channel { + out.slack + .get_or_insert_with(SlackSettings::default) + .default_channel = Some(interp_to_string(channel)); + break; + } + } + } + + // Git author from run.git + if let Some(git) = &run.git { + if let Some(author) = &git.author { + let git_settings = out.git.get_or_insert_with(GitSettings::default); + git_settings.author = GitAuthorSettings { + name: author.name.as_ref().map(interp_to_string), + email: author.email.as_ref().map(interp_to_string), + }; + } + } +} + +fn bridge_sandbox(sb: &super::run::RunSandboxLayer) -> SandboxSettings { + SandboxSettings { + provider: sb.provider.clone(), + preserve: sb.preserve, + devcontainer: sb.devcontainer, + local: sb.local.as_ref().map(|local| LocalSandboxSettings { + worktree_mode: local + .worktree_mode + .map(bridge_worktree_mode) + .unwrap_or_default(), + }), + daytona: sb.daytona.as_ref().map(|d| DaytonaSettings { + auto_stop_interval: d.auto_stop_interval, + labels: if d.labels.is_empty() { + None + } else { + Some(d.labels.clone()) + }, + snapshot: d.snapshot.as_ref().and_then(|s| { + s.name.as_ref().map(|name| DaytonaSnapshotSettings { + name: name.clone(), + cpu: s.cpu, + memory: s.memory.map(|sz| size_to_gb_i32(sz.as_bytes())), + disk: s.disk.map(|sz| size_to_gb_i32(sz.as_bytes())), + dockerfile: s.dockerfile.as_ref().map(|d| match d { + super::run::DaytonaDockerfileLayer::Inline(text) => { + DockerfileSource::Inline(text.clone()) + } + super::run::DaytonaDockerfileLayer::Path { path } => { + DockerfileSource::Path { path: path.clone() } + } + }), + }) + }), + network: d.network.as_ref().map(|n| match n { + super::run::DaytonaNetworkLayer::Block => DaytonaNetwork::Block, + super::run::DaytonaNetworkLayer::AllowAll => DaytonaNetwork::AllowAll, + super::run::DaytonaNetworkLayer::AllowList { allow_list } => { + DaytonaNetwork::AllowList(allow_list.clone()) + } + }), + skip_clone: d.skip_clone.unwrap_or(false), + }), + env: if sb.env.is_empty() { + None + } else { + Some( + sb.env + .iter() + .map(|(k, v)| (k.clone(), interp_to_string(v))) + .collect(), + ) + }, + } +} + +fn bridge_worktree_mode(m: V2WorktreeMode) -> OldWorktreeMode { + match m { + V2WorktreeMode::Always => OldWorktreeMode::Always, + V2WorktreeMode::Clean => OldWorktreeMode::Clean, + V2WorktreeMode::Dirty => OldWorktreeMode::Dirty, + V2WorktreeMode::Never => OldWorktreeMode::Never, + } +} + +fn bridge_merge_strategy(m: V2MergeStrategy) -> OldMergeStrategy { + match m { + V2MergeStrategy::Squash => OldMergeStrategy::Squash, + V2MergeStrategy::Merge => OldMergeStrategy::Merge, + V2MergeStrategy::Rebase => OldMergeStrategy::Rebase, + } +} + +fn bridge_mcps(mcps: &HashMap) -> HashMap { + mcps.iter() + .map(|(name, entry)| (name.clone(), bridge_mcp_entry(entry))) + .collect() +} + +fn bridge_mcp_entry(entry: &McpEntryLayer) -> McpServerEntry { + let transport = match entry { + McpEntryLayer::Stdio { + script, + command, + env, + .. + } => { + let command_vec: Vec = if let Some(script) = script { + vec!["sh".into(), "-c".into(), interp_to_string(script)] + } else if let Some(command) = command { + command.iter().map(interp_to_string).collect() + } else { + Vec::new() + }; + McpTransport::Stdio { + command: command_vec, + env: env + .iter() + .map(|(k, v)| (k.clone(), interp_to_string(v))) + .collect(), + } + } + McpEntryLayer::Http { url, headers, .. } => McpTransport::Http { + url: interp_to_string(url), + headers: headers + .iter() + .map(|(k, v)| (k.clone(), interp_to_string(v))) + .collect(), + }, + McpEntryLayer::Sandbox { + script, + command, + port, + env, + .. + } => { + let command_vec: Vec = if let Some(script) = script { + vec!["sh".into(), "-c".into(), interp_to_string(script)] + } else if let Some(command) = command { + command.iter().map(interp_to_string).collect() + } else { + Vec::new() + }; + McpTransport::Sandbox { + command: command_vec, + port: *port, + env: env + .iter() + .map(|(k, v)| (k.clone(), interp_to_string(v))) + .collect(), + } + } + }; + + let (startup_secs, tool_secs) = match entry { + McpEntryLayer::Http { + startup_timeout, + tool_timeout, + .. + } + | McpEntryLayer::Stdio { + startup_timeout, + tool_timeout, + .. + } + | McpEntryLayer::Sandbox { + startup_timeout, + tool_timeout, + .. + } => ( + startup_timeout.map_or(10, |d| d.as_std().as_secs()), + tool_timeout.map_or(60, |d| d.as_std().as_secs()), + ), + }; + + McpServerEntry { + transport, + startup_timeout_secs: startup_secs, + tool_timeout_secs: tool_secs, + } +} + +fn bridge_hook(hook: &V2HookEntry) -> HookDefinition { + let hook_type = resolve_hook_type(hook); + HookDefinition { + name: hook.name.clone().or_else(|| hook.id.clone()), + event: bridge_hook_event(hook.event), + command: None, + hook_type, + matcher: hook.matcher.clone(), + blocking: hook.blocking, + timeout_ms: hook + .timeout + .map(|d| u64::try_from(d.as_std().as_millis()).unwrap_or(u64::MAX)), + sandbox: hook.sandbox, + } +} + +fn resolve_hook_type(hook: &V2HookEntry) -> Option { + if let Some(script) = &hook.script { + return Some(OldHookType::Command { + command: interp_to_string(script), + }); + } + if let Some(command) = &hook.command { + return Some(OldHookType::Command { + command: command + .iter() + .map(interp_to_string) + .collect::>() + .join(" "), + }); + } + if let Some(url) = &hook.url { + let headers = if hook.headers.is_empty() { + None + } else { + Some( + hook.headers + .iter() + .map(|(k, v)| (k.clone(), interp_to_string(v))) + .collect(), + ) + }; + let tls = match hook.tls { + Some(super::run::HookTlsMode::Verify) => OldTlsMode::Verify, + Some(super::run::HookTlsMode::NoVerify) => OldTlsMode::NoVerify, + Some(super::run::HookTlsMode::Off) => OldTlsMode::Off, + None => OldTlsMode::default(), + }; + return Some(OldHookType::Http { + url: interp_to_string(url), + headers, + allowed_env_vars: hook.allowed_env_vars.clone(), + tls, + }); + } + if hook.agent.is_some() { + return Some(OldHookType::Agent { + prompt: hook + .prompt + .as_ref() + .map(interp_to_string) + .unwrap_or_default(), + model: hook.model.as_ref().map(interp_to_string), + max_tool_rounds: hook.max_tool_rounds, + }); + } + hook.prompt.as_ref().map(|prompt| OldHookType::Prompt { + prompt: interp_to_string(prompt), + model: hook.model.as_ref().map(interp_to_string), + }) +} + +fn bridge_hook_event(event: V2HookEvent) -> OldHookEvent { + match event { + V2HookEvent::RunStart => OldHookEvent::RunStart, + V2HookEvent::RunComplete => OldHookEvent::RunComplete, + V2HookEvent::RunFailed => OldHookEvent::RunFailed, + V2HookEvent::StageStart => OldHookEvent::StageStart, + V2HookEvent::StageComplete => OldHookEvent::StageComplete, + V2HookEvent::StageFailed => OldHookEvent::StageFailed, + V2HookEvent::StageRetrying => OldHookEvent::StageRetrying, + V2HookEvent::EdgeSelected => OldHookEvent::EdgeSelected, + V2HookEvent::ParallelStart => OldHookEvent::ParallelStart, + V2HookEvent::ParallelComplete => OldHookEvent::ParallelComplete, + V2HookEvent::SandboxReady => OldHookEvent::SandboxReady, + V2HookEvent::SandboxCleanup => OldHookEvent::SandboxCleanup, + V2HookEvent::CheckpointSaved => OldHookEvent::CheckpointSaved, + V2HookEvent::PreToolUse => OldHookEvent::PreToolUse, + V2HookEvent::PostToolUse => OldHookEvent::PostToolUse, + V2HookEvent::PostToolUseFailure => OldHookEvent::PostToolUseFailure, + } +} + +fn bridge_cli(cli: &CliLayer, out: &mut Settings) { + if let Some(target) = &cli.target { + let target_str = match target { + CliTargetLayer::Http { url, .. } => url.as_ref().map(interp_to_string), + CliTargetLayer::Unix { path } => path.as_ref().map(interp_to_string), + }; + if target_str.is_some() { + out.server = Some(UserServer { + target: target_str, + tls: None, + }); + } + } + + if let Some(exec) = &cli.exec { + out.exec = Some(bridge_exec(exec)); + if let Some(idle) = exec.prevent_idle_sleep { + out.prevent_idle_sleep = Some(idle); + } + } + + if let Some(output) = &cli.output { + bridge_cli_output(output, out); + } + + if let Some(updates) = &cli.updates { + out.upgrade_check = updates.check; + } +} + +fn bridge_exec(exec: &CliExecLayer) -> ExecSettings { + ExecSettings { + provider: exec + .model + .as_ref() + .and_then(|m| m.provider.as_ref()) + .map(interp_to_string), + model: exec + .model + .as_ref() + .and_then(|m| m.name.as_ref()) + .map(interp_to_string), + permissions: exec.agent.as_ref().and_then(|a| { + a.permissions.map(|p| match p { + V2AgentPermissions::ReadOnly => PermissionLevel::ReadOnly, + V2AgentPermissions::ReadWrite => PermissionLevel::ReadWrite, + V2AgentPermissions::Full => PermissionLevel::Full, + }) + }), + output_format: None, + } +} + +fn bridge_cli_output(output: &CliOutputLayer, out: &mut Settings) { + if let Some(format) = output.format { + let fmt = match format { + super::cli::OutputFormat::Text => OutputFormat::Text, + super::cli::OutputFormat::Json => OutputFormat::Json, + }; + out.exec + .get_or_insert_with(ExecSettings::default) + .output_format = Some(fmt); + } + if let Some(verbosity) = output.verbosity { + out.verbose = Some(matches!(verbosity, OutputVerbosity::Verbose)); + } +} + +fn bridge_server(server: &ServerLayer, out: &mut Settings) { + if let Some(storage) = &server.storage { + bridge_storage(storage, out); + } + if let Some(scheduler) = &server.scheduler { + bridge_scheduler(scheduler, out); + } + if let Some(artifacts) = &server.artifacts { + out.artifact_storage = Some(bridge_artifacts(artifacts)); + } + if let Some(web) = &server.web { + out.web = Some(bridge_web(web)); + } + if let Some(api) = &server.api { + out.api = Some(ApiSettings { + base_url: api.url.as_ref().map_or_else( + || "http://localhost:3000/api/v1".to_string(), + interp_to_string, + ), + authentication_strategies: bridge_api_auth_strategies(server.auth.as_ref()), + tls: None, + }); + } + if let Some(logging) = &server.logging { + out.log = Some(LogSettings { + level: logging.level.clone(), + }); + } + if let Some(integrations) = &server.integrations { + bridge_integrations(integrations, out); + } +} + +fn bridge_storage(storage: &ServerStorageLayer, out: &mut Settings) { + if let Some(root) = &storage.root { + out.storage_dir = Some(std::path::PathBuf::from(interp_to_string(root))); + } +} + +fn bridge_scheduler(scheduler: &ServerSchedulerLayer, out: &mut Settings) { + out.max_concurrent_runs = scheduler.max_concurrent_runs; +} + +fn bridge_artifacts(a: &ServerArtifactsLayer) -> ArtifactStorageSettings { + let backend = match a.provider { + Some(ObjectStoreProvider::Local) | None => ArtifactStorageBackend::Local, + Some(ObjectStoreProvider::S3) => ArtifactStorageBackend::S3, + }; + let prefix = a + .prefix + .as_ref() + .map_or_else(|| "artifacts".to_string(), interp_to_string); + let (bucket, region, endpoint, path_style) = + a.s3.as_ref().map_or((None, None, None, None), |s3| { + ( + s3.bucket.as_ref().map(interp_to_string), + s3.region.as_ref().map(interp_to_string), + s3.endpoint.as_ref().map(interp_to_string), + s3.path_style, + ) + }); + ArtifactStorageSettings { + backend, + prefix, + bucket, + region, + endpoint, + path_style, + } +} + +fn bridge_web(web: &ServerWebLayer) -> WebSettings { + WebSettings { + enabled: web.enabled.unwrap_or(true), + url: web + .url + .as_ref() + .map_or_else(|| "http://localhost:3000".to_string(), interp_to_string), + auth: AuthSettings { + provider: AuthProvider::Github, + allowed_usernames: Vec::new(), + }, + } +} + +fn bridge_api_auth_strategies( + auth: Option<&super::server::ServerAuthLayer>, +) -> Vec { + let Some(auth) = auth else { + return Vec::new(); + }; + let Some(api) = &auth.api else { + return Vec::new(); + }; + let mut out = Vec::new(); + if let Some(jwt) = &api.jwt { + if jwt.enabled.unwrap_or(true) { + out.push(ApiAuthStrategy::Jwt); + } + } + if let Some(mtls) = &api.mtls { + if mtls.enabled.unwrap_or(true) { + out.push(ApiAuthStrategy::Mtls); + } + } + out +} + +fn bridge_integrations(integrations: &ServerIntegrationsLayer, out: &mut Settings) { + if let Some(github) = &integrations.github { + let git_settings = out.git.get_or_insert_with(|| GitSettings { + provider: GitProvider::Github, + ..GitSettings::default() + }); + if let Some(id) = &github.app_id { + git_settings.app_id = Some(interp_to_string(id)); + } + if let Some(cid) = &github.client_id { + git_settings.client_id = Some(interp_to_string(cid)); + } + if let Some(slug) = &github.slug { + git_settings.slug = Some(interp_to_string(slug)); + } + } + if let Some(slack) = &integrations.slack { + let slack_settings = out.slack.get_or_insert_with(SlackSettings::default); + if let Some(channel) = &slack.default_channel { + slack_settings.default_channel = Some(interp_to_string(channel)); + } + } +} + +// ------------------- shared helpers ------------------- + +fn merge_labels(out: &mut HashMap, src: &HashMap) { + for (k, v) in src { + out.insert(k.clone(), v.clone()); + } +} + +fn interp_to_string(value: &InterpString) -> String { + value.as_source() +} + +fn toml_value_to_string(value: &toml::Value) -> String { + match value { + toml::Value::String(s) => s.clone(), + other => other.to_string(), + } +} + +fn size_to_gb_i32(bytes: u64) -> i32 { + let gb = bytes / 1_000_000_000; + i32::try_from(gb).unwrap_or(i32::MAX) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_file_bridges_to_empty_settings() { + let file = SettingsFile::default(); + let old = bridge_to_old(&file); + assert_eq!(old.goal, None); + assert_eq!(old.vars, None); + } + + #[test] + fn run_goal_bridges_to_old_goal() { + let file = SettingsFile { + run: Some(RunLayer { + goal: Some(InterpString::parse("Implement OAuth")), + ..RunLayer::default() + }), + ..SettingsFile::default() + }; + let old = bridge_to_old(&file); + assert_eq!(old.goal.as_deref(), Some("Implement OAuth")); + } + + #[test] + fn project_directory_bridges_to_old_fabro_root() { + let file = SettingsFile { + project: Some(ProjectLayer { + directory: Some("fabro/".into()), + ..ProjectLayer::default() + }), + ..SettingsFile::default() + }; + let old = bridge_to_old(&file); + assert_eq!(old.fabro.as_ref().map(|f| f.root.as_str()), Some("fabro/")); + } + + #[test] + fn run_execution_dry_run_bridges_to_old_dry_run_true() { + use super::super::run::{RunExecutionLayer, RunMode}; + let file = SettingsFile { + run: Some(RunLayer { + execution: Some(RunExecutionLayer { + mode: Some(RunMode::DryRun), + ..RunExecutionLayer::default() + }), + ..RunLayer::default() + }), + ..SettingsFile::default() + }; + let old = bridge_to_old(&file); + assert_eq!(old.dry_run, Some(true)); + } + + #[test] + fn run_execution_retros_true_bridges_to_old_no_retro_false() { + use super::super::run::RunExecutionLayer; + let file = SettingsFile { + run: Some(RunLayer { + execution: Some(RunExecutionLayer { + retros: Some(true), + ..RunExecutionLayer::default() + }), + ..RunLayer::default() + }), + ..SettingsFile::default() + }; + let old = bridge_to_old(&file); + assert_eq!(old.no_retro, Some(false)); + } +} diff --git a/lib/crates/fabro-types/src/settings/v2/cli.rs b/lib/crates/fabro-types/src/settings/v2/cli.rs index 1946a50d1..1e0dd37b0 100644 --- a/lib/crates/fabro-types/src/settings/v2/cli.rs +++ b/lib/crates/fabro-types/src/settings/v2/cli.rs @@ -3,12 +3,145 @@ //! `[cli]` is owner-first: the CLI process reads its settings from //! `~/.fabro/settings.toml` plus process-local overrides. `cli.*` stanzas in //! `fabro.toml` and `workflow.toml` remain schema-valid but runtime-inert. -//! This file holds only the Stage-1 skeleton; Stage 2 fleshes out the full -//! subtree (target, auth, exec, output, updates, logging). + +use std::collections::HashMap; use serde::{Deserialize, Serialize}; +use super::interp::InterpString; +use super::run::{AgentPermissions, McpEntryLayer}; + /// A sparse `[cli]` layer as it appears in a single settings file. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct CliLayer; +pub struct CliLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auth: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub exec: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub updates: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub logging: Option, +} + +/// `[cli.target]` — explicit transport selection. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, tag = "type", rename_all = "lowercase")] +pub enum CliTargetLayer { + Http { + #[serde(default)] + url: Option, + #[serde(default)] + tls: Option, + }, + Unix { + #[serde(default)] + path: Option, + }, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CliTargetTlsLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cert: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ca: Option, +} + +/// `[cli.auth]` — explicit auth strategy selection. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CliAuthLayer { + /// `none` explicitly disables inherited auth. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub strategy: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CliAuthStrategy { + None, + Jwt, + Mtls, +} + +/// `[cli.exec]` — `fabro exec` defaults. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CliExecLayer { + /// Prevent idle sleep on macOS while an exec run is in flight. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prevent_idle_sleep: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CliExecModelLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CliExecAgentLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub permissions: Option, + /// Agent-scoped MCP entries for `fabro exec`. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub mcps: HashMap, +} + +/// `[cli.output]` — generic CLI output defaults. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CliOutputLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub format: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub verbosity: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum OutputFormat { + Text, + Json, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum OutputVerbosity { + Quiet, + Normal, + Verbose, +} + +/// `[cli.updates]` — upgrade check toggle. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CliUpdatesLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub check: Option, +} + +/// `[cli.logging]` — process-owned logging configuration for the CLI. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CliLoggingLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub level: Option, +} diff --git a/lib/crates/fabro-types/src/settings/v2/mod.rs b/lib/crates/fabro-types/src/settings/v2/mod.rs index c88494cb3..73b595a19 100644 --- a/lib/crates/fabro-types/src/settings/v2/mod.rs +++ b/lib/crates/fabro-types/src/settings/v2/mod.rs @@ -6,6 +6,7 @@ //! Value-language helpers live alongside the tree: durations, byte sizes, //! model references, env interpolation, and splice-capable arrays. +pub mod bridge; pub mod cli; pub mod duration; pub mod features; @@ -20,6 +21,8 @@ pub mod tree; pub mod version; pub mod workflow; +pub use bridge::bridge_to_old; + pub use cli::CliLayer; pub use duration::{Duration, ParseDurationError}; pub use features::FeaturesLayer; diff --git a/lib/crates/fabro-types/src/settings/v2/run.rs b/lib/crates/fabro-types/src/settings/v2/run.rs index e03170e6c..bee9b0409 100644 --- a/lib/crates/fabro-types/src/settings/v2/run.rs +++ b/lib/crates/fabro-types/src/settings/v2/run.rs @@ -1,26 +1,536 @@ //! Run domain. //! //! `[run]` is the shared execution domain. It may appear in all three config -//! files and layer normally. This file holds only the Stage-1 skeleton; the -//! rich subtree (model, git, prepare, execution, checkpoint, sandbox, -//! notifications, interviews, agent, hooks, scm, pull_request, artifacts) is -//! filled in during Stage 2. +//! files and layer normally. Subdomains cover model selection, git author, +//! prepare steps, execution posture, checkpoint policy, sandbox selection, +//! notifications, interviews, agent knobs, hooks, SCM targeting, pull-request +//! behavior, and artifact collection. use std::collections::HashMap; use serde::{Deserialize, Serialize}; +use super::duration::Duration; +use super::interp::InterpString; +use super::model_ref::ModelRef; + /// A sparse `[run]` layer as it appears in a single settings file. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct RunLayer { #[serde(default, skip_serializing_if = "Option::is_none")] - pub goal: Option, + pub goal: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub working_dir: Option, + pub working_dir: Option, + /// Flat string-to-string map. Replaces wholesale across layers. #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub metadata: HashMap, - /// Run-time inputs. Stage 2 will widen the value type beyond strings. + /// Run inputs: typed scalar values. Replaces wholesale across layers. #[serde(default, skip_serializing_if = "Option::is_none")] pub inputs: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub git: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prepare: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub execution: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub checkpoint: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sandbox: Option, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub notifications: HashMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub interviews: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub hooks: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scm: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pull_request: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub artifacts: Option, +} + +/// `[run.model]` — provider-neutral default model selection. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RunModelLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + /// Ordered list of fallback model references. Supports `...` splice marker + /// at layering time — see [`super::splice_array`]. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub fallbacks: Vec, +} + +/// A single `fallbacks` entry: either a parsed `ModelRef` or the splice marker. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ModelRefOrSplice { + ModelRef(ModelRef), + Splice, +} + +impl Serialize for ModelRefOrSplice { + fn serialize(&self, serializer: S) -> Result { + match self { + Self::ModelRef(m) => m.serialize(serializer), + Self::Splice => serializer.serialize_str(super::splice_array::SPLICE_MARKER), + } + } +} + +impl<'de> Deserialize<'de> for ModelRefOrSplice { + fn deserialize>(deserializer: D) -> Result { + use serde::de::Error; + let raw = String::deserialize(deserializer)?; + if raw == super::splice_array::SPLICE_MARKER { + return Ok(Self::Splice); + } + let model = raw.parse::().map_err(D::Error::custom)?; + Ok(Self::ModelRef(model)) + } +} + +/// `[run.git]` — local git behavior such as commit author. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RunGitLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub author: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GitAuthorLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub email: Option, +} + +/// `[run.prepare]` — ordered list of preparation steps. Whole list replaces +/// across layers. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RunPrepareLayer { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub steps: Vec, + /// Optional timeout applied to each prepare step. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeout: Option, +} + +/// A single prepare step. Exactly one of `script` or `command` must be set. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PrepareStep { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub script: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub command: Option>, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub env: HashMap, +} + +/// `[run.execution]` — run posture knobs. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RunExecutionLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub approval: Option, + /// Positive-form: `true` runs retros, `false` skips them. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub retros: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RunMode { + Normal, + DryRun, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ApprovalMode { + Prompt, + Auto, +} + +/// `[run.checkpoint]` — checkpoint policy. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RunCheckpointLayer { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub exclude_globs: Vec, +} + +/// `[run.sandbox]` — sandbox selection and execution-environment surface. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RunSandboxLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub preserve: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub devcontainer: Option, + /// Sticky merge-by-key across layers. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub env: HashMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub local: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub daytona: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LocalSandboxLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub worktree_mode: Option, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WorktreeMode { + Always, + #[default] + Clean, + Dirty, + Never, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DaytonaSandboxLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auto_stop_interval: Option, + /// Sticky merge-by-key (provider-native labels). + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub labels: HashMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub snapshot: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub network: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub skip_clone: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DaytonaSnapshotLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cpu: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub memory: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub disk: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dockerfile: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged, deny_unknown_fields)] +pub enum DaytonaDockerfileLayer { + Inline(String), + Path { path: String }, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", deny_unknown_fields)] +pub enum DaytonaNetworkLayer { + Block, + AllowAll, + AllowList { allow_list: Vec }, +} + +/// `[run.notifications.]` — a keyed notification route. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct NotificationRouteLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + /// Raw Fabro event names. Splice marker supported at layering time. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub events: Vec, + /// Provider-specific destination subtables. First-pass chat providers. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub slack: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub discord: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub teams: Option, +} + +/// A single string array entry that may be the splice marker. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum StringOrSplice { + Value(String), + Splice, +} + +impl Serialize for StringOrSplice { + fn serialize(&self, serializer: S) -> Result { + match self { + Self::Value(s) => serializer.serialize_str(s), + Self::Splice => serializer.serialize_str(super::splice_array::SPLICE_MARKER), + } + } +} + +impl<'de> Deserialize<'de> for StringOrSplice { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + if s == super::splice_array::SPLICE_MARKER { + Ok(Self::Splice) + } else { + Ok(Self::Value(s)) + } + } +} + +/// Provider-specific destination fields for a notification route. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct NotificationProviderLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub channel: Option, +} + +/// `[run.interviews]` — external interview delivery. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct InterviewsLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub slack: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub discord: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub teams: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct InterviewProviderLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub channel: Option, +} + +/// `[run.agent]` — agent knobs only (permissions, MCPs). +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RunAgentLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub permissions: Option, + /// Agent-scoped MCP server entries, keyed by name. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub mcps: HashMap, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum AgentPermissions { + ReadOnly, + ReadWrite, + Full, +} + +/// A single MCP entry. `type` selects the transport; `script`/`command` are +/// mutually exclusive for process-launching transports. Non-launching HTTP +/// transports use neither field. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, tag = "type", rename_all = "snake_case")] +pub enum McpEntryLayer { + Http { + #[serde(default)] + enabled: Option, + url: InterpString, + #[serde(default)] + headers: HashMap, + #[serde(default)] + startup_timeout: Option, + #[serde(default)] + tool_timeout: Option, + }, + Stdio { + #[serde(default)] + enabled: Option, + #[serde(default)] + script: Option, + #[serde(default)] + command: Option>, + #[serde(default)] + env: HashMap, + #[serde(default)] + startup_timeout: Option, + #[serde(default)] + tool_timeout: Option, + }, + Sandbox { + #[serde(default)] + enabled: Option, + #[serde(default)] + script: Option, + #[serde(default)] + command: Option>, + port: u16, + #[serde(default)] + env: HashMap, + #[serde(default)] + startup_timeout: Option, + #[serde(default)] + tool_timeout: Option, + }, +} + +/// A run hook entry. Exactly one of `script`, `command`, `url`, `prompt`, or +/// `agent` fields determines the hook behavior. The `id` field, when set, is +/// used for cross-layer replace-by-id merging. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct HookEntry { + /// Optional merge identity. Hooks with the same `id` replace in place. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + /// Display-only human name. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + pub event: HookEvent, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub matcher: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub blocking: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeout: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sandbox: Option, + // Exactly one of the following groups is expected: + #[serde(default, skip_serializing_if = "Option::is_none")] + pub script: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub command: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub url: Option, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub headers: HashMap, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub allowed_env_vars: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tls: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prompt: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_tool_rounds: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent: Option, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum HookTlsMode { + #[default] + Verify, + NoVerify, + Off, +} + +/// Reserved marker for hook entries that use the `agent` hook type. Having +/// this as its own field rather than a flag lets `HookEntry` remain a flat +/// struct without a discriminator. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum HookAgentMarker { + #[default] + Enabled, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum HookEvent { + RunStart, + RunComplete, + RunFailed, + StageStart, + StageComplete, + StageFailed, + StageRetrying, + EdgeSelected, + ParallelStart, + ParallelComplete, + SandboxReady, + SandboxCleanup, + CheckpointSaved, + PreToolUse, + PostToolUse, + PostToolUseFailure, +} + +/// `[run.scm]` — remote SCM host/provider behavior. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RunScmLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub owner: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub repository: Option, + /// Provider-specific SCM leaves. First-pass providers. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub github: Option, +} + +/// `[run.scm.github]` — GitHub-specific SCM leaf. Intentionally minimal in +/// the first pass; additional branch/checkout context stays on `run` or +/// `run.pull_request` until a concrete use case lands. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ScmGitHubLayer; + +/// `[run.pull_request]` — provider-neutral PR behavior. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RunPullRequestLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub draft: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auto_merge: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub merge_strategy: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum MergeStrategy { + Squash, + Merge, + Rebase, +} + +/// `[run.artifacts]` — run artifact collection policy. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RunArtifactsLayer { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub include: Vec, } diff --git a/lib/crates/fabro-types/src/settings/v2/server.rs b/lib/crates/fabro-types/src/settings/v2/server.rs index 9e0ff87db..c1931c620 100644 --- a/lib/crates/fabro-types/src/settings/v2/server.rs +++ b/lib/crates/fabro-types/src/settings/v2/server.rs @@ -1,13 +1,323 @@ //! Server domain. //! //! `[server]` is a namespace container; actual settings live in named -//! subdomains. This file holds only the Stage-1 skeleton; Stage 2 fleshes out -//! the full subtree (listen, api, web, auth, storage, artifacts, slatedb, -//! scheduler, logging, integrations). +//! subdomains (listen, api, web, auth, storage, artifacts, slatedb, +//! scheduler, logging, integrations). Same-host and split-host deployments +//! use the same schema. + +use std::collections::HashMap; use serde::{Deserialize, Serialize}; +use super::duration::Duration; +use super::interp::InterpString; + /// A sparse `[server]` layer as it appears in a single settings file. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct ServerLayer; +pub struct ServerLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub listen: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub api: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub web: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auth: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub storage: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub artifacts: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub slatedb: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scheduler: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub logging: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub integrations: Option, +} + +/// `[server.listen]` — shared bind transport. TLS lives under `[server.listen.tls]`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, tag = "type", rename_all = "lowercase")] +pub enum ServerListenLayer { + Tcp { + #[serde(default)] + address: Option, + #[serde(default)] + tls: Option, + }, + Unix { + #[serde(default)] + path: Option, + }, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerListenTlsLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cert: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ca: Option, +} + +/// `[server.api]` — API surface settings. +/// +/// `url` is an optional public URL; it is **not** derived from `server.listen`. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerApiLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub url: Option, +} + +/// `[server.web]` — web surface settings. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerWebLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub url: Option, +} + +/// `[server.auth]` — cohesive server auth surface. +/// +/// When absent or resolved to no enabled API or web auth configuration, the +/// default server startup posture is fail-closed. Demo and test helpers may +/// explicitly opt in to insecure configurations. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerAuthLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub api: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub web: Option, +} + +/// `[server.auth.api]` — supports multiple strategies concurrently. Each +/// strategy is a named subtable: `[server.auth.api.jwt]`, `[server.auth.api.mtls]`. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerAuthApiLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub jwt: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mtls: Option, +} + +/// `[server.auth.api.jwt]` — JWT auth strategy fields. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerAuthApiJwtLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub issuer: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub audience: Option, +} + +/// `[server.auth.api.mtls]` — mutual TLS auth strategy fields. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerAuthApiMtlsLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ca: Option, +} + +/// `[server.auth.web]` — provider-neutral access rules plus keyed providers. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerAuthWebLayer { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub allowed_usernames: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub providers: Option, +} + +/// `[server.auth.web.providers.]` — web auth providers keyed by +/// provider name. First-pass providers cover GitHub. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerAuthWebProvidersLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub github: Option, +} + +/// `[server.auth.web.providers.github]` — GitHub OAuth configuration fields. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerAuthWebGithubLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client_secret: Option, +} + +/// `[server.storage]` — single managed local disk root. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerStorageLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub root: Option, +} + +/// `[server.artifacts]` — object-store-backed artifact storage. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerArtifactsLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prefix: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub local: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub s3: Option, +} + +/// `[server.slatedb]` — SlateDB bottomless storage plus tunables. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerSlateDbLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prefix: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub flush_interval: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub local: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub s3: Option, +} + +/// Closed enum of object-store providers. Unknown providers hard-fail +/// against the schema rather than passing through as opaque strings. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ObjectStoreProvider { + Local, + S3, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ObjectStoreLocalLayer { + /// Overrides the default root, which otherwise falls back to + /// `server.storage.root`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub root: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ObjectStoreS3Layer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bucket: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub region: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub endpoint: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path_style: Option, +} + +/// `[server.scheduler]` — server-managed execution policy. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerSchedulerLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_concurrent_runs: Option, +} + +/// `[server.logging]` — process-owned logging configuration for the server. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerLoggingLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub level: Option, +} + +/// `[server.integrations.]` — cohesive integration surface for chat +/// platforms and git providers (GitHub App, webhooks, etc.). First-pass +/// integrations enumerate known providers rather than using a flatten-HashMap +/// shape so strict unknown-field validation still holds. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerIntegrationsLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub github: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub slack: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub discord: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub teams: Option, +} + +/// `[server.integrations.github]` — GitHub App, credentials, and inbound webhooks. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GithubIntegrationLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub app_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub slug: Option, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub permissions: HashMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub webhooks: Option, +} + +/// `[server.integrations.slack]` — Slack workspace credentials and defaults. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SlackIntegrationLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_channel: Option, +} + +/// `[server.integrations.discord]` — Discord workspace configuration. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DiscordIntegrationLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, +} + +/// `[server.integrations.teams]` — Microsoft Teams configuration. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TeamsIntegrationLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct IntegrationWebhooksLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub strategy: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WebhookStrategy { + TailscaleFunnel, +} diff --git a/lib/crates/fabro-types/src/settings/v2/tree.rs b/lib/crates/fabro-types/src/settings/v2/tree.rs index 2d404cff3..498151d00 100644 --- a/lib/crates/fabro-types/src/settings/v2/tree.rs +++ b/lib/crates/fabro-types/src/settings/v2/tree.rs @@ -220,4 +220,203 @@ name = "Fabro" let err = parse_settings_file("_version = 99").unwrap_err(); assert!(err.to_string().contains("Upgrade")); } + + #[test] + fn representative_full_tree_parses() { + let input = r##" +_version = 1 + +[project] +name = "Fabro" +description = "AI workflow orchestration" +directory = "fabro/" + +[project.metadata] +owner = "platform" + +[workflow] +name = "Implement Feature" +description = "Turns a request into a code change" + +[run] +goal = "Implement OAuth refresh tokens" +working_dir = "/workspace" + +[run.inputs] +repo = "fabro" +branch = "main" + +[run.metadata] +team = "auth" + +[run.model] +provider = "anthropic" +name = "sonnet" +fallbacks = ["openai", "gpt-5.4", "gemini/gemini-flash"] + +[run.git.author] +name = "fabro-bot" +email = "bot@fabro.sh" + +[[run.prepare.steps]] +script = "bun install" + +[[run.prepare.steps]] +command = ["bun", "run", "typecheck"] + +[run.execution] +mode = "normal" +approval = "prompt" +retros = true + +[run.checkpoint] +exclude_globs = ["target/", "node_modules/"] + +[run.sandbox] +provider = "daytona" +preserve = false + +[run.sandbox.env] +AWS_REGION = "us-west-2" + +[run.sandbox.daytona] +auto_stop_interval = 60 + +[run.sandbox.daytona.snapshot] +name = "fabro-dev" +cpu = 4 +memory = "8GB" +disk = "20GB" + +[run.agent] +permissions = "read-write" + +[run.agent.mcps.fs] +type = "stdio" +command = ["npx", "-y", "@modelcontextprotocol/server-filesystem"] + +[run.notifications.ops] +enabled = true +provider = "slack" +events = ["run.failed", "run.completed"] + +[run.notifications.ops.slack] +channel = "#ops" + +[run.interviews] +provider = "slack" + +[run.interviews.slack] +channel = "#approvals" + +[[run.hooks]] +id = "pre-commit" +name = "Run linter before each commit" +event = "pre_tool_use" +script = "bun run lint" + +[run.pull_request] +enabled = true +draft = true +auto_merge = false +merge_strategy = "squash" + +[run.artifacts] +include = ["target/debug/fabro"] + +[cli.target] +type = "http" +url = "https://fabro.example.com/api/v1" + +[cli.auth] +strategy = "mtls" + +[cli.exec] +prevent_idle_sleep = true + +[cli.exec.model] +provider = "anthropic" +name = "claude-opus" + +[cli.exec.agent] +permissions = "read-write" + +[cli.output] +format = "text" +verbosity = "normal" + +[cli.updates] +check = true + +[cli.logging] +level = "info" + +[server.listen] +type = "tcp" +address = "127.0.0.1:32276" + +[server.api] +url = "https://fabro.example.com/api/v1" + +[server.web] +enabled = true +url = "https://fabro.example.com" + +[server.storage] +root = "/var/lib/fabro" + +[server.artifacts] +provider = "s3" +prefix = "artifacts" + +[server.artifacts.s3] +bucket = "fabro-artifacts" +region = "us-west-2" + +[server.slatedb] +provider = "s3" +prefix = "runs" +flush_interval = "1s" + +[server.slatedb.s3] +bucket = "fabro-slatedb" +region = "us-west-2" + +[server.scheduler] +max_concurrent_runs = 10 + +[server.logging] +level = "info" + +[features] +session_sandboxes = true +"##; + + let file = parse_settings_file(input).expect("full fixture should parse"); + let project = file.project.expect("project present"); + assert_eq!(project.name.as_deref(), Some("Fabro")); + assert_eq!(project.directory.as_deref(), Some("fabro/")); + + let run = file.run.expect("run present"); + let model = run.model.expect("run.model present"); + assert_eq!(model.fallbacks.len(), 3); + + let sandbox = run.sandbox.expect("run.sandbox present"); + assert_eq!(sandbox.env.len(), 1); + let daytona = sandbox.daytona.expect("daytona leaf present"); + let snap = daytona.snapshot.expect("daytona snapshot present"); + assert_eq!(snap.memory.map(|s| s.as_bytes()), Some(8_000_000_000)); + + let hooks = run.hooks; + assert_eq!(hooks.len(), 1); + assert_eq!(hooks[0].id.as_deref(), Some("pre-commit")); + + let cli = file.cli.expect("cli present"); + assert!(cli.target.is_some()); + assert!(cli.exec.is_some()); + + let server = file.server.expect("server present"); + let slate = server.slatedb.expect("slatedb present"); + assert!(slate.flush_interval.is_some()); + } }