mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-17 04:37:03 +00:00
- Move Comparison link below Troubleshooting in nav - Rename "DOT Language" page to "Fabro Language" - Remove five-tier table from dark factory page, keep link to Dan Shapiro's post - Add fork command docs and checkpoints section - Add upgrade, asset list, asset cp command docs - Add upgrade_check config reference - Add retros feature flag to server config Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
557 lines
16 KiB
Rust
557 lines
16 KiB
Rust
use std::path::{Path, PathBuf};
|
|
|
|
use fabro_workflows::cli::run_config::RunDefaults;
|
|
use serde::{Deserialize, Serialize};
|
|
use tracing::debug;
|
|
|
|
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum AuthProvider {
|
|
#[default]
|
|
Github,
|
|
InsecureDisabled,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize)]
|
|
pub struct AuthConfig {
|
|
#[serde(default)]
|
|
pub provider: AuthProvider,
|
|
#[serde(default)]
|
|
pub allowed_usernames: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum ApiAuthStrategy {
|
|
Jwt,
|
|
Mtls,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)]
|
|
pub struct TlsConfig {
|
|
pub cert: PathBuf,
|
|
pub key: PathBuf,
|
|
pub ca: PathBuf,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)]
|
|
pub struct ApiConfig {
|
|
#[serde(default = "default_base_url")]
|
|
pub base_url: String,
|
|
#[serde(default)]
|
|
pub authentication_strategies: Vec<ApiAuthStrategy>,
|
|
pub tls: Option<TlsConfig>,
|
|
}
|
|
|
|
fn default_base_url() -> String {
|
|
"http://localhost:3000".to_string()
|
|
}
|
|
|
|
impl Default for ApiConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
base_url: default_base_url(),
|
|
authentication_strategies: Vec::new(),
|
|
tls: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum GitProvider {
|
|
#[default]
|
|
Github,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize)]
|
|
pub struct GitAuthorConfig {
|
|
pub name: Option<String>,
|
|
pub email: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum WebhookStrategy {
|
|
TailscaleFunnel,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)]
|
|
pub struct WebhookConfig {
|
|
pub strategy: WebhookStrategy,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize)]
|
|
pub struct GitConfig {
|
|
#[serde(default)]
|
|
pub provider: GitProvider,
|
|
pub app_id: Option<String>,
|
|
pub client_id: Option<String>,
|
|
pub slug: Option<String>,
|
|
#[serde(default)]
|
|
pub author: GitAuthorConfig,
|
|
pub webhooks: Option<WebhookConfig>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)]
|
|
pub struct WebConfig {
|
|
#[serde(default = "default_web_url")]
|
|
pub url: String,
|
|
#[serde(default)]
|
|
pub auth: AuthConfig,
|
|
}
|
|
|
|
fn default_web_url() -> String {
|
|
"http://localhost:5173".to_string()
|
|
}
|
|
|
|
impl Default for WebConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
url: default_web_url(),
|
|
auth: AuthConfig::default(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize)]
|
|
pub struct Features {
|
|
#[serde(default)]
|
|
pub session_sandboxes: bool,
|
|
/// Experimental: enable automatic retro generation after workflow runs.
|
|
#[serde(default)]
|
|
pub retros: bool,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
|
|
pub struct LogConfig {
|
|
pub level: Option<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
|
|
pub struct ServerConfig {
|
|
pub data_dir: Option<PathBuf>,
|
|
pub max_concurrent_runs: Option<usize>,
|
|
#[serde(default)]
|
|
pub web: WebConfig,
|
|
#[serde(default)]
|
|
pub api: ApiConfig,
|
|
#[serde(default)]
|
|
pub git: GitConfig,
|
|
#[serde(default)]
|
|
pub features: Features,
|
|
#[serde(default)]
|
|
pub log: LogConfig,
|
|
#[serde(flatten)]
|
|
pub run_defaults: RunDefaults,
|
|
}
|
|
|
|
/// Load server config from an explicit path or `~/.fabro/server.toml`, returning defaults if the
|
|
/// default file doesn't exist. An explicit path that doesn't exist is an error.
|
|
pub fn load_server_config(path: Option<&Path>) -> anyhow::Result<ServerConfig> {
|
|
if let Some(explicit) = path {
|
|
debug!(path = %explicit.display(), "Loading server config from explicit path");
|
|
let contents = std::fs::read_to_string(explicit)?;
|
|
return Ok(toml::from_str(&contents)?);
|
|
}
|
|
|
|
let Some(home) = dirs::home_dir() else {
|
|
debug!("No home directory found, using default server config");
|
|
return Ok(ServerConfig::default());
|
|
};
|
|
let default_path = home.join(".fabro").join("server.toml");
|
|
debug!(path = %default_path.display(), "Loading server config");
|
|
match std::fs::read_to_string(&default_path) {
|
|
Ok(contents) => Ok(toml::from_str(&contents)?),
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(ServerConfig::default()),
|
|
Err(e) => Err(e.into()),
|
|
}
|
|
}
|
|
|
|
/// Resolve the data directory: config value > default `~/.fabro`.
|
|
pub fn resolve_data_dir(config: &ServerConfig) -> PathBuf {
|
|
if let Some(ref dir) = config.data_dir {
|
|
return dir.clone();
|
|
}
|
|
dirs::home_dir()
|
|
.map(|h| h.join(".fabro"))
|
|
.unwrap_or_else(|| PathBuf::from(".fabro"))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn parse_config_with_data_dir() {
|
|
let toml = r#"data_dir = "/custom/path""#;
|
|
let config: ServerConfig = toml::from_str(toml).unwrap();
|
|
assert_eq!(config.data_dir, Some(PathBuf::from("/custom/path")));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_empty_config_defaults() {
|
|
let toml = "";
|
|
let config: ServerConfig = toml::from_str(toml).unwrap();
|
|
assert_eq!(config.data_dir, None);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_data_dir_uses_config_value() {
|
|
let config = ServerConfig {
|
|
data_dir: Some(PathBuf::from("/my/data")),
|
|
..ServerConfig::default()
|
|
};
|
|
assert_eq!(resolve_data_dir(&config), PathBuf::from("/my/data"));
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_data_dir_defaults_to_home_arc() {
|
|
let config = ServerConfig::default();
|
|
let dir = resolve_data_dir(&config);
|
|
// Should end with .fabro
|
|
assert!(
|
|
dir.ends_with(".fabro"),
|
|
"expected path ending with .fabro, got: {}",
|
|
dir.display()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_full_config() {
|
|
let toml = r#"
|
|
[web]
|
|
url = "https://arc.example.com"
|
|
|
|
[web.auth]
|
|
provider = "github"
|
|
allowed_usernames = ["brynary", "alice"]
|
|
|
|
[api]
|
|
base_url = "http://example.com:8080"
|
|
authentication_strategies = ["jwt"]
|
|
|
|
[git]
|
|
provider = "github"
|
|
app_id = "12345"
|
|
client_id = "Iv1.abc123"
|
|
"#;
|
|
let config: ServerConfig = toml::from_str(toml).unwrap();
|
|
assert_eq!(config.web.url, "https://arc.example.com");
|
|
assert_eq!(config.web.auth.provider, AuthProvider::Github);
|
|
assert_eq!(config.web.auth.allowed_usernames, vec!["brynary", "alice"]);
|
|
assert_eq!(config.api.base_url, "http://example.com:8080");
|
|
assert_eq!(
|
|
config.api.authentication_strategies,
|
|
vec![ApiAuthStrategy::Jwt]
|
|
);
|
|
assert_eq!(config.git.provider, GitProvider::Github);
|
|
assert_eq!(config.git.app_id.as_deref(), Some("12345"));
|
|
assert_eq!(config.git.client_id.as_deref(), Some("Iv1.abc123"));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_web_defaults() {
|
|
let toml = "";
|
|
let config: ServerConfig = toml::from_str(toml).unwrap();
|
|
assert_eq!(config.web.url, "http://localhost:5173");
|
|
assert_eq!(config.web.auth.provider, AuthProvider::Github);
|
|
assert!(config.web.auth.allowed_usernames.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn parse_api_defaults() {
|
|
let toml = "";
|
|
let config: ServerConfig = toml::from_str(toml).unwrap();
|
|
assert_eq!(config.api.base_url, "http://localhost:3000");
|
|
assert!(config.api.authentication_strategies.is_empty());
|
|
assert!(config.api.tls.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn parse_git_config() {
|
|
let toml = r#"
|
|
[git]
|
|
provider = "github"
|
|
app_id = "12345"
|
|
client_id = "Iv1.abc123"
|
|
"#;
|
|
let config: ServerConfig = toml::from_str(toml).unwrap();
|
|
assert_eq!(config.git.provider, GitProvider::Github);
|
|
assert_eq!(config.git.app_id.as_deref(), Some("12345"));
|
|
assert_eq!(config.git.client_id.as_deref(), Some("Iv1.abc123"));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_git_defaults() {
|
|
let toml = "";
|
|
let config: ServerConfig = toml::from_str(toml).unwrap();
|
|
assert_eq!(config.git.provider, GitProvider::Github);
|
|
assert_eq!(config.git.app_id, None);
|
|
assert_eq!(config.git.client_id, None);
|
|
assert_eq!(config.git.author.name, None);
|
|
assert_eq!(config.git.author.email, None);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_git_author_config() {
|
|
let toml = r#"
|
|
[git.author]
|
|
name = "fabro-bot"
|
|
email = "fabro-bot@company.com"
|
|
"#;
|
|
let config: ServerConfig = toml::from_str(toml).unwrap();
|
|
assert_eq!(config.git.author.name.as_deref(), Some("fabro-bot"));
|
|
assert_eq!(
|
|
config.git.author.email.as_deref(),
|
|
Some("fabro-bot@company.com")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_git_author_partial() {
|
|
let toml = r#"
|
|
[git.author]
|
|
name = "custom-name"
|
|
"#;
|
|
let config: ServerConfig = toml::from_str(toml).unwrap();
|
|
assert_eq!(config.git.author.name.as_deref(), Some("custom-name"));
|
|
assert_eq!(config.git.author.email, None);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_config_with_run_defaults() {
|
|
let toml = r#"
|
|
[llm]
|
|
model = "claude-haiku"
|
|
provider = "anthropic"
|
|
|
|
[sandbox]
|
|
provider = "daytona"
|
|
|
|
[vars]
|
|
repo_url = "https://github.com/org/repo"
|
|
"#;
|
|
let config: ServerConfig = toml::from_str(toml).unwrap();
|
|
let llm = config.run_defaults.llm.unwrap();
|
|
assert_eq!(llm.model.as_deref(), Some("claude-haiku"));
|
|
assert_eq!(llm.provider.as_deref(), Some("anthropic"));
|
|
let sandbox = config.run_defaults.sandbox.unwrap();
|
|
assert_eq!(sandbox.provider.as_deref(), Some("daytona"));
|
|
let vars = config.run_defaults.vars.unwrap();
|
|
assert_eq!(vars["repo_url"], "https://github.com/org/repo");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_config_server_and_run_defaults_together() {
|
|
let toml = r#"
|
|
[web.auth]
|
|
provider = "github"
|
|
|
|
[git]
|
|
provider = "github"
|
|
app_id = "123"
|
|
|
|
[llm]
|
|
model = "gpt-4"
|
|
"#;
|
|
let config: ServerConfig = toml::from_str(toml).unwrap();
|
|
assert_eq!(config.web.auth.provider, AuthProvider::Github);
|
|
assert_eq!(config.git.app_id.as_deref(), Some("123"));
|
|
let llm = config.run_defaults.llm.unwrap();
|
|
assert_eq!(llm.model.as_deref(), Some("gpt-4"));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_insecure_disabled_auth_provider() {
|
|
let toml = r#"
|
|
[web.auth]
|
|
provider = "insecure_disabled"
|
|
"#;
|
|
let config: ServerConfig = toml::from_str(toml).unwrap();
|
|
assert_eq!(config.web.auth.provider, AuthProvider::InsecureDisabled);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_jwt_and_mtls_strategies() {
|
|
let toml = r#"
|
|
[api]
|
|
authentication_strategies = ["jwt", "mtls"]
|
|
|
|
[api.tls]
|
|
cert = "~/.fabro/certs/server.crt"
|
|
key = "~/.fabro/certs/server.key"
|
|
ca = "~/.fabro/certs/ca.crt"
|
|
"#;
|
|
let config: ServerConfig = toml::from_str(toml).unwrap();
|
|
assert_eq!(
|
|
config.api.authentication_strategies,
|
|
vec![ApiAuthStrategy::Jwt, ApiAuthStrategy::Mtls]
|
|
);
|
|
let tls = config.api.tls.unwrap();
|
|
assert_eq!(tls.cert, PathBuf::from("~/.fabro/certs/server.crt"));
|
|
assert_eq!(tls.key, PathBuf::from("~/.fabro/certs/server.key"));
|
|
assert_eq!(tls.ca, PathBuf::from("~/.fabro/certs/ca.crt"));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_max_concurrent_runs() {
|
|
let toml = r#"max_concurrent_runs = 8"#;
|
|
let config: ServerConfig = toml::from_str(toml).unwrap();
|
|
assert_eq!(config.max_concurrent_runs, Some(8));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_max_concurrent_runs_defaults_to_none() {
|
|
let toml = "";
|
|
let config: ServerConfig = toml::from_str(toml).unwrap();
|
|
assert_eq!(config.max_concurrent_runs, None);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_empty_strategies() {
|
|
let toml = r#"
|
|
[api]
|
|
authentication_strategies = []
|
|
"#;
|
|
let config: ServerConfig = toml::from_str(toml).unwrap();
|
|
assert!(config.api.authentication_strategies.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn parse_jwt_only_strategy() {
|
|
let toml = r#"
|
|
[api]
|
|
authentication_strategies = ["jwt"]
|
|
"#;
|
|
let config: ServerConfig = toml::from_str(toml).unwrap();
|
|
assert_eq!(
|
|
config.api.authentication_strategies,
|
|
vec![ApiAuthStrategy::Jwt]
|
|
);
|
|
assert!(config.api.tls.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn load_server_config_from_explicit_path() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("custom.toml");
|
|
std::fs::write(&path, r#"max_concurrent_runs = 42"#).unwrap();
|
|
let config = load_server_config(Some(&path)).unwrap();
|
|
assert_eq!(config.max_concurrent_runs, Some(42));
|
|
}
|
|
|
|
#[test]
|
|
fn load_server_config_explicit_path_missing_is_error() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("nonexistent.toml");
|
|
let result = load_server_config(Some(&path));
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn parse_config_with_hooks() {
|
|
let toml = r#"
|
|
[[hooks]]
|
|
event = "run_start"
|
|
command = "echo 'run starting'"
|
|
|
|
[[hooks]]
|
|
event = "stage_complete"
|
|
command = "echo 'stage done'"
|
|
matcher = "agent_loop"
|
|
"#;
|
|
let config: ServerConfig = toml::from_str(toml).unwrap();
|
|
assert_eq!(config.run_defaults.hooks.len(), 2);
|
|
assert_eq!(
|
|
config.run_defaults.hooks[0].event,
|
|
fabro_workflows::hook::HookEvent::RunStart
|
|
);
|
|
assert_eq!(
|
|
config.run_defaults.hooks[0].command.as_deref(),
|
|
Some("echo 'run starting'")
|
|
);
|
|
assert_eq!(
|
|
config.run_defaults.hooks[1].event,
|
|
fabro_workflows::hook::HookEvent::StageComplete
|
|
);
|
|
assert_eq!(
|
|
config.run_defaults.hooks[1].matcher.as_deref(),
|
|
Some("agent_loop")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_features() {
|
|
let toml = "[features]\nsession_sandboxes = true";
|
|
let config: ServerConfig = toml::from_str(toml).unwrap();
|
|
assert!(config.features.session_sandboxes);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_features_defaults() {
|
|
let toml = "";
|
|
let config: ServerConfig = toml::from_str(toml).unwrap();
|
|
assert!(!config.features.session_sandboxes);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_config_without_hooks_defaults_empty() {
|
|
let toml = "";
|
|
let config: ServerConfig = toml::from_str(toml).unwrap();
|
|
assert!(config.run_defaults.hooks.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn parse_config_with_checkpoint_exclude_globs() {
|
|
let toml = r#"
|
|
[checkpoint]
|
|
exclude_globs = ["**/node_modules/**", "**/.cache/**"]
|
|
"#;
|
|
let config: ServerConfig = toml::from_str(toml).unwrap();
|
|
assert_eq!(
|
|
config.run_defaults.checkpoint.exclude_globs,
|
|
vec!["**/node_modules/**", "**/.cache/**"]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_config_checkpoint_defaults_empty() {
|
|
let toml = "";
|
|
let config: ServerConfig = toml::from_str(toml).unwrap();
|
|
assert!(config.run_defaults.checkpoint.exclude_globs.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn parse_git_webhooks_config() {
|
|
let toml = r#"
|
|
[git]
|
|
provider = "github"
|
|
app_id = "2993730"
|
|
|
|
[git.webhooks]
|
|
strategy = "tailscale_funnel"
|
|
"#;
|
|
let config: ServerConfig = toml::from_str(toml).unwrap();
|
|
let webhooks = config.git.webhooks.unwrap();
|
|
assert_eq!(webhooks.strategy, WebhookStrategy::TailscaleFunnel);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_git_webhooks_missing_is_none() {
|
|
let toml = r#"
|
|
[git]
|
|
provider = "github"
|
|
app_id = "123"
|
|
"#;
|
|
let config: ServerConfig = toml::from_str(toml).unwrap();
|
|
assert!(config.git.webhooks.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn parse_log_config() {
|
|
let toml = "[log]\nlevel = \"trace\"";
|
|
let config: ServerConfig = toml::from_str(toml).unwrap();
|
|
assert_eq!(config.log.level.as_deref(), Some("trace"));
|
|
}
|
|
}
|