Skip to main content

mlua_swarm_server/
config.rs

1//! Server config file support (`~/.mse/config.toml` by default).
2//!
3//! Resolution precedence: **CLI flag > config file > built-in default**.
4//! CLI flags are represented as `Option<T>` on the `main.rs` `Args` struct
5//! (rather than relying on `clap`'s `default_value`) so "not passed" can be
6//! distinguished from "matches the default value"; [`resolve`] performs the
7//! actual 3-way merge.
8//!
9//! Design rationale: the config file becomes the lifecycle SoT; the launchd
10//! plist's `ProgramArguments` stays fixed at `<server-bin> --config <path>`,
11//! so changing settings = editing the file + restarting, not editing the plist.
12
13use serde::Deserialize;
14use std::net::SocketAddr;
15use std::path::{Path, PathBuf};
16
17/// Default config path, `~/.mse/config.toml`. Falls back to a relative path
18/// literal when `$HOME` is unset (best-effort; dev-only edge case).
19pub fn default_config_path() -> PathBuf {
20    match std::env::var("HOME") {
21        Ok(home) => PathBuf::from(home).join(".mse").join("config.toml"),
22        Err(_) => PathBuf::from(".mse/config.toml"),
23    }
24}
25
26/// Default `BlueprintStore` root, `~/.mse/store`. Same `$HOME` fallback
27/// rule as [`default_config_path`]. The store is always git-backed;
28/// config/CLI only override *where* the repos live, never whether they
29/// persist.
30pub fn default_store_path() -> PathBuf {
31    match std::env::var("HOME") {
32        Ok(home) => PathBuf::from(home).join(".mse").join("store"),
33        Err(_) => PathBuf::from(".mse/store"),
34    }
35}
36
37/// Default `TaskStore` SQLite path, `~/.mse/store/task.sqlite` (issue
38/// #35 ST1 — persist-by-default). Same `$HOME` fallback as
39/// [`default_config_path`].
40pub fn default_task_store_path() -> PathBuf {
41    match std::env::var("HOME") {
42        Ok(home) => PathBuf::from(home)
43            .join(".mse")
44            .join("store")
45            .join("task.sqlite"),
46        Err(_) => PathBuf::from(".mse/store/task.sqlite"),
47    }
48}
49
50/// Default `RunStore` SQLite path, `~/.mse/store/run.sqlite`. Sibling of
51/// [`default_task_store_path`].
52pub fn default_run_store_path() -> PathBuf {
53    match std::env::var("HOME") {
54        Ok(home) => PathBuf::from(home)
55            .join(".mse")
56            .join("store")
57            .join("run.sqlite"),
58        Err(_) => PathBuf::from(".mse/store/run.sqlite"),
59    }
60}
61
62/// TOML config schema. All fields are optional — a missing field falls back
63/// to the CLI-supplied value or the built-in default at [`resolve`] time.
64/// Unknown fields are a hard error (`deny_unknown_fields`; typo guard).
65#[derive(Debug, Default, Clone, PartialEq, Deserialize)]
66#[serde(deny_unknown_fields)]
67pub struct FileConfig {
68    /// Listen address string (e.g. `"127.0.0.1:7777"`), parsed at [`resolve`] time.
69    pub bind: Option<String>,
70    /// Whether the enhance flow (Lua + AgentBlock factories) is baked into the registry.
71    pub enable_enhance_flow: Option<bool>,
72    /// Base dir for `$file` / `$agent_md` ref expansion in seeded Blueprints.
73    pub blueprint_ref_base: Option<PathBuf>,
74    /// Root path for the git-backed `BlueprintStore` (when using the git2 backend).
75    pub git_store_path: Option<PathBuf>,
76    /// Path to the SQLite database file backing the `IssueStore`. `None` = fall
77    /// back to `InMemoryIssueStore` (process-volatile).
78    pub issue_store_path: Option<PathBuf>,
79    /// Path to the SQLite database file backing the `EnhanceSettingStore`.
80    /// `None` = fall back to `InMemoryEnhanceSettingStore` (process-volatile).
81    pub enhance_setting_store_path: Option<PathBuf>,
82    /// Path to the SQLite database file backing the `EnhanceLogStore`.
83    /// `None` = fall back to `InMemoryEnhanceLogStore` (process-volatile).
84    pub enhance_log_store_path: Option<PathBuf>,
85    /// Path to the SQLite database file backing the `OutputStore`.
86    /// `None` = fall back to `InMemoryOutputStore` (process-volatile).
87    pub output_store_path: Option<PathBuf>,
88    /// Path to the SQLite database file backing the `TaskStore` (issue #13
89    /// ID-hierarchy `POST /v1/tasks` work-item records). `None` = fall back
90    /// to `InMemoryTaskStore` (process-volatile).
91    pub task_store_path: Option<PathBuf>,
92    /// Path to the SQLite database file backing the `RunStore` (one kick of
93    /// a Task). `None` = fall back to `InMemoryRunStore` (process-volatile).
94    pub run_store_path: Option<PathBuf>,
95    /// Opt-out flag: when `true`, restores the InMemory default for
96    /// `task_store_path`/`run_store_path` even though the built-in default
97    /// (issue #35 ST1) is now to persist. Has no effect when an explicit
98    /// `task_store_path`/`run_store_path` (CLI or file) is set — explicit
99    /// paths always win. `None` = fall back to `false`.
100    pub ephemeral: Option<bool>,
101    /// Seed blueprint id used in combined-mode default routing.
102    pub seed_blueprint_id: Option<String>,
103    /// snake_case `AgentKind` literal (`operator` / `agent_block` / `rust_fn` /
104    /// `lua` / `subprocess`). Validated by the caller after [`resolve`].
105    pub default_agent_kind: Option<String>,
106    /// Shared secret used to verify/sign `CapToken` HMAC signatures.
107    pub token_secret: Option<String>,
108    /// Ceiling (seconds) for the `POST /v1/tasks` synchronous launch await
109    /// (GH #33 Guard 2). Overridable per-request via `TaskLaunchRequest
110    /// .timeout_secs`; this is the server-wide fallback when the request
111    /// omits it. `None` = fall back to the built-in default (3600s / 60 min, see
112    /// [`ResolvedConfig`]'s `Default` impl).
113    pub sync_timeout_secs: Option<u64>,
114}
115
116/// CLI-side overrides. Mirrors [`FileConfig`] field-for-field. Kept as a
117/// separate type (rather than reusing `clap::Args` directly) so this module
118/// stays independent of the `clap` derive on `main.rs::Args`.
119#[derive(Debug, Default, Clone)]
120pub struct CliOverrides {
121    /// `--bind` value, unparsed (mirrors [`FileConfig::bind`]).
122    pub bind: Option<String>,
123    /// `--enable-enhance-flow` flag.
124    pub enable_enhance_flow: Option<bool>,
125    /// `--blueprint-ref-base` value.
126    pub blueprint_ref_base: Option<PathBuf>,
127    /// `--git-store-path` value.
128    pub git_store_path: Option<PathBuf>,
129    /// `--issue-store-path` value (mirrors [`FileConfig::issue_store_path`]).
130    pub issue_store_path: Option<PathBuf>,
131    /// `--enhance-setting-store-path` value.
132    pub enhance_setting_store_path: Option<PathBuf>,
133    /// `--enhance-log-store-path` value.
134    pub enhance_log_store_path: Option<PathBuf>,
135    /// `--output-store-path` value.
136    pub output_store_path: Option<PathBuf>,
137    /// `--task-store-path` value (mirrors [`FileConfig::task_store_path`]).
138    pub task_store_path: Option<PathBuf>,
139    /// `--run-store-path` value (mirrors [`FileConfig::run_store_path`]).
140    pub run_store_path: Option<PathBuf>,
141    /// `--ephemeral` flag (mirrors [`FileConfig::ephemeral`]).
142    pub ephemeral: Option<bool>,
143    /// `--seed-blueprint-id` value.
144    pub seed_blueprint_id: Option<String>,
145    /// `--default-agent-kind` value (snake_case `AgentKind` literal, unvalidated).
146    pub default_agent_kind: Option<String>,
147    /// `--token-secret` value.
148    pub token_secret: Option<String>,
149    /// `--sync-timeout-secs` value (mirrors [`FileConfig::sync_timeout_secs`]).
150    pub sync_timeout_secs: Option<u64>,
151}
152
153/// Fully resolved config — every field has the built-in default applied.
154#[derive(Debug, Clone, PartialEq)]
155pub struct ResolvedConfig {
156    /// Parsed listen address for the server to bind to.
157    pub bind: SocketAddr,
158    /// Whether the enhance flow (Lua + AgentBlock factories) is baked into the registry.
159    pub enable_enhance_flow: bool,
160    /// Base dir for `$file` / `$agent_md` ref expansion in seeded Blueprints.
161    pub blueprint_ref_base: Option<PathBuf>,
162    /// Root path for the git-backed `BlueprintStore`. Always set — defaults
163    /// to [`default_store_path`] (`~/.mse/store`) when neither CLI nor config
164    /// file provides one.
165    pub git_store_path: PathBuf,
166    /// Path to the SQLite database file backing the `IssueStore`. `None` = fall
167    /// back to `InMemoryIssueStore` (process-volatile).
168    pub issue_store_path: Option<PathBuf>,
169    /// Path to the SQLite database file backing the `EnhanceSettingStore`.
170    /// `None` = `InMemoryEnhanceSettingStore`.
171    pub enhance_setting_store_path: Option<PathBuf>,
172    /// Path to the SQLite database file backing the `EnhanceLogStore`.
173    /// `None` = `InMemoryEnhanceLogStore`.
174    pub enhance_log_store_path: Option<PathBuf>,
175    /// Path to the SQLite database file backing the `OutputStore`.
176    /// `None` = `InMemoryOutputStore`.
177    pub output_store_path: Option<PathBuf>,
178    /// Path to the SQLite database file backing the `TaskStore`.
179    /// `None` = `InMemoryTaskStore`.
180    pub task_store_path: Option<PathBuf>,
181    /// Path to the SQLite database file backing the `RunStore`.
182    /// `None` = `InMemoryRunStore`.
183    pub run_store_path: Option<PathBuf>,
184    /// Seed blueprint id used in combined-mode default routing.
185    pub seed_blueprint_id: String,
186    /// snake_case `AgentKind` literal, unvalidated. `None` = caller applies
187    /// the schema-impl `Default` (`Operator`).
188    pub default_agent_kind: Option<String>,
189    /// Shared secret used to verify/sign `CapToken` HMAC signatures.
190    pub token_secret: Option<String>,
191    /// Ceiling (seconds) for the `POST /v1/tasks` synchronous launch await
192    /// (GH #33 Guard 2). Always set — defaults to 3600s / 60 min (see
193    /// [`default_sync_timeout_secs`]) when neither CLI nor config file
194    /// provides one. A per-request `TaskLaunchRequest.timeout_secs`
195    /// override, when present, takes priority over this server-wide value.
196    pub sync_timeout_secs: u64,
197}
198
199impl Default for ResolvedConfig {
200    fn default() -> Self {
201        Self {
202            bind: default_bind(),
203            enable_enhance_flow: false,
204            blueprint_ref_base: None,
205            git_store_path: default_store_path(),
206            issue_store_path: None,
207            enhance_setting_store_path: None,
208            enhance_log_store_path: None,
209            output_store_path: None,
210            task_store_path: None,
211            run_store_path: None,
212            seed_blueprint_id: "main".into(),
213            default_agent_kind: None,
214            token_secret: None,
215            sync_timeout_secs: default_sync_timeout_secs(),
216        }
217    }
218}
219
220/// Built-in default sync-launch timeout ceiling (GH #33 Guard 2), seconds.
221/// 3600s / 60 min — sized for LLM-driven agent flows where individual
222/// spawns routinely take 60-180s and full phases run 20-40 min. The
223/// previous 300s ceiling under-shot the primary workload; users hitting
224/// it were legitimate long-running runs, not stuck ones. Callers who
225/// want faster fail-loud can override per-request
226/// (`TaskLaunchRequest.timeout_secs`) or server-wide (config or CLI).
227/// GH #39.
228pub fn default_sync_timeout_secs() -> u64 {
229    3600
230}
231
232fn default_bind() -> SocketAddr {
233    "127.0.0.1:7777"
234        .parse()
235        .expect("literal default bind must parse")
236}
237
238/// Load + parse a TOML config file. A missing file resolves to
239/// `Ok(FileConfig::default())` (built-in default fallback, per module doc);
240/// any other IO error or a parse error is `Err` — a malformed config file
241/// must not be silently ignored (fail-loud).
242pub fn load_file_config(path: &Path) -> Result<FileConfig, String> {
243    match std::fs::read_to_string(path) {
244        Ok(text) => toml::from_str(&text)
245            .map_err(|e| format!("config file {} parse error: {e}", path.display())),
246        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(FileConfig::default()),
247        Err(e) => Err(format!("config file {} read error: {e}", path.display())),
248    }
249}
250
251/// 3-way merge: CLI > file > built-in default. `bind` requires a parse step
252/// (both CLI and file carry it as a string); a parse error surfaces as `Err`.
253pub fn resolve(cli: CliOverrides, file: FileConfig) -> Result<ResolvedConfig, String> {
254    let default = ResolvedConfig::default();
255
256    let bind = match cli.bind.or(file.bind) {
257        Some(s) => s
258            .parse::<SocketAddr>()
259            .map_err(|e| format!("bind {s:?}: {e}"))?,
260        None => default.bind,
261    };
262
263    let ephemeral = cli.ephemeral.or(file.ephemeral).unwrap_or(false);
264
265    Ok(ResolvedConfig {
266        bind,
267        enable_enhance_flow: cli
268            .enable_enhance_flow
269            .or(file.enable_enhance_flow)
270            .unwrap_or(default.enable_enhance_flow),
271        blueprint_ref_base: cli.blueprint_ref_base.or(file.blueprint_ref_base),
272        git_store_path: cli
273            .git_store_path
274            .or(file.git_store_path)
275            .unwrap_or_else(default_store_path),
276        issue_store_path: cli.issue_store_path.or(file.issue_store_path),
277        enhance_setting_store_path: cli
278            .enhance_setting_store_path
279            .or(file.enhance_setting_store_path),
280        enhance_log_store_path: cli.enhance_log_store_path.or(file.enhance_log_store_path),
281        output_store_path: cli.output_store_path.or(file.output_store_path),
282        task_store_path: cli.task_store_path.or(file.task_store_path).or_else(|| {
283            if ephemeral {
284                None
285            } else {
286                Some(default_task_store_path())
287            }
288        }),
289        run_store_path: cli.run_store_path.or(file.run_store_path).or_else(|| {
290            if ephemeral {
291                None
292            } else {
293                Some(default_run_store_path())
294            }
295        }),
296        seed_blueprint_id: cli
297            .seed_blueprint_id
298            .or(file.seed_blueprint_id)
299            .unwrap_or(default.seed_blueprint_id),
300        default_agent_kind: cli.default_agent_kind.or(file.default_agent_kind),
301        token_secret: cli.token_secret.or(file.token_secret),
302        sync_timeout_secs: cli
303            .sync_timeout_secs
304            .or(file.sync_timeout_secs)
305            .unwrap_or_else(default_sync_timeout_secs),
306    })
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    #[test]
314    fn resolve_cli_flag_wins_over_file_and_default() {
315        let cli = CliOverrides {
316            bind: Some("127.0.0.1:9999".into()),
317            ..Default::default()
318        };
319        let file = FileConfig {
320            bind: Some("127.0.0.1:8888".into()),
321            ..Default::default()
322        };
323        let resolved = resolve(cli, file).expect("resolve");
324        assert_eq!(
325            resolved.bind,
326            "127.0.0.1:9999".parse::<SocketAddr>().unwrap()
327        );
328    }
329
330    #[test]
331    fn resolve_file_wins_over_built_in_default_when_cli_absent() {
332        let cli = CliOverrides::default();
333        let file = FileConfig {
334            seed_blueprint_id: Some("from-file".into()),
335            enable_enhance_flow: Some(true),
336            ..Default::default()
337        };
338        let resolved = resolve(cli, file).expect("resolve");
339        assert_eq!(resolved.seed_blueprint_id, "from-file");
340        assert!(resolved.enable_enhance_flow);
341    }
342
343    #[test]
344    fn resolve_built_in_default_when_cli_and_file_absent() {
345        let resolved = resolve(CliOverrides::default(), FileConfig::default()).expect("resolve");
346        assert_eq!(resolved.bind, default_bind());
347        assert_eq!(resolved.seed_blueprint_id, "main");
348        assert!(!resolved.enable_enhance_flow);
349        assert_eq!(resolved.git_store_path, default_store_path());
350    }
351
352    #[test]
353    fn resolve_git_store_path_file_overrides_default_location() {
354        let file = FileConfig {
355            git_store_path: Some(PathBuf::from("/tmp/custom-store")),
356            ..Default::default()
357        };
358        let resolved = resolve(CliOverrides::default(), file).expect("resolve");
359        assert_eq!(resolved.git_store_path, PathBuf::from("/tmp/custom-store"));
360    }
361
362    #[test]
363    fn resolve_bind_parse_error_is_propagated() {
364        let cli = CliOverrides {
365            bind: Some("not-a-valid-addr".into()),
366            ..Default::default()
367        };
368        let err = resolve(cli, FileConfig::default()).unwrap_err();
369        assert!(err.contains("not-a-valid-addr"), "unexpected error: {err}");
370    }
371
372    #[test]
373    fn load_file_config_rejects_unknown_fields() {
374        let toml_text = "bind = \"127.0.0.1:1234\"\ntypo_field = true\n";
375        let err = toml::from_str::<FileConfig>(toml_text).unwrap_err();
376        let msg = err.to_string();
377        assert!(
378            msg.contains("typo_field") || msg.contains("unknown field"),
379            "unexpected error message: {msg}"
380        );
381    }
382
383    #[test]
384    fn load_file_config_missing_file_falls_back_to_default() {
385        let path = std::path::Path::new("/nonexistent/mse-config-test-path/config.toml");
386        let cfg = load_file_config(path).expect("missing file should not error");
387        assert_eq!(cfg, FileConfig::default());
388    }
389
390    #[test]
391    fn load_file_config_parses_valid_toml() {
392        let dir = std::env::temp_dir().join(format!("server-config-test-{}", std::process::id()));
393        std::fs::create_dir_all(&dir).expect("create tmp dir");
394        let path = dir.join("config.toml");
395        std::fs::write(
396            &path,
397            "bind = \"127.0.0.1:7000\"\nenable_enhance_flow = true\nseed_blueprint_id = \"main\"\n",
398        )
399        .expect("write tmp config");
400        let cfg = load_file_config(&path).expect("parse tmp config");
401        assert_eq!(cfg.bind.as_deref(), Some("127.0.0.1:7000"));
402        assert_eq!(cfg.enable_enhance_flow, Some(true));
403        let _ = std::fs::remove_dir_all(&dir);
404    }
405
406    #[test]
407    fn resolve_task_and_run_store_path_cli_wins_over_file() {
408        let cli = CliOverrides {
409            task_store_path: Some(PathBuf::from("/tmp/cli-tasks.db")),
410            ..Default::default()
411        };
412        let file = FileConfig {
413            task_store_path: Some(PathBuf::from("/tmp/file-tasks.db")),
414            run_store_path: Some(PathBuf::from("/tmp/file-runs.db")),
415            ..Default::default()
416        };
417        let resolved = resolve(cli, file).expect("resolve");
418        assert_eq!(
419            resolved.task_store_path,
420            Some(PathBuf::from("/tmp/cli-tasks.db")),
421            "cli task_store_path must win over file"
422        );
423        assert_eq!(
424            resolved.run_store_path,
425            Some(PathBuf::from("/tmp/file-runs.db")),
426            "run_store_path falls back to file when cli is absent"
427        );
428    }
429
430    #[test]
431    fn resolve_task_and_run_store_path_default_none() {
432        let resolved = resolve(CliOverrides::default(), FileConfig::default()).expect("resolve");
433        assert_eq!(
434            resolved.task_store_path,
435            Some(default_task_store_path()),
436            "issue #35 ST1: task_store_path now persists by default"
437        );
438        assert_eq!(
439            resolved.run_store_path,
440            Some(default_run_store_path()),
441            "issue #35 ST1: run_store_path now persists by default"
442        );
443    }
444
445    #[test]
446    fn resolve_ephemeral_true_restores_in_memory_default() {
447        let cli = CliOverrides {
448            ephemeral: Some(true),
449            ..Default::default()
450        };
451        let resolved = resolve(cli, FileConfig::default()).expect("resolve");
452        assert_eq!(resolved.task_store_path, None);
453        assert_eq!(resolved.run_store_path, None);
454    }
455
456    #[test]
457    fn resolve_explicit_path_wins_over_ephemeral() {
458        let cli = CliOverrides {
459            task_store_path: Some(PathBuf::from("/tmp/explicit-tasks.db")),
460            ephemeral: Some(true),
461            ..Default::default()
462        };
463        let resolved = resolve(cli, FileConfig::default()).expect("resolve");
464        assert_eq!(
465            resolved.task_store_path,
466            Some(PathBuf::from("/tmp/explicit-tasks.db")),
467            "explicit path must win over ephemeral"
468        );
469    }
470
471    #[test]
472    fn resolve_ephemeral_from_file_config() {
473        let file = FileConfig {
474            ephemeral: Some(true),
475            ..Default::default()
476        };
477        let resolved = resolve(CliOverrides::default(), file).expect("resolve");
478        assert_eq!(resolved.task_store_path, None);
479        assert_eq!(resolved.run_store_path, None);
480    }
481
482    // ──────────────────────────────────────────────────────────────────
483    // GH #33 Guard 2: `sync_timeout_secs` resolution cascade
484    // ──────────────────────────────────────────────────────────────────
485
486    #[test]
487    fn resolve_sync_timeout_secs_default_when_cli_and_file_absent() {
488        let resolved = resolve(CliOverrides::default(), FileConfig::default()).expect("resolve");
489        assert_eq!(resolved.sync_timeout_secs, 3600);
490        assert_eq!(resolved.sync_timeout_secs, default_sync_timeout_secs());
491    }
492
493    #[test]
494    fn resolve_sync_timeout_secs_file_wins_over_default() {
495        let file = FileConfig {
496            sync_timeout_secs: Some(120),
497            ..Default::default()
498        };
499        let resolved = resolve(CliOverrides::default(), file).expect("resolve");
500        assert_eq!(resolved.sync_timeout_secs, 120);
501    }
502
503    #[test]
504    fn resolve_sync_timeout_secs_cli_wins_over_file() {
505        let cli = CliOverrides {
506            sync_timeout_secs: Some(45),
507            ..Default::default()
508        };
509        let file = FileConfig {
510            sync_timeout_secs: Some(120),
511            ..Default::default()
512        };
513        let resolved = resolve(cli, file).expect("resolve");
514        assert_eq!(
515            resolved.sync_timeout_secs, 45,
516            "cli sync_timeout_secs must win over file"
517        );
518    }
519}