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 mlua_swarm::core::config::CheckPolicy;
14use mlua_swarm::LegacyWorkerBindingPolicy;
15use serde::Deserialize;
16use std::net::SocketAddr;
17use std::path::{Path, PathBuf};
18
19/// Default config path, `~/.mse/config.toml`. Falls back to a relative path
20/// literal when `$HOME` is unset (best-effort; dev-only edge case).
21pub fn default_config_path() -> PathBuf {
22    match std::env::var("HOME") {
23        Ok(home) => PathBuf::from(home).join(".mse").join("config.toml"),
24        Err(_) => PathBuf::from(".mse/config.toml"),
25    }
26}
27
28/// Default `BlueprintStore` root, `~/.mse/store`. Same `$HOME` fallback
29/// rule as [`default_config_path`]. The store is always git-backed;
30/// config/CLI only override *where* the repos live, never whether they
31/// persist.
32pub fn default_store_path() -> PathBuf {
33    match std::env::var("HOME") {
34        Ok(home) => PathBuf::from(home).join(".mse").join("store"),
35        Err(_) => PathBuf::from(".mse/store"),
36    }
37}
38
39/// Default `TaskStore` SQLite path, `~/.mse/store/task.sqlite` (issue
40/// #35 ST1 — persist-by-default). Same `$HOME` fallback as
41/// [`default_config_path`].
42pub fn default_task_store_path() -> PathBuf {
43    match std::env::var("HOME") {
44        Ok(home) => PathBuf::from(home)
45            .join(".mse")
46            .join("store")
47            .join("task.sqlite"),
48        Err(_) => PathBuf::from(".mse/store/task.sqlite"),
49    }
50}
51
52/// Default `RunStore` SQLite path, `~/.mse/store/run.sqlite`. Sibling of
53/// [`default_task_store_path`].
54pub fn default_run_store_path() -> PathBuf {
55    match std::env::var("HOME") {
56        Ok(home) => PathBuf::from(home)
57            .join(".mse")
58            .join("store")
59            .join("run.sqlite"),
60        Err(_) => PathBuf::from(".mse/store/run.sqlite"),
61    }
62}
63
64/// Default `ReplayStore` SQLite path, `~/.mse/store/replay.sqlite`. Sibling
65/// of [`default_run_store_path`] — persisted by default so a restart can
66/// consult the replay log (see `mlua_swarm::store::replay` module doc).
67pub fn default_replay_store_path() -> PathBuf {
68    match std::env::var("HOME") {
69        Ok(home) => PathBuf::from(home)
70            .join(".mse")
71            .join("store")
72            .join("replay.sqlite"),
73        Err(_) => PathBuf::from(".mse/store/replay.sqlite"),
74    }
75}
76
77/// TOML config schema. All fields are optional — a missing field falls back
78/// to the CLI-supplied value or the built-in default at [`resolve`] time.
79/// Unknown fields are a hard error (`deny_unknown_fields`; typo guard).
80#[derive(Debug, Default, Clone, PartialEq, Deserialize)]
81#[serde(deny_unknown_fields)]
82pub struct FileConfig {
83    /// Listen address string (e.g. `"127.0.0.1:7777"`), parsed at [`resolve`] time.
84    pub bind: Option<String>,
85    /// Whether the enhance flow (Lua + AgentBlock factories) is baked into the registry.
86    pub enable_enhance_flow: Option<bool>,
87    /// Migration gate for deprecated `profile.worker_binding` Runner fallback.
88    pub legacy_worker_binding_policy: Option<LegacyWorkerBindingPolicy>,
89    /// Base dir for `$file` / `$agent_md` ref expansion in seeded Blueprints.
90    pub blueprint_ref_base: Option<PathBuf>,
91    /// Additional dirs (tier 5 of the include cascade — see
92    /// `mlua-swarm-compile::ResolveConfig`) searched after the CLI
93    /// `--include` list and before the bundled default. `None` = no
94    /// server-config includes.
95    pub blueprint_ref_includes: Option<Vec<PathBuf>>,
96    /// Server-side strict-embed switch (design table row 3 — the
97    /// strict opt-in for the register layer). When `true`, `POST
98    /// /v1/blueprints/:id` refuses any raw body that still carries
99    /// `$file` / `$agent_md` refs (returns 400 with a hint pointing at
100    /// `mse bp build --strict-embed`), so ref resolution is pushed onto
101    /// the client. Default `false` = the server runs the linker itself
102    /// (backward-compat). `None` = fall back to the built-in default
103    /// `false`.
104    pub blueprint_strict_embed: Option<bool>,
105    /// Opt-in: inject the server's public endpoint (base URL) into
106    /// worker-facing data — the WS Spawn directive's `base_url` line and
107    /// the `StepPointer.content_url` absolute-URL prefix. Default
108    /// `false` = the endpoint is never handed to workers (directive
109    /// renders its historical placeholder, `content_url` stays a
110    /// relative path); workers reach the server through their own
111    /// configured bind (e.g. the mse-mcp tools' `bind` parameter).
112    /// `None` = fall back to the built-in default `false`.
113    pub inject_endpoint_for_worker: Option<bool>,
114    /// Observation threshold (milliseconds) for `LongHoldMiddleware`.
115    /// When `Some(ms)`, every dispatched step whose completion time
116    /// exceeds `ms` fires `Event::TaskAttemptCompleted { long_hold_warn:
117    /// true, .. }` on the broadcast event bus AND appends
118    /// `mw.long_hold_warn` to the persistent `RunTraceStore`
119    /// (best-effort, purely observational — never alters the step
120    /// signal or blocks completion). `None` (the default) leaves the
121    /// layer uninstalled, byte-for-byte compat with pre-config
122    /// behaviour.
123    pub long_hold_warn_ms: Option<u64>,
124    /// Root path for the git-backed `BlueprintStore` (when using the git2 backend).
125    pub git_store_path: Option<PathBuf>,
126    /// Path to the SQLite database file backing the `IssueStore`. `None` = fall
127    /// back to `InMemoryIssueStore` (process-volatile).
128    pub issue_store_path: Option<PathBuf>,
129    /// Path to the SQLite database file backing the `EnhanceSettingStore`.
130    /// `None` = fall back to `InMemoryEnhanceSettingStore` (process-volatile).
131    pub enhance_setting_store_path: Option<PathBuf>,
132    /// Path to the SQLite database file backing the `EnhanceLogStore`.
133    /// `None` = fall back to `InMemoryEnhanceLogStore` (process-volatile).
134    pub enhance_log_store_path: Option<PathBuf>,
135    /// Path to the SQLite database file backing the `OutputStore`.
136    /// `None` = fall back to `InMemoryOutputStore` (process-volatile).
137    pub output_store_path: Option<PathBuf>,
138    /// Path to the SQLite database file backing the `TaskStore` (issue #13
139    /// ID-hierarchy `POST /v1/tasks` work-item records). `None` = fall back
140    /// to `InMemoryTaskStore` (process-volatile).
141    pub task_store_path: Option<PathBuf>,
142    /// Path to the SQLite database file backing the `RunStore` (one kick of
143    /// a Task). `None` = fall back to `InMemoryRunStore` (process-volatile).
144    pub run_store_path: Option<PathBuf>,
145    /// Path to the SQLite database file backing the `ReplayStore` (per-run
146    /// Ctx-snapshot + step-output log). Persisted by default even when
147    /// omitted (sibling of `run_store_path`): resolves to
148    /// `~/.mse/store/replay.sqlite` unless `ephemeral` is set. `None` = fall
149    /// back to `InMemoryReplayStore` (process-volatile).
150    pub replay_store_path: Option<PathBuf>,
151    /// Opt-out flag: when `true`, restores the InMemory default for
152    /// `task_store_path`/`run_store_path` even though the built-in default
153    /// (issue #35 ST1) is now to persist. Has no effect when an explicit
154    /// `task_store_path`/`run_store_path` (CLI or file) is set — explicit
155    /// paths always win. `None` = fall back to `false`.
156    pub ephemeral: Option<bool>,
157    /// Seed blueprint id used in combined-mode default routing.
158    pub seed_blueprint_id: Option<String>,
159    /// snake_case `AgentKind` literal (`operator` / `agent_block` / `rust_fn` /
160    /// `lua` / `subprocess`). Validated by the caller after [`resolve`].
161    pub default_agent_kind: Option<String>,
162    /// Shared secret used to verify/sign `CapToken` HMAC signatures.
163    pub token_secret: Option<String>,
164    /// Ceiling (seconds) for the `POST /v1/tasks` synchronous launch await
165    /// (GH #33 Guard 2). Overridable per-request via `TaskLaunchRequest
166    /// .timeout_secs`; this is the server-wide fallback when the request
167    /// omits it. `None` = fall back to the built-in default (3600s / 60 min, see
168    /// [`ResolvedConfig`]'s `Default` impl).
169    pub sync_timeout_secs: Option<u64>,
170    /// Idle threshold (seconds) for the periodic stale-run sweep: a Run
171    /// still `Running` whose `updated_at` is older than this is marked
172    /// `Interrupted` so it becomes resumable without a restart. `0`
173    /// disables the sweep entirely. `None` = fall back to
174    /// [`default_stale_run_sweep_secs`] applied to the resolved
175    /// `sync_timeout_secs`.
176    pub stale_run_sweep_secs: Option<u64>,
177    /// R4 lock-hold guard threshold (milliseconds) for
178    /// `mlua_swarm::EngineCfg::max_hold_ms` — how long a single
179    /// `Engine::with_state` closure may hold the state lock before the
180    /// engine reports a suspected long operation inside the lock. `None`
181    /// = leave the engine's built-in default (50ms) in place.
182    pub engine_max_hold_ms: Option<u64>,
183    /// Server-wide [`mlua_swarm::core::config::CheckPolicy`] — governs how
184    /// submit-time projection sinks
185    /// (`Engine::materialize_final_submission` /
186    /// `Engine::materialize_artifact_submission`) react to fail-open
187    /// conditions (missing `work_dir`/`project_root`, `OutputStore` write
188    /// error, adapter materialize error, state lookup error). `None`
189    /// falls back to the built-in default `Warn` (byte-identical to
190    /// pre-`CheckPolicy` behaviour); `"silent"` skips both the log and
191    /// error, `"strict"` returns
192    /// `EngineError::CheckPolicyStrict` so a caller who has opted in can
193    /// fail the step / launch fast. Per-task override
194    /// (`TaskSpec.check_policy`) wins over this server-wide value.
195    pub check_policy: Option<CheckPolicy>,
196}
197
198/// CLI-side overrides. Mirrors [`FileConfig`] field-for-field. Kept as a
199/// separate type (rather than reusing `clap::Args` directly) so this module
200/// stays independent of the `clap` derive on `main.rs::Args`.
201#[derive(Debug, Default, Clone)]
202pub struct CliOverrides {
203    /// `--bind` value, unparsed (mirrors [`FileConfig::bind`]).
204    pub bind: Option<String>,
205    /// `--enable-enhance-flow` flag.
206    pub enable_enhance_flow: Option<bool>,
207    /// `--legacy-worker-binding-policy` value.
208    pub legacy_worker_binding_policy: Option<LegacyWorkerBindingPolicy>,
209    /// `--blueprint-ref-base` value.
210    pub blueprint_ref_base: Option<PathBuf>,
211    /// `--include` values (repeatable). Merged with the file config's
212    /// `blueprint_ref_includes` (CLI wins on conflict — see [`resolve`]).
213    pub blueprint_ref_includes: Vec<PathBuf>,
214    /// `--blueprint-strict-embed` flag (mirrors
215    /// [`FileConfig::blueprint_strict_embed`]).
216    pub blueprint_strict_embed: Option<bool>,
217    /// `--inject-endpoint-for-worker` flag (mirrors
218    /// [`FileConfig::inject_endpoint_for_worker`]).
219    pub inject_endpoint_for_worker: Option<bool>,
220    /// `--long-hold-warn-ms` value (mirrors
221    /// [`FileConfig::long_hold_warn_ms`]).
222    pub long_hold_warn_ms: Option<u64>,
223    /// `--git-store-path` value.
224    pub git_store_path: Option<PathBuf>,
225    /// `--issue-store-path` value (mirrors [`FileConfig::issue_store_path`]).
226    pub issue_store_path: Option<PathBuf>,
227    /// `--enhance-setting-store-path` value.
228    pub enhance_setting_store_path: Option<PathBuf>,
229    /// `--enhance-log-store-path` value.
230    pub enhance_log_store_path: Option<PathBuf>,
231    /// `--output-store-path` value.
232    pub output_store_path: Option<PathBuf>,
233    /// `--task-store-path` value (mirrors [`FileConfig::task_store_path`]).
234    pub task_store_path: Option<PathBuf>,
235    /// `--run-store-path` value (mirrors [`FileConfig::run_store_path`]).
236    pub run_store_path: Option<PathBuf>,
237    /// `--replay-store-path` value (mirrors [`FileConfig::replay_store_path`]).
238    pub replay_store_path: Option<PathBuf>,
239    /// `--ephemeral` flag (mirrors [`FileConfig::ephemeral`]).
240    pub ephemeral: Option<bool>,
241    /// `--seed-blueprint-id` value.
242    pub seed_blueprint_id: Option<String>,
243    /// `--default-agent-kind` value (snake_case `AgentKind` literal, unvalidated).
244    pub default_agent_kind: Option<String>,
245    /// `--token-secret` value.
246    pub token_secret: Option<String>,
247    /// `--sync-timeout-secs` value (mirrors [`FileConfig::sync_timeout_secs`]).
248    pub sync_timeout_secs: Option<u64>,
249    /// `--stale-run-sweep-secs` value (mirrors
250    /// [`FileConfig::stale_run_sweep_secs`]).
251    pub stale_run_sweep_secs: Option<u64>,
252    /// `--engine-max-hold-ms` value (mirrors
253    /// [`FileConfig::engine_max_hold_ms`]).
254    pub engine_max_hold_ms: Option<u64>,
255    /// `--check-policy` value (mirrors [`FileConfig::check_policy`]).
256    /// Parsed at the caller (`serve.rs`) before landing here — invalid
257    /// tokens are rejected before this struct is ever constructed.
258    pub check_policy: Option<CheckPolicy>,
259}
260
261/// Fully resolved config — every field has the built-in default applied.
262#[derive(Debug, Clone, PartialEq)]
263pub struct ResolvedConfig {
264    /// Parsed listen address for the server to bind to.
265    pub bind: SocketAddr,
266    /// Whether the enhance flow (Lua + AgentBlock factories) is baked into the registry.
267    pub enable_enhance_flow: bool,
268    /// Migration gate for fresh Blueprint declarations.
269    pub legacy_worker_binding_policy: LegacyWorkerBindingPolicy,
270    /// Base dir for `$file` / `$agent_md` ref expansion in seeded Blueprints.
271    pub blueprint_ref_base: Option<PathBuf>,
272    /// Merged include list (CLI `--include` first, then file
273    /// `blueprint_ref_includes`) — tier 4+5 of the include cascade.
274    /// Always set (may be empty).
275    pub blueprint_ref_includes: Vec<PathBuf>,
276    /// Server-side strict-embed switch (design table row 3). Always
277    /// set — defaults to `false` when neither CLI nor config file
278    /// provides one (backward-compat: the server runs the linker
279    /// itself). When `true`, `POST /v1/blueprints/:id` refuses raw
280    /// bodies that still carry `$file` / `$agent_md` refs.
281    pub blueprint_strict_embed: bool,
282    /// Root path for the git-backed `BlueprintStore`. Always set — defaults
283    /// to [`default_store_path`] (`~/.mse/store`) when neither CLI nor config
284    /// file provides one.
285    pub git_store_path: PathBuf,
286    /// Path to the SQLite database file backing the `IssueStore`. `None` = fall
287    /// back to `InMemoryIssueStore` (process-volatile).
288    pub issue_store_path: Option<PathBuf>,
289    /// Path to the SQLite database file backing the `EnhanceSettingStore`.
290    /// `None` = `InMemoryEnhanceSettingStore`.
291    pub enhance_setting_store_path: Option<PathBuf>,
292    /// Path to the SQLite database file backing the `EnhanceLogStore`.
293    /// `None` = `InMemoryEnhanceLogStore`.
294    pub enhance_log_store_path: Option<PathBuf>,
295    /// Path to the SQLite database file backing the `OutputStore`.
296    /// `None` = `InMemoryOutputStore`.
297    pub output_store_path: Option<PathBuf>,
298    /// Path to the SQLite database file backing the `TaskStore`.
299    /// `None` = `InMemoryTaskStore`.
300    pub task_store_path: Option<PathBuf>,
301    /// Path to the SQLite database file backing the `RunStore`.
302    /// `None` = `InMemoryRunStore`.
303    pub run_store_path: Option<PathBuf>,
304    /// Path to the SQLite database file backing the `ReplayStore`.
305    /// `None` = `InMemoryReplayStore`.
306    pub replay_store_path: Option<PathBuf>,
307    /// Seed blueprint id used in combined-mode default routing.
308    pub seed_blueprint_id: String,
309    /// snake_case `AgentKind` literal, unvalidated. `None` = caller applies
310    /// the schema-impl `Default` (`Operator`).
311    pub default_agent_kind: Option<String>,
312    /// Shared secret used to verify/sign `CapToken` HMAC signatures.
313    pub token_secret: Option<String>,
314    /// Ceiling (seconds) for the `POST /v1/tasks` synchronous launch await
315    /// (GH #33 Guard 2). Always set — defaults to 3600s / 60 min (see
316    /// [`default_sync_timeout_secs`]) when neither CLI nor config file
317    /// provides one. A per-request `TaskLaunchRequest.timeout_secs`
318    /// override, when present, takes priority over this server-wide value.
319    pub sync_timeout_secs: u64,
320    /// Idle threshold (seconds) the periodic stale-run sweep reaps at.
321    /// Always set — defaults to [`default_stale_run_sweep_secs`] applied
322    /// to the resolved `sync_timeout_secs` (3900s under the built-in
323    /// timeout). `0` disables the sweep: no sweeper task is spawned.
324    pub stale_run_sweep_secs: u64,
325    /// Resolved `EngineCfg.max_hold_ms` override in milliseconds. `None`
326    /// = the engine's built-in default (50ms) stands. See
327    /// [`FileConfig::engine_max_hold_ms`].
328    pub engine_max_hold_ms: Option<u64>,
329    /// Opt-in endpoint injection into worker-facing data (WS Spawn
330    /// directive `base_url` line / `StepPointer.content_url` absolute
331    /// prefix). Always set — defaults to `false` (never injected) when
332    /// neither CLI nor config file provides one. See
333    /// [`FileConfig::inject_endpoint_for_worker`].
334    pub inject_endpoint_for_worker: bool,
335    /// Resolved `LongHoldMiddleware` threshold. `None` = the layer is
336    /// not installed. See [`FileConfig::long_hold_warn_ms`].
337    pub long_hold_warn_ms: Option<u64>,
338    /// Server-wide [`mlua_swarm::core::config::CheckPolicy`]. Always set
339    /// — defaults to `CheckPolicy::Warn` (byte-identical to the
340    /// pre-`CheckPolicy` fail-open behaviour) when neither CLI nor config
341    /// file provides one. Per-task `TaskSpec.check_policy` (set via
342    /// caller code — HTTP request per-launch override wiring is a
343    /// follow-up) takes priority over this server-wide value.
344    pub check_policy: CheckPolicy,
345}
346
347impl Default for ResolvedConfig {
348    fn default() -> Self {
349        Self {
350            bind: default_bind(),
351            enable_enhance_flow: false,
352            legacy_worker_binding_policy: LegacyWorkerBindingPolicy::Allow,
353            blueprint_ref_base: None,
354            blueprint_ref_includes: Vec::new(),
355            blueprint_strict_embed: false,
356            git_store_path: default_store_path(),
357            issue_store_path: None,
358            enhance_setting_store_path: None,
359            enhance_log_store_path: None,
360            output_store_path: None,
361            task_store_path: None,
362            run_store_path: None,
363            replay_store_path: None,
364            seed_blueprint_id: "main".into(),
365            default_agent_kind: None,
366            token_secret: None,
367            sync_timeout_secs: default_sync_timeout_secs(),
368            stale_run_sweep_secs: default_stale_run_sweep_secs(default_sync_timeout_secs()),
369            engine_max_hold_ms: None,
370            inject_endpoint_for_worker: false,
371            long_hold_warn_ms: None,
372            check_policy: CheckPolicy::default(),
373        }
374    }
375}
376
377/// Built-in default sync-launch timeout ceiling (GH #33 Guard 2), seconds.
378/// 3600s / 60 min — sized for LLM-driven agent flows where individual
379/// spawns routinely take 60-180s and full phases run 20-40 min. The
380/// previous 300s ceiling under-shot the primary workload; users hitting
381/// it were legitimate long-running runs, not stuck ones. Callers who
382/// want faster fail-loud can override per-request
383/// (`TaskLaunchRequest.timeout_secs`) or server-wide (config or CLI).
384/// GH #39.
385pub fn default_sync_timeout_secs() -> u64 {
386    3600
387}
388
389/// Built-in default idle threshold for the periodic stale-run sweep,
390/// seconds: `max(sync_timeout_secs, default_run_ttl()) + 300`.
391///
392/// The two terms are the structural ceilings on how long a *live* Run can
393/// legitimately go without touching its row: a synchronous launch is
394/// bounded by `sync_timeout_secs`, a detached one by the run TTL. Anything
395/// idle beyond the larger of the two (plus a 300s margin) has no driver
396/// left to advance it — the case a dropped driver future (a cancelled
397/// synchronous launch) leaves behind, which no other finalizer covers.
398/// Erring long is deliberate: a late reap costs a resume kick, an early
399/// one would interrupt a run that is still working.
400pub fn default_stale_run_sweep_secs(sync_timeout_secs: u64) -> u64 {
401    sync_timeout_secs.max(crate::default_run_ttl()) + 300
402}
403
404fn default_bind() -> SocketAddr {
405    "127.0.0.1:7777"
406        .parse()
407        .expect("literal default bind must parse")
408}
409
410/// Load + parse a TOML config file. A missing file resolves to
411/// `Ok(FileConfig::default())` (built-in default fallback, per module doc);
412/// any other IO error or a parse error is `Err` — a malformed config file
413/// must not be silently ignored (fail-loud).
414pub fn load_file_config(path: &Path) -> Result<FileConfig, String> {
415    match std::fs::read_to_string(path) {
416        Ok(text) => toml::from_str(&text)
417            .map_err(|e| format!("config file {} parse error: {e}", path.display())),
418        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(FileConfig::default()),
419        Err(e) => Err(format!("config file {} read error: {e}", path.display())),
420    }
421}
422
423/// 3-way merge: CLI > file > built-in default. `bind` requires a parse step
424/// (both CLI and file carry it as a string); a parse error surfaces as `Err`.
425pub fn resolve(cli: CliOverrides, file: FileConfig) -> Result<ResolvedConfig, String> {
426    let default = ResolvedConfig::default();
427
428    let bind = match cli.bind.or(file.bind) {
429        Some(s) => s
430            .parse::<SocketAddr>()
431            .map_err(|e| format!("bind {s:?}: {e}"))?,
432        None => default.bind,
433    };
434
435    let ephemeral = cli.ephemeral.or(file.ephemeral).unwrap_or(false);
436
437    // Resolved ahead of the struct literal because the stale-run sweep
438    // threshold's built-in default is derived from it.
439    let sync_timeout_secs = cli
440        .sync_timeout_secs
441        .or(file.sync_timeout_secs)
442        .unwrap_or_else(default_sync_timeout_secs);
443
444    Ok(ResolvedConfig {
445        bind,
446        enable_enhance_flow: cli
447            .enable_enhance_flow
448            .or(file.enable_enhance_flow)
449            .unwrap_or(default.enable_enhance_flow),
450        legacy_worker_binding_policy: cli
451            .legacy_worker_binding_policy
452            .or(file.legacy_worker_binding_policy)
453            .unwrap_or(default.legacy_worker_binding_policy),
454        blueprint_ref_base: cli.blueprint_ref_base.or(file.blueprint_ref_base),
455        blueprint_ref_includes: {
456            let mut merged = cli.blueprint_ref_includes;
457            merged.extend(file.blueprint_ref_includes.unwrap_or_default());
458            merged
459        },
460        blueprint_strict_embed: cli
461            .blueprint_strict_embed
462            .or(file.blueprint_strict_embed)
463            .unwrap_or(default.blueprint_strict_embed),
464        inject_endpoint_for_worker: cli
465            .inject_endpoint_for_worker
466            .or(file.inject_endpoint_for_worker)
467            .unwrap_or(default.inject_endpoint_for_worker),
468        long_hold_warn_ms: cli.long_hold_warn_ms.or(file.long_hold_warn_ms),
469        git_store_path: cli
470            .git_store_path
471            .or(file.git_store_path)
472            .unwrap_or_else(default_store_path),
473        issue_store_path: cli.issue_store_path.or(file.issue_store_path),
474        enhance_setting_store_path: cli
475            .enhance_setting_store_path
476            .or(file.enhance_setting_store_path),
477        enhance_log_store_path: cli.enhance_log_store_path.or(file.enhance_log_store_path),
478        output_store_path: cli.output_store_path.or(file.output_store_path),
479        task_store_path: cli.task_store_path.or(file.task_store_path).or_else(|| {
480            if ephemeral {
481                None
482            } else {
483                Some(default_task_store_path())
484            }
485        }),
486        run_store_path: cli.run_store_path.or(file.run_store_path).or_else(|| {
487            if ephemeral {
488                None
489            } else {
490                Some(default_run_store_path())
491            }
492        }),
493        replay_store_path: cli
494            .replay_store_path
495            .or(file.replay_store_path)
496            .or_else(|| {
497                if ephemeral {
498                    None
499                } else {
500                    Some(default_replay_store_path())
501                }
502            }),
503        seed_blueprint_id: cli
504            .seed_blueprint_id
505            .or(file.seed_blueprint_id)
506            .unwrap_or(default.seed_blueprint_id),
507        default_agent_kind: cli.default_agent_kind.or(file.default_agent_kind),
508        token_secret: cli.token_secret.or(file.token_secret),
509        sync_timeout_secs,
510        stale_run_sweep_secs: cli
511            .stale_run_sweep_secs
512            .or(file.stale_run_sweep_secs)
513            .unwrap_or_else(|| default_stale_run_sweep_secs(sync_timeout_secs)),
514        engine_max_hold_ms: cli.engine_max_hold_ms.or(file.engine_max_hold_ms),
515        check_policy: cli
516            .check_policy
517            .or(file.check_policy)
518            .unwrap_or(default.check_policy),
519    })
520}
521
522#[cfg(test)]
523mod tests {
524    use super::*;
525
526    #[test]
527    fn resolve_cli_flag_wins_over_file_and_default() {
528        let cli = CliOverrides {
529            bind: Some("127.0.0.1:9999".into()),
530            ..Default::default()
531        };
532        let file = FileConfig {
533            bind: Some("127.0.0.1:8888".into()),
534            ..Default::default()
535        };
536        let resolved = resolve(cli, file).expect("resolve");
537        assert_eq!(
538            resolved.bind,
539            "127.0.0.1:9999".parse::<SocketAddr>().unwrap()
540        );
541    }
542
543    #[test]
544    fn resolve_file_wins_over_built_in_default_when_cli_absent() {
545        let cli = CliOverrides::default();
546        let file = FileConfig {
547            seed_blueprint_id: Some("from-file".into()),
548            enable_enhance_flow: Some(true),
549            ..Default::default()
550        };
551        let resolved = resolve(cli, file).expect("resolve");
552        assert_eq!(resolved.seed_blueprint_id, "from-file");
553        assert!(resolved.enable_enhance_flow);
554    }
555
556    #[test]
557    fn resolve_legacy_worker_binding_policy_uses_cli_file_default_precedence() {
558        let resolved = resolve(CliOverrides::default(), FileConfig::default()).unwrap();
559        assert_eq!(
560            resolved.legacy_worker_binding_policy,
561            LegacyWorkerBindingPolicy::Allow
562        );
563
564        let file = FileConfig {
565            legacy_worker_binding_policy: Some(LegacyWorkerBindingPolicy::Reject),
566            ..Default::default()
567        };
568        let resolved = resolve(CliOverrides::default(), file.clone()).unwrap();
569        assert_eq!(
570            resolved.legacy_worker_binding_policy,
571            LegacyWorkerBindingPolicy::Reject
572        );
573
574        let cli = CliOverrides {
575            legacy_worker_binding_policy: Some(LegacyWorkerBindingPolicy::Allow),
576            ..Default::default()
577        };
578        let resolved = resolve(cli, file).unwrap();
579        assert_eq!(
580            resolved.legacy_worker_binding_policy,
581            LegacyWorkerBindingPolicy::Allow
582        );
583    }
584
585    #[test]
586    fn resolve_built_in_default_when_cli_and_file_absent() {
587        let resolved = resolve(CliOverrides::default(), FileConfig::default()).expect("resolve");
588        assert_eq!(resolved.bind, default_bind());
589        assert_eq!(resolved.seed_blueprint_id, "main");
590        assert!(!resolved.enable_enhance_flow);
591        assert_eq!(resolved.git_store_path, default_store_path());
592    }
593
594    #[test]
595    fn resolve_git_store_path_file_overrides_default_location() {
596        let file = FileConfig {
597            git_store_path: Some(PathBuf::from("/tmp/custom-store")),
598            ..Default::default()
599        };
600        let resolved = resolve(CliOverrides::default(), file).expect("resolve");
601        assert_eq!(resolved.git_store_path, PathBuf::from("/tmp/custom-store"));
602    }
603
604    #[test]
605    fn resolve_bind_parse_error_is_propagated() {
606        let cli = CliOverrides {
607            bind: Some("not-a-valid-addr".into()),
608            ..Default::default()
609        };
610        let err = resolve(cli, FileConfig::default()).unwrap_err();
611        assert!(err.contains("not-a-valid-addr"), "unexpected error: {err}");
612    }
613
614    #[test]
615    fn load_file_config_rejects_unknown_fields() {
616        let toml_text = "bind = \"127.0.0.1:1234\"\ntypo_field = true\n";
617        let err = toml::from_str::<FileConfig>(toml_text).unwrap_err();
618        let msg = err.to_string();
619        assert!(
620            msg.contains("typo_field") || msg.contains("unknown field"),
621            "unexpected error message: {msg}"
622        );
623    }
624
625    #[test]
626    fn load_file_config_missing_file_falls_back_to_default() {
627        let path = std::path::Path::new("/nonexistent/mse-config-test-path/config.toml");
628        let cfg = load_file_config(path).expect("missing file should not error");
629        assert_eq!(cfg, FileConfig::default());
630    }
631
632    #[test]
633    fn load_file_config_parses_valid_toml() {
634        let dir = std::env::temp_dir().join(format!("server-config-test-{}", std::process::id()));
635        std::fs::create_dir_all(&dir).expect("create tmp dir");
636        let path = dir.join("config.toml");
637        std::fs::write(
638            &path,
639            "bind = \"127.0.0.1:7000\"\nenable_enhance_flow = true\nseed_blueprint_id = \"main\"\n",
640        )
641        .expect("write tmp config");
642        let cfg = load_file_config(&path).expect("parse tmp config");
643        assert_eq!(cfg.bind.as_deref(), Some("127.0.0.1:7000"));
644        assert_eq!(cfg.enable_enhance_flow, Some(true));
645        let _ = std::fs::remove_dir_all(&dir);
646    }
647
648    #[test]
649    fn resolve_task_and_run_store_path_cli_wins_over_file() {
650        let cli = CliOverrides {
651            task_store_path: Some(PathBuf::from("/tmp/cli-tasks.db")),
652            ..Default::default()
653        };
654        let file = FileConfig {
655            task_store_path: Some(PathBuf::from("/tmp/file-tasks.db")),
656            run_store_path: Some(PathBuf::from("/tmp/file-runs.db")),
657            ..Default::default()
658        };
659        let resolved = resolve(cli, file).expect("resolve");
660        assert_eq!(
661            resolved.task_store_path,
662            Some(PathBuf::from("/tmp/cli-tasks.db")),
663            "cli task_store_path must win over file"
664        );
665        assert_eq!(
666            resolved.run_store_path,
667            Some(PathBuf::from("/tmp/file-runs.db")),
668            "run_store_path falls back to file when cli is absent"
669        );
670    }
671
672    #[test]
673    fn resolve_task_and_run_store_path_default_none() {
674        let resolved = resolve(CliOverrides::default(), FileConfig::default()).expect("resolve");
675        assert_eq!(
676            resolved.task_store_path,
677            Some(default_task_store_path()),
678            "issue #35 ST1: task_store_path now persists by default"
679        );
680        assert_eq!(
681            resolved.run_store_path,
682            Some(default_run_store_path()),
683            "issue #35 ST1: run_store_path now persists by default"
684        );
685    }
686
687    #[test]
688    fn resolve_ephemeral_true_restores_in_memory_default() {
689        let cli = CliOverrides {
690            ephemeral: Some(true),
691            ..Default::default()
692        };
693        let resolved = resolve(cli, FileConfig::default()).expect("resolve");
694        assert_eq!(resolved.task_store_path, None);
695        assert_eq!(resolved.run_store_path, None);
696    }
697
698    #[test]
699    fn resolve_explicit_path_wins_over_ephemeral() {
700        let cli = CliOverrides {
701            task_store_path: Some(PathBuf::from("/tmp/explicit-tasks.db")),
702            ephemeral: Some(true),
703            ..Default::default()
704        };
705        let resolved = resolve(cli, FileConfig::default()).expect("resolve");
706        assert_eq!(
707            resolved.task_store_path,
708            Some(PathBuf::from("/tmp/explicit-tasks.db")),
709            "explicit path must win over ephemeral"
710        );
711    }
712
713    #[test]
714    fn resolve_ephemeral_from_file_config() {
715        let file = FileConfig {
716            ephemeral: Some(true),
717            ..Default::default()
718        };
719        let resolved = resolve(CliOverrides::default(), file).expect("resolve");
720        assert_eq!(resolved.task_store_path, None);
721        assert_eq!(resolved.run_store_path, None);
722        assert_eq!(resolved.replay_store_path, None);
723    }
724
725    // ──────────────────────────────────────────────────────────────────
726    // `replay_store_path` resolution cascade (sibling of run_store_path)
727    // ──────────────────────────────────────────────────────────────────
728
729    #[test]
730    fn resolve_replay_store_path_cli_wins_over_file() {
731        let cli = CliOverrides {
732            replay_store_path: Some(PathBuf::from("/tmp/cli-replay.db")),
733            ..Default::default()
734        };
735        let file = FileConfig {
736            replay_store_path: Some(PathBuf::from("/tmp/file-replay.db")),
737            ..Default::default()
738        };
739        let resolved = resolve(cli, file).expect("resolve");
740        assert_eq!(
741            resolved.replay_store_path,
742            Some(PathBuf::from("/tmp/cli-replay.db")),
743            "cli replay_store_path must win over file"
744        );
745    }
746
747    #[test]
748    fn resolve_replay_store_path_file_wins_over_default() {
749        let file = FileConfig {
750            replay_store_path: Some(PathBuf::from("/tmp/file-replay.db")),
751            ..Default::default()
752        };
753        let resolved = resolve(CliOverrides::default(), file).expect("resolve");
754        assert_eq!(
755            resolved.replay_store_path,
756            Some(PathBuf::from("/tmp/file-replay.db")),
757            "file replay_store_path must win over built-in default"
758        );
759    }
760
761    #[test]
762    fn resolve_replay_store_path_default_persists() {
763        let resolved = resolve(CliOverrides::default(), FileConfig::default()).expect("resolve");
764        assert_eq!(
765            resolved.replay_store_path,
766            Some(default_replay_store_path()),
767            "replay_store_path persists by default (sibling of run_store_path)"
768        );
769    }
770
771    #[test]
772    fn resolve_replay_store_path_ephemeral_restores_in_memory() {
773        let cli = CliOverrides {
774            ephemeral: Some(true),
775            ..Default::default()
776        };
777        let resolved = resolve(cli, FileConfig::default()).expect("resolve");
778        assert_eq!(resolved.replay_store_path, None);
779    }
780
781    #[test]
782    fn resolve_replay_store_path_explicit_wins_over_ephemeral() {
783        let cli = CliOverrides {
784            replay_store_path: Some(PathBuf::from("/tmp/explicit-replay.db")),
785            ephemeral: Some(true),
786            ..Default::default()
787        };
788        let resolved = resolve(cli, FileConfig::default()).expect("resolve");
789        assert_eq!(
790            resolved.replay_store_path,
791            Some(PathBuf::from("/tmp/explicit-replay.db")),
792            "explicit replay path must win over ephemeral"
793        );
794    }
795
796    // ──────────────────────────────────────────────────────────────────
797    // GH #33 Guard 2: `sync_timeout_secs` resolution cascade
798    // ──────────────────────────────────────────────────────────────────
799
800    #[test]
801    fn resolve_sync_timeout_secs_default_when_cli_and_file_absent() {
802        let resolved = resolve(CliOverrides::default(), FileConfig::default()).expect("resolve");
803        assert_eq!(resolved.sync_timeout_secs, 3600);
804        assert_eq!(resolved.sync_timeout_secs, default_sync_timeout_secs());
805    }
806
807    #[test]
808    fn resolve_sync_timeout_secs_file_wins_over_default() {
809        let file = FileConfig {
810            sync_timeout_secs: Some(120),
811            ..Default::default()
812        };
813        let resolved = resolve(CliOverrides::default(), file).expect("resolve");
814        assert_eq!(resolved.sync_timeout_secs, 120);
815    }
816
817    #[test]
818    fn resolve_sync_timeout_secs_cli_wins_over_file() {
819        let cli = CliOverrides {
820            sync_timeout_secs: Some(45),
821            ..Default::default()
822        };
823        let file = FileConfig {
824            sync_timeout_secs: Some(120),
825            ..Default::default()
826        };
827        let resolved = resolve(cli, file).expect("resolve");
828        assert_eq!(
829            resolved.sync_timeout_secs, 45,
830            "cli sync_timeout_secs must win over file"
831        );
832    }
833
834    // ──────────────────────────────────────────────────────────────────
835    // `stale_run_sweep_secs` resolution cascade (periodic stale-run sweep)
836    // ──────────────────────────────────────────────────────────────────
837
838    #[test]
839    fn resolve_stale_run_sweep_secs_default_derives_from_sync_timeout() {
840        let resolved = resolve(CliOverrides::default(), FileConfig::default()).expect("resolve");
841        assert_eq!(
842            resolved.stale_run_sweep_secs,
843            default_stale_run_sweep_secs(default_sync_timeout_secs())
844        );
845        assert_eq!(
846            resolved.stale_run_sweep_secs, 3900,
847            "max(3600 sync timeout, 1800 run ttl) + 300 margin"
848        );
849    }
850
851    /// The default tracks a raised `sync_timeout_secs` (a longer sync
852    /// launch legitimately keeps a Run's row idle for longer), and stays
853    /// pinned to the run TTL when the timeout is lowered below it.
854    #[test]
855    fn resolve_stale_run_sweep_secs_default_tracks_the_resolved_sync_timeout() {
856        let file = FileConfig {
857            sync_timeout_secs: Some(7200),
858            ..Default::default()
859        };
860        let resolved = resolve(CliOverrides::default(), file).expect("resolve");
861        assert_eq!(resolved.stale_run_sweep_secs, 7500);
862
863        let cli = CliOverrides {
864            sync_timeout_secs: Some(60),
865            ..Default::default()
866        };
867        let resolved = resolve(cli, FileConfig::default()).expect("resolve");
868        assert_eq!(
869            resolved.stale_run_sweep_secs, 2100,
870            "a short sync timeout must not shrink the threshold below the run TTL + margin"
871        );
872    }
873
874    #[test]
875    fn resolve_stale_run_sweep_secs_cli_wins_over_file() {
876        let cli = CliOverrides {
877            stale_run_sweep_secs: Some(600),
878            ..Default::default()
879        };
880        let file = FileConfig {
881            stale_run_sweep_secs: Some(1200),
882            ..Default::default()
883        };
884        let resolved = resolve(cli, file).expect("resolve");
885        assert_eq!(resolved.stale_run_sweep_secs, 600);
886    }
887
888    #[test]
889    fn resolve_stale_run_sweep_secs_zero_is_kept_as_the_disable_switch() {
890        let file = FileConfig {
891            stale_run_sweep_secs: Some(0),
892            ..Default::default()
893        };
894        let resolved = resolve(CliOverrides::default(), file).expect("resolve");
895        assert_eq!(
896            resolved.stale_run_sweep_secs, 0,
897            "an explicit 0 disables the sweep and must not fall through to the default"
898        );
899    }
900
901    // ──────────────────────────────────────────────────────────────────
902    // `engine_max_hold_ms` resolution cascade (EngineCfg.max_hold_ms)
903    // ──────────────────────────────────────────────────────────────────
904
905    #[test]
906    fn resolve_engine_max_hold_ms_absent_leaves_the_engine_default() {
907        let resolved = resolve(CliOverrides::default(), FileConfig::default()).expect("resolve");
908        assert_eq!(
909            resolved.engine_max_hold_ms, None,
910            "None keeps the engine's built-in max_hold_ms"
911        );
912    }
913
914    #[test]
915    fn resolve_engine_max_hold_ms_file_wins_over_default() {
916        let file = FileConfig {
917            engine_max_hold_ms: Some(200),
918            ..Default::default()
919        };
920        let resolved = resolve(CliOverrides::default(), file).expect("resolve");
921        assert_eq!(resolved.engine_max_hold_ms, Some(200));
922    }
923
924    #[test]
925    fn resolve_engine_max_hold_ms_cli_wins_over_file() {
926        let cli = CliOverrides {
927            engine_max_hold_ms: Some(500),
928            ..Default::default()
929        };
930        let file = FileConfig {
931            engine_max_hold_ms: Some(200),
932            ..Default::default()
933        };
934        let resolved = resolve(cli, file).expect("resolve");
935        assert_eq!(resolved.engine_max_hold_ms, Some(500));
936    }
937
938    #[test]
939    fn file_config_deserializes_the_new_keys() {
940        let toml_text = "stale_run_sweep_secs = 900\nengine_max_hold_ms = 200\n";
941        let cfg: FileConfig = toml::from_str(toml_text).expect("parse");
942        assert_eq!(cfg.stale_run_sweep_secs, Some(900));
943        assert_eq!(cfg.engine_max_hold_ms, Some(200));
944    }
945
946    // ──────────────────────────────────────────────────────────────────
947    // ST1c-2a: `check_policy` resolution cascade
948    // ──────────────────────────────────────────────────────────────────
949
950    #[test]
951    fn resolve_check_policy_default_when_cli_and_file_absent() {
952        let resolved = resolve(CliOverrides::default(), FileConfig::default()).expect("resolve");
953        assert_eq!(
954            resolved.check_policy,
955            CheckPolicy::Warn,
956            "default check_policy must preserve pre-CheckPolicy fail-open (Warn)"
957        );
958        assert_eq!(resolved.check_policy, CheckPolicy::default());
959    }
960
961    #[test]
962    fn resolve_check_policy_file_wins_over_default() {
963        let file = FileConfig {
964            check_policy: Some(CheckPolicy::Strict),
965            ..Default::default()
966        };
967        let resolved = resolve(CliOverrides::default(), file).expect("resolve");
968        assert_eq!(
969            resolved.check_policy,
970            CheckPolicy::Strict,
971            "file check_policy must win over built-in default"
972        );
973    }
974
975    #[test]
976    fn resolve_check_policy_cli_wins_over_file() {
977        let cli = CliOverrides {
978            check_policy: Some(CheckPolicy::Silent),
979            ..Default::default()
980        };
981        let file = FileConfig {
982            check_policy: Some(CheckPolicy::Strict),
983            ..Default::default()
984        };
985        let resolved = resolve(cli, file).expect("resolve");
986        assert_eq!(
987            resolved.check_policy,
988            CheckPolicy::Silent,
989            "cli check_policy must win over file"
990        );
991    }
992
993    // ──────────────────────────────────────────────────────────────────
994    // Phase 6 (issue 4c4e3eb8): `blueprint_strict_embed` resolution cascade
995    // ──────────────────────────────────────────────────────────────────
996
997    #[test]
998    fn resolve_blueprint_strict_embed_default_false_when_cli_and_file_absent() {
999        let resolved = resolve(CliOverrides::default(), FileConfig::default()).expect("resolve");
1000        assert!(
1001            !resolved.blueprint_strict_embed,
1002            "default blueprint_strict_embed = false (backward-compat: linker runs server-side)"
1003        );
1004    }
1005
1006    #[test]
1007    fn resolve_blueprint_strict_embed_file_wins_over_default() {
1008        let file = FileConfig {
1009            blueprint_strict_embed: Some(true),
1010            ..Default::default()
1011        };
1012        let resolved = resolve(CliOverrides::default(), file).expect("resolve");
1013        assert!(resolved.blueprint_strict_embed);
1014    }
1015
1016    #[test]
1017    fn resolve_blueprint_strict_embed_cli_wins_over_file() {
1018        let cli = CliOverrides {
1019            blueprint_strict_embed: Some(false),
1020            ..Default::default()
1021        };
1022        let file = FileConfig {
1023            blueprint_strict_embed: Some(true),
1024            ..Default::default()
1025        };
1026        let resolved = resolve(cli, file).expect("resolve");
1027        assert!(
1028            !resolved.blueprint_strict_embed,
1029            "cli blueprint_strict_embed=false must win over file=true"
1030        );
1031    }
1032
1033    #[test]
1034    fn file_config_deserializes_blueprint_strict_embed() {
1035        let toml_text = "blueprint_strict_embed = true\n";
1036        let cfg: FileConfig = toml::from_str(toml_text).expect("parse");
1037        assert_eq!(cfg.blueprint_strict_embed, Some(true));
1038    }
1039
1040    #[test]
1041    fn file_config_deserializes_check_policy_snake_case_literals() {
1042        let toml_text = "check_policy = \"strict\"\n";
1043        let cfg: FileConfig = toml::from_str(toml_text).expect("parse");
1044        assert_eq!(cfg.check_policy, Some(CheckPolicy::Strict));
1045
1046        let toml_text = "check_policy = \"silent\"\n";
1047        let cfg: FileConfig = toml::from_str(toml_text).expect("parse");
1048        assert_eq!(cfg.check_policy, Some(CheckPolicy::Silent));
1049
1050        let toml_text = "check_policy = \"warn\"\n";
1051        let cfg: FileConfig = toml::from_str(toml_text).expect("parse");
1052        assert_eq!(cfg.check_policy, Some(CheckPolicy::Warn));
1053    }
1054}