mlua_swarm_server/lib.rs
1//! the server lib: axum Router + handler set. Split out as a library so it can
2//! be used from both `main.rs` (CLI) and integration tests.
3//!
4//! # Endpoints
5//!
6//! - `GET /v1/healthz`
7//! - `POST /v1/sessions` / `DELETE /v1/sessions` (= operator attach / detach, Bearer sid)
8//! - `POST /v1/tasks` (= unified Flow-form entry, Operator inject supported;
9//! `operator_sid` explicitly pins the task to a registered Operator session, S2).
10//! Also creates a `TaskRecord` + `RunRecord` (issue #13 ID-hierarchy persistence)
11//! and echoes their ids in the response; see the `tasks` module doc.
12//! - `GET /v1/tasks` — list every persisted `TaskRecord` (newest first).
13//! - `GET /v1/tasks/:id` — a `TaskRecord` plus every `RunRecord` kicked from it.
14//! - `POST /v1/tasks/:id/runs` — re-kick an existing Task (new `RunId`, same
15//! `blueprint_ref` / `input_ctx`).
16//! - `GET /v1/runs/:id` — a single `RunRecord` (its `step_entries` trace included).
17//! - `POST /v1/operators` / `GET /v1/operators/:sid` / `DELETE /v1/operators/:sid` /
18//! `GET /v1/operators/:sid/ws` (WS upgrade) — REST-like Operator login flow,
19//! Bearer-mandatory; the sole WS Operator session route. See `operator_ws::login`
20//! module doc.
21//!
22//! The Enhance issue axis (`/issues`) lives in the `issues` module; callers merge
23//! `build_issues_router` to integrate it into the same server.
24//!
25//! # The 3 faces of the Operator role (= registered directly on the engine SoT)
26//!
27//! The engine stateless-executor refactor removed the three
28//! `AppState` registries (former `HookRegistry` / `BridgeRegistry` / `OperatorRegistry`);
29//! all registration now goes directly to the engine SoT via
30//! `engine.register_spawn_hook` / `register_senior_bridge` / `register_operator`.
31//! `WSOperatorSession` (in the `operator_ws` module) registers all three traits
32//! simultaneously under a single sid — one WS connection covers all 3 faces of
33//! the Operator role, the canonical pattern.
34//!
35//! # `build_*` family
36//!
37//! - [`build_router`] — minimal entry (= `default_registry()`)
38//! - [`build_router_with`] — caller provides a `SpawnerRegistry` and optional `BlueprintStore`
39//!
40//! The engine should be started with [`default_layer_registry`] (= `Engine::new_with_layers`);
41//! otherwise `Blueprint.spawner_hints` is ignored.
42
43#![warn(missing_docs)]
44
45/// HTTP surface for inspecting/registering Blueprint state (`/v1/blueprints/*`).
46pub mod blueprints;
47/// Server config file support (`~/.mse/config.toml`, CLI > file > default merge).
48pub mod config;
49/// `/v1/data/*` endpoints (v9 Big Response handling, Store-owner direct path).
50pub mod data;
51/// `GET /v1/doctor` — read-only startup config / Store snapshot.
52pub mod doctor;
53/// HTTP surface for the `/v1/enhance/log` axis.
54pub mod enhance_log;
55/// `EnhanceSetting` HTTP CRUD (`/v1/enhance-settings*`).
56pub mod enhance_settings;
57/// HTTP surface for the Enhance issue axis (`/v1/issues*`).
58pub mod issues;
59/// WebSocket Operator Callback IF (`/v1/operators*`).
60pub mod operator_ws;
61/// HTTP surface for the Task/Run persistence axis (issue #13 ID hierarchy;
62/// `GET /v1/tasks`, `GET /v1/tasks/:id`, `POST /v1/tasks/:id/runs`,
63/// `GET /v1/runs/:id`). `POST /v1/tasks` itself stays in this module (it is
64/// the entry point `tasks_start` shares with the flow-eval path) — see the
65/// `tasks` module doc for the split rationale.
66pub mod tasks;
67/// `/v1/worker/*` endpoints (SubAgent self-fetch path).
68pub mod worker;
69pub use blueprints::{build_blueprints_router, build_blueprints_router_with_refs};
70pub use enhance_log::build_enhance_log_router;
71pub use enhance_settings::build_enhance_settings_router;
72pub use issues::{build_issues_router, GetIssueResponse, PostIssueRequest, PostIssueResponse};
73pub use operator_ws::{
74 operators_create, operators_delete, operators_info, operators_ws_connect, ClientMsg,
75 OperatorSessionEntry, ServerMsg, WSOperatorSession,
76};
77pub use tasks::{RunKickRequest, RunKickResponse, TaskDetailResponse};
78pub use worker::{worker_prompt, worker_result, PromptQuery, WorkerResultReq};
79
80use axum::{
81 extract::State,
82 http::{header::AUTHORIZATION, HeaderMap, StatusCode},
83 response::{IntoResponse, Response},
84 routing::{get, post},
85 Json, Router,
86};
87use mlua_swarm::application::{BlueprintRef, TaskApplication};
88use mlua_swarm::blueprint::store::BlueprintStore;
89use mlua_swarm::service::TaskLaunchService;
90use mlua_swarm::store::run::{RunContext, RunRecord, RunStatus, RunStore};
91use mlua_swarm::store::task::{TaskRecord, TaskRecordStatus, TaskStore};
92use mlua_swarm::{
93 CapToken, Compiler, Engine, LayerRegistry, LuaInProcessSpawnerFactory, MainAIMiddleware,
94 OperatorDelegateMiddleware, OperatorSpawnerFactory, Role, RunId, RustFnInProcessSpawnerFactory,
95 SeniorEscalationMiddleware, SessionId, SpawnerRegistry, SubprocessProcessSpawnerFactory,
96 TaskId,
97};
98use serde::{Deserialize, Serialize};
99use serde_json::{json, Value};
100use std::collections::HashMap;
101use std::sync::Arc;
102use std::time::Duration;
103use tokio::sync::Mutex;
104
105/// In-memory session map backing `/v1/sessions` attach/detach.
106///
107/// The `sid` handed to the client on this REST path is the token nonce
108/// itself (a bearer secret), so the server never uses it as a map key —
109/// entries are keyed by its fingerprint
110/// (`mlua_swarm::types::token_fingerprint`; issue #14).
111#[derive(Default)]
112pub struct SessionStore {
113 /// Live session tokens keyed by the sid's fingerprint.
114 pub map: HashMap<String, CapToken>,
115}
116
117/// Shared axum handler state for the whole router. Cloned per-request (all
118/// fields are `Arc`/cheap-clone), constructed once in [`build_router_with_ws_factory`].
119#[derive(Clone)]
120pub struct AppState {
121 /// The engine SoT (attach/detach, dispatch, registries).
122 pub engine: Engine,
123 /// Live `/v1/sessions` attach records (Operator/Worker/etc session tokens).
124 pub sessions: Arc<Mutex<SessionStore>>,
125 /// Application used at the task entry to resolve `BlueprintRef`. Without a Store, runs in Inline-only mode.
126 pub task_app: Arc<TaskApplication>,
127 /// When `Some`, on WS connect a new `WSOperatorSession` is automatically registered
128 /// with this factory under the sid name (= a `kind=operator` + `operator_ref=<sid>` AgentDef
129 /// binds to the `WSOperatorSession` backend).
130 /// When `None`, no auto-registration happens; the session is only registered on
131 /// `engine.OperatorRegistry` (= only the `OperatorDelegateMiddleware` path is effective;
132 /// the `OperatorSpawnerFactory` path is dead).
133 pub ws_operator_factory: Option<Arc<OperatorSpawnerFactory>>,
134 /// Owner of the Store on the Data path (Big Response handling). Added in v9.
135 /// Independent layer — the Engine core and the Domain path (`/v1/worker/result`)
136 /// are not involved.
137 /// Default = `InMemoryOutputStore` (constructed inside `build_router_with_ws_factory`);
138 /// callers can swap in an sqlite/fs backend later (future carry).
139 pub data_store: Arc<dyn mlua_swarm::store::output::OutputStore>,
140 /// Login-flow session store (`POST /v1/operators` mint records). `sid` →
141 /// `OperatorSessionEntry`. This is the sole session store for the WS
142 /// Operator role. See `operator_ws::login` module doc.
143 pub operator_sessions:
144 Arc<Mutex<HashMap<SessionId, Arc<crate::operator_ws::login::OperatorSessionEntry>>>>,
145 /// S1 login-flow roles-exclusivity map. Role name → owning `sid`. Checked
146 /// (and updated) atomically under a single lock in
147 /// `operator_ws::login::operators_create` — a role already present here
148 /// causes `POST /v1/operators` to return `409 CONFLICT`. Entries are
149 /// released on `DELETE /v1/operators/:sid`.
150 pub roles_to_sid: Arc<Mutex<HashMap<String, SessionId>>>,
151 /// Persistence for `Task` records (issue #13 ID-hierarchy work-item
152 /// identity; see `mlua_swarm::store::task` module doc). Default =
153 /// `InMemoryTaskStore` (constructed inside `build_router_full`); callers
154 /// can swap in a `SqliteTaskStore` via the `task_store` argument.
155 pub task_store: Arc<dyn TaskStore>,
156 /// Persistence for `Run` records (one kick of a Task; see
157 /// `mlua_swarm::store::run` module doc). Default = `InMemoryRunStore`;
158 /// callers can swap in a `SqliteRunStore` via the `run_store` argument.
159 pub run_store: Arc<dyn RunStore>,
160 /// Public HTTP base URL the server is reachable at (e.g.
161 /// `"http://127.0.0.1:7777"`), sourced from the binary at boot time.
162 /// When `Some`, `WSOperatorSession` renders it literally into the
163 /// Spawn `directive`'s `base_url` line so the receiving operator can
164 /// paste the frame into a SubAgent prompt without a `mse_doctor`
165 /// detour (issue #8). `None` preserves the historical fallback
166 /// (a placeholder that points at `mse_doctor`).
167 pub base_url: Option<Arc<str>>,
168}
169
170/// Minimal entry point: builds a router with [`default_registry`] and no
171/// `BlueprintStore` (Inline-only mode) or `ws_operator_factory`.
172pub fn build_router(engine: Engine) -> Router {
173 build_router_with(engine, default_registry(), None)
174}
175
176/// Default `LayerRegistry` for the server. Hint keys:
177/// - `"main_ai"` → `MainAIMiddleware` (= fires SpawnHook before/after)
178/// - `"senior_escalation"` → `SeniorEscalationMiddleware` (= on `ok=false`, escalates via `SeniorBridge.ask`)
179/// - `"operator_delegate"` → `OperatorDelegateMiddleware` (= when an operator backend is registered, delegates the entire spawn)
180///
181/// Including any of these keys in `Blueprint.spawner_hints.layers` causes them to
182/// be wrapped into a `SpawnerStack` at `service::linker::link` time (= per-launch;
183/// the old `engine.bind` global-state path is retired).
184/// Callers (the engine builder side) receive it via
185/// `Engine::new_with_layers(cfg, mse_server::default_layer_registry())`.
186pub fn default_layer_registry() -> LayerRegistry {
187 LayerRegistry::new()
188 .with_hint("main_ai", |_engine| Arc::new(MainAIMiddleware::new()))
189 .with_hint("senior_escalation", |_engine| {
190 Arc::new(SeniorEscalationMiddleware::new())
191 })
192 .with_hint("operator_delegate", |_engine| {
193 Arc::new(OperatorDelegateMiddleware::new())
194 })
195}
196
197/// Build form where the caller supplies a registry and an optional `BlueprintStore`.
198/// The Operator callback path (= external HTTP / WS callers acting as an Operator)
199/// must be pre-registered via `engine.register_*` (= the engine is the SoT).
200/// See the `operator_ws` module doc and `OperatorInfo` (engine-side `ctx.rs`) for details.
201pub fn build_router_with(
202 engine: Engine,
203 registry: SpawnerRegistry,
204 store: Option<Arc<dyn BlueprintStore>>,
205) -> Router {
206 build_router_with_ws_factory(engine, registry, store, None)
207}
208
209/// 4-argument variant of `build_router_with`. Passing `ws_operator_factory = Some(arc)`
210/// causes each WS connect to auto-register a new `WSOperatorSession` under its sid
211/// name with the factory (= a `kind=operator` AgentDef with `operator_ref: <sid>`
212/// can then bind to the WS client backend). Callers are expected to also install
213/// the same `Arc` into the `SpawnerRegistry` via
214/// `reg.register::<OperatorSpawnerFactory>(arc.clone())`.
215pub fn build_router_with_ws_factory(
216 engine: Engine,
217 registry: SpawnerRegistry,
218 store: Option<Arc<dyn BlueprintStore>>,
219 ws_operator_factory: Option<Arc<OperatorSpawnerFactory>>,
220) -> Router {
221 build_router_with_ws_factory_and_output(engine, registry, store, ws_operator_factory, None)
222}
223
224/// 5-argument variant of [`build_router_with_ws_factory`]. Passing
225/// `output_store = Some(arc)` swaps the default `InMemoryOutputStore` for a
226/// caller-supplied backend (a `SqliteOutputStore`, for instance). `None`
227/// preserves the historical behaviour (fresh in-memory store per call).
228pub fn build_router_with_ws_factory_and_output(
229 engine: Engine,
230 registry: SpawnerRegistry,
231 store: Option<Arc<dyn BlueprintStore>>,
232 ws_operator_factory: Option<Arc<OperatorSpawnerFactory>>,
233 output_store: Option<Arc<dyn mlua_swarm::store::output::OutputStore>>,
234) -> Router {
235 build_router_full(
236 engine,
237 registry,
238 store,
239 ws_operator_factory,
240 output_store,
241 None,
242 None,
243 None,
244 )
245}
246
247/// 8-argument variant of [`build_router_with_ws_factory_and_output`].
248/// Passing `base_url = Some(...)` (e.g. `"http://127.0.0.1:7777"`) makes
249/// `WSOperatorSession` render the actual server bind into the Spawn
250/// directive's `base_url` line, so the receiving operator can copy the
251/// frame straight into a SubAgent prompt (issue #8). `None` preserves
252/// the historical fallback (`<check with mse_doctor>` placeholder).
253/// `task_store` / `run_store` swap the default `InMemoryTaskStore` /
254/// `InMemoryRunStore` (issue #13 ID-hierarchy persistence) for a
255/// caller-supplied backend (`SqliteTaskStore` / `SqliteRunStore`, for
256/// instance); `None` preserves the process-volatile default.
257// This is the terminal builder in the `build_router*` delegation chain
258// (each variant adds one more caller-overridable store/factory); the
259// argument count grows with the number of pluggable backends, not with
260// unrelated responsibilities, so a plain allow is preferable to bundling
261// them into a config struct only this one function would consume.
262#[allow(clippy::too_many_arguments)]
263pub fn build_router_full(
264 engine: Engine,
265 registry: SpawnerRegistry,
266 store: Option<Arc<dyn BlueprintStore>>,
267 ws_operator_factory: Option<Arc<OperatorSpawnerFactory>>,
268 output_store: Option<Arc<dyn mlua_swarm::store::output::OutputStore>>,
269 base_url: Option<Arc<str>>,
270 task_store: Option<Arc<dyn TaskStore>>,
271 run_store: Option<Arc<dyn RunStore>>,
272) -> Router {
273 let compiler = Compiler::new(registry);
274 let launch = Arc::new(TaskLaunchService::new(engine.clone(), compiler));
275 let task_app = Arc::new(match store {
276 Some(s) => TaskApplication::new(launch, s),
277 None => TaskApplication::new_inline_only(launch),
278 });
279 let data_store: Arc<dyn mlua_swarm::store::output::OutputStore> = match output_store {
280 Some(s) => s,
281 None => Arc::new(mlua_swarm::store::output::InMemoryOutputStore::new()),
282 };
283 let task_store: Arc<dyn TaskStore> = match task_store {
284 Some(s) => s,
285 None => Arc::new(mlua_swarm::store::task::InMemoryTaskStore::new()),
286 };
287 let run_store: Arc<dyn RunStore> = match run_store {
288 Some(s) => s,
289 None => Arc::new(mlua_swarm::store::run::InMemoryRunStore::new()),
290 };
291 let state = AppState {
292 engine,
293 sessions: Arc::new(Mutex::new(SessionStore::default())),
294 task_app,
295 ws_operator_factory,
296 data_store,
297 operator_sessions: Arc::new(Mutex::new(HashMap::new())),
298 roles_to_sid: Arc::new(Mutex::new(HashMap::new())),
299 task_store,
300 run_store,
301 base_url,
302 };
303 Router::new()
304 .route("/v1/healthz", get(healthz))
305 // session = collection (POST = attach, DELETE = detach, sid via Authorization)
306 .route(
307 "/v1/sessions",
308 post(sessions_attach).delete(sessions_detach),
309 )
310 // task = flat, single level; authz resolved via Authorization: Bearer <sid>
311 .route("/v1/tasks", post(tasks_start).get(tasks::tasks_list))
312 .route("/v1/tasks/:id", get(tasks::task_get))
313 .route("/v1/tasks/:id/runs", post(tasks::task_rekick))
314 .route("/v1/runs/:id", get(tasks::run_get))
315 // REST-like Operator login flow (Bearer-mandatory, roles exclusivity).
316 // Sole WS Operator session route; see `operator_ws::login` module doc.
317 .route("/v1/operators", post(operators_create))
318 .route("/v1/operators/:sid/ws", get(operators_ws_connect))
319 .route(
320 "/v1/operators/:sid",
321 get(operators_info).delete(operators_delete),
322 )
323 // SubAgent self-fetch path (the SubAgent self-fetch design). The SubAgent puts the
324 // CapToken handed over via WS Spawn into Bearer and hits the prompt / result
325 // endpoints directly over HTTP. See the `worker` module doc for details.
326 .route("/v1/worker/prompt", get(worker::worker_prompt))
327 .route("/v1/worker/result", post(worker::worker_result))
328 // Simplified endpoint (= worker POSTs with just token + raw body; task_id is auto-looked-up)
329 .route("/v1/worker/submit", post(worker::worker_submit))
330 // Data path (v9 Big Response handling, independent from Domain / verdict flow)
331 .route("/v1/data/emit", post(data::data_emit))
332 .route(
333 "/v1/data/:key",
334 get(data::data_get).post(data::data_emit_named),
335 )
336 .with_state(state)
337}
338
339/// Default registry = Subprocess + RustFn (baseline `identity` worker pre-baked) + empty Operator factory.
340///
341/// `RustFnInProcessSpawnerFactory` gets one baseline entry (`fn_id = "identity"`)
342/// baked in via [`mlua_swarm::worker::baseline::extend_with_baseline`]. This
343/// is the shared bootstrap / smoke worker SoT across each binary (the server / MCP adapter /
344/// one-shot runner) — it structurally replaces the old per-binary inline echo injection.
345///
346/// Usage: default Task path at server startup. If production needs additional
347/// backends, callers bring in a different registry via
348/// `build_router_with(engine, custom_registry)`. The enhance flow
349/// (= patch-spawner / patch-applier / verifier-router / committer axes) uses
350/// [`default_registry_with_enhance_flow`].
351///
352/// The Operator factory is an empty shell with zero registrations (= sids are
353/// dynamically registered per WS connect; see the `operator_ws` module).
354pub fn default_registry() -> SpawnerRegistry {
355 let rustfn_factory =
356 mlua_swarm::worker::baseline::extend_with_baseline(RustFnInProcessSpawnerFactory::new());
357
358 let mut reg = SpawnerRegistry::new();
359 reg.register::<SubprocessProcessSpawnerFactory>(Arc::new(SubprocessProcessSpawnerFactory));
360 reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(rustfn_factory));
361 // Empty `LuaInProcessSpawnerFactory`: no `fn_id` is pre-registered here,
362 // but BP agents can still declare `kind: lua` by carrying an inline
363 // `spec.source` (or a `$file`-expanded Lua chunk). This lets a BP ship
364 // deterministic Lua gates on the vanilla registry, without opting into
365 // the enhance flow. See `LuaInProcessSpawnerFactory` docs for the spec
366 // shape.
367 reg.register::<LuaInProcessSpawnerFactory>(Arc::new(LuaInProcessSpawnerFactory::new()));
368 reg.register::<OperatorSpawnerFactory>(Arc::new(OperatorSpawnerFactory::new()));
369 reg
370}
371
372/// Opt-in registry that merges [`default_registry`] with the enhance flow
373/// (Lua factory + AgentBlock factory).
374///
375/// Selected via the `the server` CLI flag `--enable-enhance-flow`. The enhance
376/// flow is a separate-axis wrapper: the Lua factory (= 3 Lua workers + 3 primitive
377/// bridges) and the AgentBlock factory (= patch-spawner path, expects
378/// `assets/operator_scripts/blueprint_patch_spawner.lua` + `ANTHROPIC_API_KEY`)
379/// are baked in as pipeline defaults. The baseline RustFn (`identity`) is pre-baked
380/// the same way as in `default_registry`.
381pub fn default_registry_with_enhance_flow() -> SpawnerRegistry {
382 let lua_factory =
383 mlua_swarm::enhance::blueprint::extend_factory(LuaInProcessSpawnerFactory::new());
384 // The Factory is stateless (= 1 process → 1 factory shared by all AgentDefs).
385 // Per-agent specialization (script_path / project_root, etc.) goes through AgentDef.spec.
386 // The enhance-flow patch-spawner is declared literally in agents[].spec of `default_blueprint.yaml`.
387 let agent_block_factory =
388 mlua_swarm::worker::agent_block::AgentBlockInProcessSpawnerFactory::new();
389 let rustfn_factory =
390 mlua_swarm::worker::baseline::extend_with_baseline(RustFnInProcessSpawnerFactory::new());
391
392 let mut reg = SpawnerRegistry::new();
393 reg.register::<SubprocessProcessSpawnerFactory>(Arc::new(SubprocessProcessSpawnerFactory));
394 reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(rustfn_factory));
395 reg.register::<LuaInProcessSpawnerFactory>(Arc::new(lua_factory));
396 reg.register::<mlua_swarm::worker::agent_block::AgentBlockInProcessSpawnerFactory>(Arc::new(
397 agent_block_factory,
398 ));
399 reg.register::<OperatorSpawnerFactory>(Arc::new(OperatorSpawnerFactory::new()));
400 reg
401}
402
403// ─── handlers ────────────────────────────────────────────────────────────
404
405async fn healthz() -> &'static str {
406 "ok"
407}
408
409#[derive(Deserialize)]
410struct AttachReq {
411 agent_id: String,
412 role: String,
413 ttl_secs: u64,
414}
415
416#[derive(Serialize)]
417struct AttachResp {
418 session_id: String,
419 role: String,
420}
421
422async fn sessions_attach(
423 State(state): State<AppState>,
424 Json(req): Json<AttachReq>,
425) -> Result<Json<AttachResp>, ApiError> {
426 let role = parse_role(&req.role)?;
427 let token = state
428 .engine
429 .attach(req.agent_id, role, Duration::from_secs(req.ttl_secs))
430 .await
431 .map_err(ApiError::engine)?;
432 // The wire `session_id` stays the nonce (Bearer credential contract);
433 // the server-side map key is its fingerprint (issue #14).
434 let sid = token.nonce.clone();
435 let key = token.fingerprint();
436 state.sessions.lock().await.map.insert(key, token);
437 Ok(Json(AttachResp {
438 session_id: sid,
439 role: req.role,
440 }))
441}
442
443async fn sessions_detach(
444 State(state): State<AppState>,
445 headers: HeaderMap,
446) -> Result<StatusCode, ApiError> {
447 let sid = extract_bearer(&headers)?;
448 let token = take_session_token(&state, &sid).await?;
449 state
450 .engine
451 .detach(&token)
452 .await
453 .map_err(ApiError::engine)?;
454 Ok(StatusCode::NO_CONTENT)
455}
456
457// ─── Unified /v1/tasks schema (= flow-eval path, Operator inject supported) ───────
458
459/// `/v1/tasks` POST schema. Uses the flow-eval path and supports Operator inject
460/// (kind / spawn_hook / senior_bridge). Expressing a one-shot task as a 1-Step
461/// Blueprint is the only correct model.
462///
463/// `pub` (issue #19 ST5) so its `schemars`-derived JSON Schema can be
464/// generated cross-crate by `mlua-swarm-cli`'s `mse://api/http-endpoints`
465/// MCP resource; fields stay module-private (no public field-level API
466/// surface is intended).
467#[derive(Deserialize, schemars::JsonSchema)]
468pub struct TaskLaunchRequest {
469 /// `BlueprintRef` selects Inline (a full Blueprint value) or Id (a
470 /// store lookup). Left opaque here — its own schema nests the full
471 /// `Blueprint` schema (owned by `mse://api/blueprint-schema`), and
472 /// mixing the two into this HTTP-endpoint resource would violate
473 /// their separation of concerns (see the resource's module doc).
474 #[schemars(with = "Value")]
475 blueprint: BlueprintRef,
476 /// flow.ir's initial `ctx` — every `Step.in` `$.<path>` reads from
477 /// here. This field's role is limited to the flow-ir eval seed
478 /// (issue #19); the Task-level execution context lives in the
479 /// sibling top-level fields below (`project_root` / `work_dir` /
480 /// `task_metadata`), promoted out of `init_ctx` to remove the
481 /// prior "free bag nested in free JSON" duplication.
482 ///
483 /// Backward compat: the pre-#19 shape — the same three keys nested
484 /// directly inside this object — is still honored as a fallback
485 /// when the sibling field is absent; see `run_flow_form`'s 2-stage
486 /// resolution and `TaskInputMiddleware::from_init_ctx`.
487 #[schemars(with = "Value")]
488 init_ctx: Value,
489 /// Task-level project root (issue #19 canonical Task IF field —
490 /// promoted out of `init_ctx`). Takes priority over a same-named
491 /// key nested inside `init_ctx` (backward-compat fallback).
492 #[serde(default)]
493 project_root: Option<String>,
494 /// Task-level working directory (issue #19), same priority rule as
495 /// `project_root`.
496 #[serde(default)]
497 work_dir: Option<String>,
498 /// Task-level arbitrary metadata bag (issue #19), same priority
499 /// rule as `project_root`.
500 #[serde(default)]
501 #[schemars(with = "Option<Value>")]
502 task_metadata: Option<Value>,
503 /// TTL in seconds. When unspecified (`None`), falls back in this order:
504 /// (1) `metadata.default_run_ttl_secs` from the resolved BP,
505 /// (2) if absent, the server global `default_run_ttl()` (1800s).
506 #[serde(default)]
507 ttl_secs: Option<u64>,
508 #[serde(default)]
509 operator: Option<OperatorReq>,
510 /// Explicit Operator session sid (or role alias) this task's entire Spawn
511 /// stream should be routed to (runtime Operator match stage 1).
512 ///
513 /// When `Some`, it is validated at request time against
514 /// `state.engine.list_operator_ids()` (the live `engine.operators`
515 /// registry key set): an unknown/never-registered id returns `400`
516 /// immediately — this is a deliberate hard-fail, in contrast to
517 /// `OperatorDelegateWrapped::spawn`, which silently falls through to
518 /// `inner.spawn` on a registry miss. A sid that *was* registered but has
519 /// since disconnected (WS `tx` cleared, session entry retained for
520 /// reconnect) passes this check and surfaces as an explicit dispatch-time
521 /// error instead (`WSOperatorSession::send_and_await` returns `Err` when
522 /// `tx` is `None`), which also propagates as a request failure rather
523 /// than a silent fallback.
524 ///
525 /// On success this value **overrides** `operator.operator_backend_id`
526 /// (last-write-wins, `operator_sid` takes priority) before the flow is
527 /// dispatched — see `run_flow_form`. Dispatch still only delegates if the
528 /// Blueprint opts into `spawner_hints.layers = ["operator_delegate"]`
529 /// (unchanged precondition, same as the existing `operator_backend_id`
530 /// field).
531 ///
532 /// When unset, behavior is unchanged: whatever
533 /// `operator.operator_backend_id` / BP-level `operator_ref` alias
534 /// resolution already does still applies.
535 #[serde(default)]
536 operator_sid: Option<String>,
537 /// Human-facing description of the work item (e.g. "resolve issue #10"),
538 /// stashed verbatim into the minted `TaskRecord.goal`. Omitted / `None`
539 /// stores an empty string — the flow-eval path itself never reads it.
540 #[serde(default)]
541 goal: Option<String>,
542}
543
544/// Operator inject sub-schema of [`TaskLaunchRequest`] (`kind` / `id` /
545/// `spawn_hook_id` / `senior_bridge_id` / `operator_backend_id` /
546/// `per_agent_kinds`). `pub` for the same cross-crate schema-generation
547/// reason as `TaskLaunchRequest`.
548#[derive(Deserialize, Default, schemars::JsonSchema)]
549pub struct OperatorReq {
550 /// `main_ai` / `automate` / `composite`. This is the "Runtime Global"
551 /// tier of the 4-tier `OperatorKind` cascade (see `mlua_swarm
552 /// ::ctx::collapse_operator_kind`); when unspecified, falls through to
553 /// the BP-level tiers (`OperatorDef.kind` / `Blueprint
554 /// .default_operator_kind`) instead of eagerly defaulting to `automate`.
555 #[serde(default)]
556 kind: Option<String>,
557 /// Operator id at attach time (= sessions tracking key in the EventLog); unspecified defaults to `"http-run"`.
558 #[serde(default)]
559 id: Option<String>,
560 /// Name of a hook pre-registered via `engine.register_spawn_hook`; `None` if unspecified.
561 #[serde(default)]
562 spawn_hook_id: Option<String>,
563 /// Name of a bridge pre-registered via `engine.register_senior_bridge`; `None` if unspecified.
564 #[serde(default)]
565 senior_bridge_id: Option<String>,
566 /// Name of an Operator backend pre-registered via `engine.register_operator`
567 /// (= the path that delegates the entire spawn to an external Operator);
568 /// `None` if unspecified. When `kind == MainAi/Composite` and this id is `Some`,
569 /// `OperatorDelegateMiddleware` bypasses `inner.spawn` and calls `operator.execute` instead.
570 /// This is a different axis from `operator.id` (= session tracking label);
571 /// `operator_backend_id` is the registry lookup key.
572 #[serde(default)]
573 operator_backend_id: Option<String>,
574 /// "Runtime Agent-level" tier (highest priority) of the `OperatorKind`
575 /// cascade — per-agent override, keyed by `AgentDef.name`, value is
576 /// `main_ai` / `automate` / `composite` (same parsing as `kind`).
577 /// `None` / absent means no per-agent override.
578 #[serde(default)]
579 per_agent_kinds: Option<HashMap<String, String>>,
580}
581
582/// Parse a wire-level kind string (`"main_ai"` / `"automate"` / `"composite"`)
583/// into `OperatorKind`. Shared by `OperatorReq.kind` and
584/// `OperatorReq.per_agent_kinds` values.
585fn parse_operator_kind_str(s: &str) -> Result<mlua_swarm::OperatorKind, ApiError> {
586 use mlua_swarm::OperatorKind;
587 match s {
588 "main_ai" => Ok(OperatorKind::MainAi),
589 "composite" => Ok(OperatorKind::Composite),
590 "automate" => Ok(OperatorKind::Automate),
591 other => Err(ApiError::bad_request(format!(
592 "operator kind: unknown value '{other}' (expected main_ai|automate|composite)"
593 ))),
594 }
595}
596
597/// `/v1/tasks` POST response body. `pub` for the same cross-crate
598/// schema-generation reason as [`TaskLaunchRequest`].
599#[derive(Serialize, schemars::JsonSchema)]
600pub struct TaskLaunchResponse {
601 /// The final flow.ir `ctx` after every `Step.out` has been written.
602 #[schemars(with = "Value")]
603 final_ctx: Value,
604 /// Debug-formatted `BlueprintVersion` the run resolved against, when
605 /// the Blueprint came from a store lookup (`None` for `Inline` refs).
606 bound_version: Option<String>,
607 /// Resolved TTL (seconds) actually applied to the run. Exposes the
608 /// 3-layer cascade (request body → BP metadata → server default) so
609 /// clients can verify which value took effect without re-deriving it.
610 effective_ttl_secs: u64,
611 /// Which layer of the TTL cascade won.
612 ttl_source: TtlSource,
613 /// The `TaskRecord` minted for this request (issue #13 ID-hierarchy
614 /// persistence). `GET /v1/tasks/:id` re-fetches it; `POST
615 /// /v1/tasks/:id/runs` re-kicks it under a fresh `RunId`.
616 #[schemars(with = "String")]
617 task_id: TaskId,
618 /// The `RunRecord` minted for this specific kick. `GET /v1/runs/:id`
619 /// re-fetches it (`step_entries` included).
620 #[schemars(with = "String")]
621 run_id: RunId,
622}
623
624/// Which layer of the TTL cascade (request body → BP metadata → server
625/// default) resolved [`TaskLaunchResponse::effective_ttl_secs`]. `pub` for
626/// the same cross-crate schema-generation reason as `TaskLaunchRequest`.
627#[derive(Serialize, Clone, Copy, Debug, PartialEq, Eq, schemars::JsonSchema)]
628#[serde(rename_all = "snake_case")]
629pub enum TtlSource {
630 /// The request body's `ttl_secs` was set explicitly.
631 RequestBody,
632 /// The request body omitted `ttl_secs`; the resolved Blueprint's
633 /// `metadata.default_run_ttl_secs` was set.
634 BpMetadata,
635 /// Both the request body and the Blueprint metadata omitted a TTL;
636 /// the server-global `default_run_ttl()` (1800s) applied.
637 ServerDefault,
638}
639
640/// Unified `/v1/tasks` POST entry (= Flow form only).
641/// Runs `Blueprint.flow` to completion via flow eval in a single round-trip.
642/// One-shot tasks are also expressed as a 1-Step Blueprint. Operator
643/// (kind / spawn_hook / senior_bridge) can be injected per request body.
644/// `operator_sid` (S2, runtime Operator match stage 1) additionally
645/// lets the caller pin the task to a specific already-registered Operator
646/// session sid, bypassing BP-level alias lookup — see `TaskLaunchRequest` doc.
647async fn tasks_start(
648 State(state): State<AppState>,
649 Json(req): Json<TaskLaunchRequest>,
650) -> Result<Json<TaskLaunchResponse>, ApiError> {
651 let resp = run_flow_form(&state, req).await?;
652 Ok(Json(resp))
653}
654
655/// Flow-form path (= via `TaskApplication::handle_with_run`).
656/// Core handler behind the `/v1/tasks` entry (`tasks_start`).
657///
658/// Engine stateless-executor refactor: the per-request
659/// sub_engine + 3-registry propagate loop is retired; the startup-built
660/// `state.task_app` (= a `TaskLaunchService` wrap around `state.engine`) is
661/// used directly. The Operator callback IF (`spawn_hook_id` /
662/// `senior_bridge_id` / `operator_backend_id`) is registered on
663/// `state.engine.register_*` at WS connect time — the engine is the SoT.
664/// See the `operator_ws` module doc for details.
665async fn run_flow_form(
666 state: &AppState,
667 req: TaskLaunchRequest,
668) -> Result<TaskLaunchResponse, ApiError> {
669 use mlua_swarm::application::{BlueprintRef as AppBlueprintRef, TaskApplicationInput};
670 use mlua_swarm::OperatorKind;
671
672 // Snapshot everything the TaskRecord needs before `req.blueprint` /
673 // `req.init_ctx` are moved into the dispatch path below.
674 let blueprint_ref_json = serde_json::to_value(&req.blueprint)
675 .map_err(|e| ApiError::bad_request(format!("blueprint snapshot: {e}")))?;
676 let input_ctx_snapshot = req.init_ctx.clone();
677 let goal = req.goal.clone().unwrap_or_default();
678
679 // issue #19 ST2: resolve the Task-level canonical fields
680 // (`project_root` / `work_dir` / `task_metadata`) once, at the wire
681 // boundary. Sibling top-level fields on the request body take
682 // priority; the pre-#19 shape (same key nested inside `init_ctx`) is
683 // only a fallback for legacy callers. The result is threaded straight
684 // through as `TaskApplicationInput.task_input` — `init_ctx` itself is
685 // NOT mutated, so it stays a pure flow-ir eval seed identical to
686 // whatever the caller sent.
687 let task_input_spec = build_task_input_spec_from_request(&req);
688 // Issue #19 ST4: snapshot the resolved spec into the `TaskRecord` (JSON,
689 // same "bare `Value`" rationale as `blueprint_ref_json` /
690 // `input_ctx_snapshot` above) so `POST /v1/tasks/:id/runs` can resolve
691 // it back out on rekick without re-deriving it from a since-stale
692 // request body. Cloned rather than computed from `task_input_spec`
693 // after the fact — the original is still moved into
694 // `TaskApplicationInput.task_input` below.
695 let task_input_spec_snapshot = task_input_spec
696 .clone()
697 .map(|spec| serde_json::to_value(&spec))
698 .transpose()
699 .map_err(|e| ApiError::bad_request(format!("task_input_spec snapshot: {e}")))?;
700 let init_ctx = req.init_ctx.clone();
701
702 let mut op_req = req.operator.unwrap_or_default();
703
704 // S2: explicit `operator_sid` override (runtime Operator match stage 1).
705 // Resolved *before* building `operator_kind` / dispatching so an
706 // unknown sid fails fast with a 400, never silently falling back to the
707 // BP-level alias lookup. See `TaskLaunchRequest::operator_sid` doc for the
708 // disconnected-vs-unknown distinction.
709 if let Some(sid) = &req.operator_sid {
710 let known_ids = state.engine.list_operator_ids().await;
711 if !known_ids.iter().any(|id| id == sid) {
712 return Err(ApiError::bad_request(format!(
713 "operator_sid: no such registered operator session '{sid}'"
714 )));
715 }
716 op_req.operator_backend_id = Some(sid.clone());
717 }
718
719 // "Runtime Global" tier: `Some(_)` — including `Some(Automate)` — is
720 // always an explicit request that outranks the BP-level tiers; an
721 // absent/unset `kind` in the request body stays `None`, leaving the
722 // BP-level tiers (`OperatorDef.kind` / `Blueprint.default_operator_kind`)
723 // to decide instead of eagerly defaulting to `Automate`.
724 let operator_kind = op_req
725 .kind
726 .as_deref()
727 .map(parse_operator_kind_str)
728 .transpose()?;
729 let operator_id = op_req.id.unwrap_or_else(|| "http-run".to_string());
730 // "Runtime Agent-level" tier: per-agent overrides. Absent/empty = no
731 // override for any agent, letting the BP-level tiers decide per agent.
732 let mut operator_kind_overrides: HashMap<String, OperatorKind> = HashMap::new();
733 for (agent, kind_str) in op_req.per_agent_kinds.take().unwrap_or_default() {
734 operator_kind_overrides.insert(agent, parse_operator_kind_str(&kind_str)?);
735 }
736
737 let blueprint: AppBlueprintRef = match req.blueprint {
738 AppBlueprintRef::Inline { value } => AppBlueprintRef::Inline { value },
739 AppBlueprintRef::Id { id, version } => AppBlueprintRef::Id { id, version },
740 };
741
742 // TTL resolution cascade: (1) request body value, (2) BP metadata `default_run_ttl_secs`,
743 // (3) server global default (`default_run_ttl()`, 1800s).
744 let (ttl_secs, ttl_source) = match req.ttl_secs {
745 Some(v) => (v, TtlSource::RequestBody),
746 None => {
747 let (resolved_bp, _ver) = state
748 .task_app
749 .resolve(&blueprint)
750 .await
751 .map_err(|e| ApiError::bad_request(format!("bp resolve: {e}")))?;
752 match resolved_bp.metadata.default_run_ttl_secs {
753 Some(v) => (v, TtlSource::BpMetadata),
754 None => (default_run_ttl(), TtlSource::ServerDefault),
755 }
756 }
757 };
758
759 // issue #13 ID-hierarchy persistence: mint the work-item identity (Task)
760 // and this kick's identity (Run) *before* dispatching, so a Task/Run
761 // pair always exists even if the flow itself fails mid-way (the
762 // Failed-status paths below still have a row to update).
763 let task_id = TaskId::new();
764 let run_id = RunId::new();
765 let now = tasks::now_secs();
766 state
767 .task_store
768 .create(TaskRecord {
769 id: task_id.clone(),
770 goal,
771 blueprint_ref: blueprint_ref_json,
772 input_ctx: input_ctx_snapshot,
773 task_input_spec: task_input_spec_snapshot,
774 status: TaskRecordStatus::Running,
775 created_at: now,
776 updated_at: now,
777 })
778 .await
779 .map_err(ApiError::engine)?;
780 state
781 .run_store
782 .create(RunRecord {
783 id: run_id.clone(),
784 task_id: task_id.clone(),
785 status: RunStatus::Running,
786 step_entries: Vec::new(),
787 operator_sid: req.operator_sid.clone(),
788 result_ref: None,
789 created_at: now,
790 updated_at: now,
791 })
792 .await
793 .map_err(ApiError::engine)?;
794
795 let run_ctx = RunContext {
796 run_id: run_id.clone(),
797 run_store: state.run_store.clone(),
798 };
799 let outcome = state
800 .task_app
801 .handle_with_run(
802 TaskApplicationInput {
803 blueprint,
804 operator_id: operator_id.clone(),
805 role: Role::Operator,
806 ttl: Duration::from_secs(ttl_secs),
807 init_ctx,
808 operator_kind,
809 bridge_id: op_req.senior_bridge_id,
810 hook_id: op_req.spawn_hook_id,
811 operator_backend_id: op_req.operator_backend_id,
812 operator_kind_overrides,
813 task_input: task_input_spec,
814 },
815 Some(run_ctx),
816 )
817 .await;
818
819 let out = tasks::finalize_run(state, &task_id, &run_id, outcome)
820 .await
821 .map_err(|e| ApiError::bad_request(format!("run: {e}")))?;
822
823 Ok(TaskLaunchResponse {
824 final_ctx: out.final_ctx,
825 bound_version: out.bound_version.map(|v| format!("{:?}", v)),
826 effective_ttl_secs: ttl_secs,
827 ttl_source,
828 task_id,
829 run_id,
830 })
831}
832
833/// issue #19 ST2 direct sibling-field resolver — extracts the three
834/// Task-level canonical fields (`project_root` / `work_dir` /
835/// `task_metadata`) once at the wire boundary. Sibling top-level body
836/// fields take priority; the pre-#19 shape (same key nested inside
837/// `init_ctx`) is only a fallback for legacy callers. Unlike the ST1
838/// `resolve_task_level_init_ctx` bridge this replaced, `init_ctx` is
839/// NOT mutated — the resolved values are handed straight to
840/// [`mlua_swarm::service::TaskLaunchInput::task_input`], keeping
841/// `init_ctx` a pure flow-ir eval seed.
842///
843/// Returns `None` when all three fields resolve to `None` (no
844/// middleware is layered onto the spawner stack downstream — the
845/// [`mlua_swarm::middleware::task_input::TaskInputMiddleware::new_from_fields`]
846/// contract).
847fn build_task_input_spec_from_request(
848 req: &TaskLaunchRequest,
849) -> Option<mlua_swarm::service::TaskInputSpec> {
850 let project_root = req.project_root.clone().or_else(|| {
851 req.init_ctx
852 .get("project_root")
853 .and_then(Value::as_str)
854 .map(String::from)
855 });
856 let work_dir = req.work_dir.clone().or_else(|| {
857 req.init_ctx
858 .get("work_dir")
859 .and_then(Value::as_str)
860 .map(String::from)
861 });
862 let task_metadata = req.task_metadata.clone().or_else(|| {
863 req.init_ctx
864 .get("task_metadata")
865 .filter(|v| v.is_object())
866 .cloned()
867 });
868
869 if project_root.is_none() && work_dir.is_none() && task_metadata.is_none() {
870 None
871 } else {
872 Some(mlua_swarm::service::TaskInputSpec {
873 project_root,
874 work_dir,
875 task_metadata,
876 })
877 }
878}
879
880// ─── helpers ─────────────────────────────────────────────────────────────
881
882async fn take_session_token(state: &AppState, sid: &str) -> Result<CapToken, ApiError> {
883 // `sid` on this path is the token nonce itself (a bearer secret), so
884 // both the map key and the not-found diagnostic use its fingerprint
885 // (issue #14 — never echo the nonce back in an error body).
886 let key = mlua_swarm::types::token_fingerprint(sid);
887 state
888 .sessions
889 .lock()
890 .await
891 .map
892 .remove(&key)
893 .ok_or_else(|| ApiError::not_found(format!("session: fp={key}")))
894}
895
896/// Extracts sid from `Authorization: Bearer <sid>`. Strict — does not accept any other scheme prefix.
897fn extract_bearer(headers: &HeaderMap) -> Result<String, ApiError> {
898 let v = headers
899 .get(AUTHORIZATION)
900 .ok_or_else(|| ApiError::bad_request("missing Authorization header".into()))?
901 .to_str()
902 .map_err(|_| ApiError::bad_request("invalid Authorization header encoding".into()))?;
903 let sid = v
904 .strip_prefix("Bearer ")
905 .ok_or_else(|| ApiError::bad_request("Authorization must be 'Bearer <sid>'".into()))?
906 .trim();
907 if sid.is_empty() {
908 return Err(ApiError::bad_request("Bearer sid is empty".into()));
909 }
910 Ok(sid.to_string())
911}
912
913fn parse_role(s: &str) -> Result<Role, ApiError> {
914 match s.to_ascii_lowercase().as_str() {
915 "operator" => Ok(Role::Operator),
916 "worker" => Ok(Role::Worker),
917 "observer" => Ok(Role::Observer),
918 "senior" => Ok(Role::Senior),
919 other => Err(ApiError::bad_request(format!("unknown role: {other}"))),
920 }
921}
922
923// ─── error type ──────────────────────────────────────────────────────────
924
925/// Uniform error response type for the handlers in this module. Converts to
926/// a JSON `{"error": message}` body with the given status via [`IntoResponse`].
927#[derive(Debug)]
928pub struct ApiError {
929 status: StatusCode,
930 message: String,
931}
932
933impl ApiError {
934 /// Wraps an engine-side error as `500 Internal Server Error`.
935 pub fn engine(e: impl std::fmt::Display) -> Self {
936 Self {
937 status: StatusCode::INTERNAL_SERVER_ERROR,
938 message: format!("engine: {e}"),
939 }
940 }
941 /// Builds a `404 Not Found` with the given message.
942 pub fn not_found(m: String) -> Self {
943 Self {
944 status: StatusCode::NOT_FOUND,
945 message: m,
946 }
947 }
948 /// Builds a `400 Bad Request` with the given message.
949 pub fn bad_request(m: String) -> Self {
950 Self {
951 status: StatusCode::BAD_REQUEST,
952 message: m,
953 }
954 }
955}
956
957impl IntoResponse for ApiError {
958 fn into_response(self) -> Response {
959 (self.status, Json(json!({"error": self.message}))).into_response()
960 }
961}
962
963fn default_run_ttl() -> u64 {
964 // 1800s (= 30 min). Prevents op_token expiry across a flow.ir multi-step chain
965 // (= 5+ SubAgent dispatches at 30–60s each). Origin: the observed fvloop smoke
966 // where a post-gate mock-commit dispatch blew past 300s and expired — sibling of worker_token TTL.
967 1800
968}
969
970/// TTL cascade resolve helper (Blueprint metadata → server default fallback).
971/// Second-stage fallback, called when the POST `/v1/tasks` body does not set `ttl_secs`.
972/// (1) If BP metadata `default_run_ttl_secs` is `Some`, use it.
973/// (2) If `None`, fall back to the server global `default_run_ttl()` (1800s).
974///
975/// # Full cascade (combined in `run_flow_form`)
976///
977/// - request body `ttl_secs=Some(v)` → v (this helper is not called)
978/// - request body `None` + metadata `Some(v)` → v
979/// - request body `None` + metadata `None` → `default_run_ttl()` = 1800s
980#[cfg(test)]
981fn resolve_ttl_from_metadata(metadata_ttl: Option<u64>) -> u64 {
982 metadata_ttl.unwrap_or_else(default_run_ttl)
983}
984
985#[cfg(test)]
986mod tests {
987 use super::*;
988
989 /// TTL cascade case 1: when the request body sets it, that value is used as-is
990 /// (upper branch that does not go through the helper; semantic verify of the
991 /// `Some(v) => v` direct-return path in `run_flow_form`).
992 #[test]
993 fn ttl_cascade_request_body_wins_over_metadata() {
994 let req_ttl: Option<u64> = Some(100);
995 let metadata_ttl: Option<u64> = Some(3600);
996 let effective = match req_ttl {
997 Some(v) => v,
998 None => resolve_ttl_from_metadata(metadata_ttl),
999 };
1000 assert_eq!(
1001 effective, 100,
1002 "request body ttl_secs=100 must win over metadata=3600 (cascade priority (1) > (2))"
1003 );
1004 }
1005
1006 /// TTL cascade case 2: request body omitted + BP metadata `Some(N)` → `N` is effective.
1007 #[test]
1008 fn ttl_cascade_metadata_used_when_body_missing() {
1009 let req_ttl: Option<u64> = None;
1010 let metadata_ttl: Option<u64> = Some(3600);
1011 let effective = match req_ttl {
1012 Some(v) => v,
1013 None => resolve_ttl_from_metadata(metadata_ttl),
1014 };
1015 assert_eq!(
1016 effective, 3600,
1017 "body None + metadata=3600 must resolve to 3600 (cascade (2))"
1018 );
1019 }
1020
1021 /// TTL cascade case 3: request body omitted + BP metadata `None` → server default (1800s).
1022 #[test]
1023 fn ttl_cascade_server_default_when_both_missing() {
1024 let req_ttl: Option<u64> = None;
1025 let metadata_ttl: Option<u64> = None;
1026 let effective = match req_ttl {
1027 Some(v) => v,
1028 None => resolve_ttl_from_metadata(metadata_ttl),
1029 };
1030 assert_eq!(
1031 effective,
1032 default_run_ttl(),
1033 "body None + metadata None must fall back to default_run_ttl() = 1800s"
1034 );
1035 assert_eq!(effective, 1800, "default_run_ttl() literal = 1800s");
1036 }
1037
1038 /// Helper unit: metadata `None` → 1800 (server default expansion).
1039 #[test]
1040 fn resolve_ttl_from_metadata_none_returns_server_default() {
1041 assert_eq!(resolve_ttl_from_metadata(None), 1800);
1042 }
1043
1044 /// Helper unit: metadata `Some(N)` → `N` (server default ignored).
1045 #[test]
1046 fn resolve_ttl_from_metadata_some_returns_value() {
1047 assert_eq!(resolve_ttl_from_metadata(Some(7200)), 7200);
1048 assert_eq!(resolve_ttl_from_metadata(Some(60)), 60);
1049 }
1050
1051 // ──────────────────────────────────────────────────────────────────
1052 // issue #19 ST2: `build_task_input_spec_from_request` direct resolver
1053 // ──────────────────────────────────────────────────────────────────
1054
1055 fn task_req(
1056 init_ctx: Value,
1057 project_root: Option<&str>,
1058 work_dir: Option<&str>,
1059 task_metadata: Option<Value>,
1060 ) -> TaskLaunchRequest {
1061 TaskLaunchRequest {
1062 blueprint: BlueprintRef::Id {
1063 id: mlua_swarm::blueprint::store::BlueprintId::new("ut"),
1064 version: Default::default(),
1065 },
1066 init_ctx,
1067 project_root: project_root.map(String::from),
1068 work_dir: work_dir.map(String::from),
1069 task_metadata,
1070 ttl_secs: None,
1071 operator: None,
1072 operator_sid: None,
1073 goal: None,
1074 }
1075 }
1076
1077 /// (a) Sibling fields only — no legacy keys in `init_ctx` — are
1078 /// returned in the `TaskInputSpec` unchanged. `init_ctx` itself is
1079 /// untouched by this resolver (checked separately at the call site).
1080 #[test]
1081 fn build_task_input_spec_from_request_returns_sibling_fields_when_present() {
1082 let req = task_req(
1083 json!({"free": "form"}),
1084 Some("/repo/sibling"),
1085 Some("/repo/sibling/work"),
1086 Some(json!({"issue": 19})),
1087 );
1088 let spec = build_task_input_spec_from_request(&req).expect("spec must be Some");
1089 assert_eq!(spec.project_root.as_deref(), Some("/repo/sibling"));
1090 assert_eq!(spec.work_dir.as_deref(), Some("/repo/sibling/work"));
1091 assert_eq!(spec.task_metadata, Some(json!({"issue": 19})));
1092 }
1093
1094 /// (b) No sibling fields — the pre-#19 shape (same 3 keys nested
1095 /// inside `init_ctx`) is used as the fallback source.
1096 #[test]
1097 fn build_task_input_spec_from_request_falls_back_to_legacy_init_ctx_shape() {
1098 let req = task_req(
1099 json!({
1100 "project_root": "/repo/legacy",
1101 "work_dir": "/repo/legacy/work",
1102 "task_metadata": {"issue": 17},
1103 }),
1104 None,
1105 None,
1106 None,
1107 );
1108 let spec = build_task_input_spec_from_request(&req).expect("spec must be Some");
1109 assert_eq!(spec.project_root.as_deref(), Some("/repo/legacy"));
1110 assert_eq!(spec.work_dir.as_deref(), Some("/repo/legacy/work"));
1111 assert_eq!(spec.task_metadata, Some(json!({"issue": 17})));
1112 }
1113
1114 /// (c) Both present — the sibling field must win over the legacy
1115 /// `init_ctx`-nested value.
1116 #[test]
1117 fn build_task_input_spec_from_request_sibling_wins_over_legacy_shape() {
1118 let req = task_req(
1119 json!({
1120 "project_root": "/repo/legacy",
1121 "work_dir": "/repo/legacy/work",
1122 "task_metadata": {"issue": 17},
1123 }),
1124 Some("/repo/sibling"),
1125 Some("/repo/sibling/work"),
1126 Some(json!({"issue": 19})),
1127 );
1128 let spec = build_task_input_spec_from_request(&req).expect("spec must be Some");
1129 assert_eq!(
1130 spec.project_root.as_deref(),
1131 Some("/repo/sibling"),
1132 "sibling field must win over the legacy init_ctx-nested value"
1133 );
1134 assert_eq!(spec.work_dir.as_deref(), Some("/repo/sibling/work"));
1135 assert_eq!(spec.task_metadata, Some(json!({"issue": 19})));
1136 }
1137
1138 /// (d) All three fields absent from both sibling and legacy shapes —
1139 /// resolver returns `None`, and no middleware is layered downstream.
1140 #[test]
1141 fn build_task_input_spec_from_request_returns_none_when_no_fields_present() {
1142 let req = task_req(json!({"unrelated": "value"}), None, None, None);
1143 assert!(build_task_input_spec_from_request(&req).is_none());
1144 }
1145}