Skip to main content

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