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//! - `POST /v1/operators` / `GET /v1/operators/:sid` / `DELETE /v1/operators/:sid` /
11//!   `GET /v1/operators/:sid/ws` (WS upgrade) — REST-like Operator login flow,
12//!   Bearer-mandatory; the sole WS Operator session route. See `operator_ws::login`
13//!   module doc.
14//!
15//! The Enhance issue axis (`/issues`) lives in the `issues` module; callers merge
16//! `build_issues_router` to integrate it into the same server.
17//!
18//! # The 3 faces of the Operator role (= registered directly on the engine SoT)
19//!
20//! The engine stateless-executor refactor removed the three
21//! `AppState` registries (former `HookRegistry` / `BridgeRegistry` / `OperatorRegistry`);
22//! all registration now goes directly to the engine SoT via
23//! `engine.register_spawn_hook` / `register_senior_bridge` / `register_operator`.
24//! `WSOperatorSession` (in the `operator_ws` module) registers all three traits
25//! simultaneously under a single sid — one WS connection covers all 3 faces of
26//! the Operator role, the canonical pattern.
27//!
28//! # `build_*` family
29//!
30//! - [`build_router`] — minimal entry (= `default_registry()`)
31//! - [`build_router_with`] — caller provides a `SpawnerRegistry` and optional `BlueprintStore`
32//!
33//! The engine should be started with [`default_layer_registry`] (= `Engine::new_with_layers`);
34//! otherwise `Blueprint.spawner_hints` is ignored.
35
36#![warn(missing_docs)]
37
38/// HTTP surface for inspecting/registering Blueprint state (`/v1/blueprints/*`).
39pub mod blueprints;
40/// Server config file support (`~/.mse/config.toml`, CLI > file > default merge).
41pub mod config;
42/// `/v1/data/*` endpoints (v9 Big Response handling, Store-owner direct path).
43pub mod data;
44/// `GET /v1/doctor` — read-only startup config / Store snapshot.
45pub mod doctor;
46/// HTTP surface for the `/v1/enhance/log` axis.
47pub mod enhance_log;
48/// `EnhanceSetting` HTTP CRUD (`/v1/enhance-settings*`).
49pub mod enhance_settings;
50/// HTTP surface for the Enhance issue axis (`/v1/issues*`).
51pub mod issues;
52/// WebSocket Operator Callback IF (`/v1/operators*`).
53pub mod operator_ws;
54/// `/v1/worker/*` endpoints (SubAgent self-fetch path).
55pub mod worker;
56pub use blueprints::{build_blueprints_router, build_blueprints_router_with_refs};
57pub use enhance_log::build_enhance_log_router;
58pub use enhance_settings::build_enhance_settings_router;
59pub use issues::{build_issues_router, GetIssueResponse, PostIssueRequest, PostIssueResponse};
60pub use operator_ws::{
61    operators_create, operators_delete, operators_info, operators_ws_connect, ClientMsg,
62    OperatorSessionEntry, ServerMsg, WSOperatorSession,
63};
64pub use worker::{worker_prompt, worker_result, PromptQuery, WorkerResultReq};
65
66use axum::{
67    extract::State,
68    http::{header::AUTHORIZATION, HeaderMap, StatusCode},
69    response::{IntoResponse, Response},
70    routing::{get, post},
71    Json, Router,
72};
73use mlua_swarm::application::{BlueprintRef, TaskApplication};
74use mlua_swarm::blueprint::store::BlueprintStore;
75use mlua_swarm::service::TaskLaunchService;
76use mlua_swarm::{
77    CapToken, Compiler, Engine, LayerRegistry, LuaInProcessSpawnerFactory, MainAIMiddleware,
78    OperatorDelegateMiddleware, OperatorSpawnerFactory, Role, RustFnInProcessSpawnerFactory,
79    SeniorEscalationMiddleware, SpawnerRegistry, SubprocessProcessSpawnerFactory,
80};
81use serde::{Deserialize, Serialize};
82use serde_json::{json, Value};
83use std::collections::HashMap;
84use std::sync::Arc;
85use std::time::Duration;
86use tokio::sync::Mutex;
87
88/// In-memory `sid → CapToken` map backing `/v1/sessions` attach/detach.
89#[derive(Default)]
90pub struct SessionStore {
91    /// Live session tokens keyed by `sid` (the token's `nonce`).
92    pub map: HashMap<String, CapToken>,
93}
94
95/// Shared axum handler state for the whole router. Cloned per-request (all
96/// fields are `Arc`/cheap-clone), constructed once in [`build_router_with_ws_factory`].
97#[derive(Clone)]
98pub struct AppState {
99    /// The engine SoT (attach/detach, dispatch, registries).
100    pub engine: Engine,
101    /// Live `/v1/sessions` attach records (Operator/Worker/etc session tokens).
102    pub sessions: Arc<Mutex<SessionStore>>,
103    /// Application used at the task entry to resolve `BlueprintRef`. Without a Store, runs in Inline-only mode.
104    pub task_app: Arc<TaskApplication>,
105    /// When `Some`, on WS connect a new `WSOperatorSession` is automatically registered
106    /// with this factory under the sid name (= a `kind=operator` + `operator_ref=<sid>` AgentDef
107    /// binds to the `WSOperatorSession` backend).
108    /// When `None`, no auto-registration happens; the session is only registered on
109    /// `engine.OperatorRegistry` (= only the `OperatorDelegateMiddleware` path is effective;
110    /// the `OperatorSpawnerFactory` path is dead).
111    pub ws_operator_factory: Option<Arc<OperatorSpawnerFactory>>,
112    /// Owner of the Store on the Data path (Big Response handling). Added in v9.
113    /// Independent layer — the Engine core and the Domain path (`/v1/worker/result`)
114    /// are not involved.
115    /// Default = `InMemoryOutputStore` (constructed inside `build_router_with_ws_factory`);
116    /// callers can swap in an sqlite/fs backend later (future carry).
117    pub data_store: Arc<dyn mlua_swarm::store::output::OutputStore>,
118    /// Login-flow session store (`POST /v1/operators` mint records). `sid` →
119    /// `OperatorSessionEntry`. This is the sole session store for the WS
120    /// Operator role. See `operator_ws::login` module doc.
121    pub operator_sessions:
122        Arc<Mutex<HashMap<String, Arc<crate::operator_ws::login::OperatorSessionEntry>>>>,
123    /// S1 login-flow roles-exclusivity map. Role name → owning `sid`. Checked
124    /// (and updated) atomically under a single lock in
125    /// `operator_ws::login::operators_create` — a role already present here
126    /// causes `POST /v1/operators` to return `409 CONFLICT`. Entries are
127    /// released on `DELETE /v1/operators/:sid`.
128    pub roles_to_sid: Arc<Mutex<HashMap<String, String>>>,
129    /// Public HTTP base URL the server is reachable at (e.g.
130    /// `"http://127.0.0.1:7777"`), sourced from the binary at boot time.
131    /// When `Some`, `WSOperatorSession` renders it literally into the
132    /// Spawn `directive`'s `base_url` line so the receiving operator can
133    /// paste the frame into a SubAgent prompt without a `mse_doctor`
134    /// detour (issue #8). `None` preserves the historical fallback
135    /// (a placeholder that points at `mse_doctor`).
136    pub base_url: Option<Arc<str>>,
137}
138
139/// Minimal entry point: builds a router with [`default_registry`] and no
140/// `BlueprintStore` (Inline-only mode) or `ws_operator_factory`.
141pub fn build_router(engine: Engine) -> Router {
142    build_router_with(engine, default_registry(), None)
143}
144
145/// Default `LayerRegistry` for the server. Hint keys:
146/// - `"main_ai"` → `MainAIMiddleware` (= fires SpawnHook before/after)
147/// - `"senior_escalation"` → `SeniorEscalationMiddleware` (= on `ok=false`, escalates via `SeniorBridge.ask`)
148/// - `"operator_delegate"` → `OperatorDelegateMiddleware` (= when an operator backend is registered, delegates the entire spawn)
149///
150/// Including any of these keys in `Blueprint.spawner_hints.layers` causes them to
151/// be wrapped into a `SpawnerStack` at `service::linker::link` time (= per-launch;
152/// the old `engine.bind` global-state path is retired).
153/// Callers (the engine builder side) receive it via
154/// `Engine::new_with_layers(cfg, mse_server::default_layer_registry())`.
155pub fn default_layer_registry() -> LayerRegistry {
156    LayerRegistry::new()
157        .with_hint("main_ai", |_engine| Arc::new(MainAIMiddleware::new()))
158        .with_hint("senior_escalation", |_engine| {
159            Arc::new(SeniorEscalationMiddleware::new())
160        })
161        .with_hint("operator_delegate", |_engine| {
162            Arc::new(OperatorDelegateMiddleware::new())
163        })
164}
165
166/// Build form where the caller supplies a registry and an optional `BlueprintStore`.
167/// The Operator callback path (= external HTTP / WS callers acting as an Operator)
168/// must be pre-registered via `engine.register_*` (= the engine is the SoT).
169/// See the `operator_ws` module doc and `OperatorInfo` (engine-side `ctx.rs`) for details.
170pub fn build_router_with(
171    engine: Engine,
172    registry: SpawnerRegistry,
173    store: Option<Arc<dyn BlueprintStore>>,
174) -> Router {
175    build_router_with_ws_factory(engine, registry, store, None)
176}
177
178/// 4-argument variant of `build_router_with`. Passing `ws_operator_factory = Some(arc)`
179/// causes each WS connect to auto-register a new `WSOperatorSession` under its sid
180/// name with the factory (= a `kind=operator` AgentDef with `operator_ref: <sid>`
181/// can then bind to the WS client backend). Callers are expected to also install
182/// the same `Arc` into the `SpawnerRegistry` via
183/// `reg.register::<OperatorSpawnerFactory>(arc.clone())`.
184pub fn build_router_with_ws_factory(
185    engine: Engine,
186    registry: SpawnerRegistry,
187    store: Option<Arc<dyn BlueprintStore>>,
188    ws_operator_factory: Option<Arc<OperatorSpawnerFactory>>,
189) -> Router {
190    build_router_with_ws_factory_and_output(engine, registry, store, ws_operator_factory, None)
191}
192
193/// 5-argument variant of [`build_router_with_ws_factory`]. Passing
194/// `output_store = Some(arc)` swaps the default `InMemoryOutputStore` for a
195/// caller-supplied backend (a `SqliteOutputStore`, for instance). `None`
196/// preserves the historical behaviour (fresh in-memory store per call).
197pub fn build_router_with_ws_factory_and_output(
198    engine: Engine,
199    registry: SpawnerRegistry,
200    store: Option<Arc<dyn BlueprintStore>>,
201    ws_operator_factory: Option<Arc<OperatorSpawnerFactory>>,
202    output_store: Option<Arc<dyn mlua_swarm::store::output::OutputStore>>,
203) -> Router {
204    build_router_full(
205        engine,
206        registry,
207        store,
208        ws_operator_factory,
209        output_store,
210        None,
211    )
212}
213
214/// 6-argument variant of [`build_router_with_ws_factory_and_output`].
215/// Passing `base_url = Some(...)` (e.g. `"http://127.0.0.1:7777"`) makes
216/// `WSOperatorSession` render the actual server bind into the Spawn
217/// directive's `base_url` line, so the receiving operator can copy the
218/// frame straight into a SubAgent prompt (issue #8). `None` preserves
219/// the historical fallback (`<check with mse_doctor>` placeholder).
220pub fn build_router_full(
221    engine: Engine,
222    registry: SpawnerRegistry,
223    store: Option<Arc<dyn BlueprintStore>>,
224    ws_operator_factory: Option<Arc<OperatorSpawnerFactory>>,
225    output_store: Option<Arc<dyn mlua_swarm::store::output::OutputStore>>,
226    base_url: Option<Arc<str>>,
227) -> Router {
228    let compiler = Compiler::new(registry);
229    let launch = Arc::new(TaskLaunchService::new(engine.clone(), compiler));
230    let task_app = Arc::new(match store {
231        Some(s) => TaskApplication::new(launch, s),
232        None => TaskApplication::new_inline_only(launch),
233    });
234    let data_store: Arc<dyn mlua_swarm::store::output::OutputStore> = match output_store {
235        Some(s) => s,
236        None => Arc::new(mlua_swarm::store::output::InMemoryOutputStore::new()),
237    };
238    let state = AppState {
239        engine,
240        sessions: Arc::new(Mutex::new(SessionStore::default())),
241        task_app,
242        ws_operator_factory,
243        data_store,
244        operator_sessions: Arc::new(Mutex::new(HashMap::new())),
245        roles_to_sid: Arc::new(Mutex::new(HashMap::new())),
246        base_url,
247    };
248    Router::new()
249        .route("/v1/healthz", get(healthz))
250        // session = collection (POST = attach, DELETE = detach, sid via Authorization)
251        .route(
252            "/v1/sessions",
253            post(sessions_attach).delete(sessions_detach),
254        )
255        // task = flat, single level; authz resolved via Authorization: Bearer <sid>
256        .route("/v1/tasks", post(tasks_start))
257        // REST-like Operator login flow (Bearer-mandatory, roles exclusivity).
258        // Sole WS Operator session route; see `operator_ws::login` module doc.
259        .route("/v1/operators", post(operators_create))
260        .route("/v1/operators/:sid/ws", get(operators_ws_connect))
261        .route(
262            "/v1/operators/:sid",
263            get(operators_info).delete(operators_delete),
264        )
265        // SubAgent self-fetch path (the SubAgent self-fetch design). The SubAgent puts the
266        // CapToken handed over via WS Spawn into Bearer and hits the prompt / result
267        // endpoints directly over HTTP. See the `worker` module doc for details.
268        .route("/v1/worker/prompt", get(worker::worker_prompt))
269        .route("/v1/worker/result", post(worker::worker_result))
270        // Simplified endpoint (= worker POSTs with just token + raw body; task_id is auto-looked-up)
271        .route("/v1/worker/submit", post(worker::worker_submit))
272        // Data path (v9 Big Response handling, independent from Domain / verdict flow)
273        .route("/v1/data/emit", post(data::data_emit))
274        .route(
275            "/v1/data/:key",
276            get(data::data_get).post(data::data_emit_named),
277        )
278        .with_state(state)
279}
280
281/// Default registry = Subprocess + RustFn (baseline `identity` worker pre-baked) + empty Operator factory.
282///
283/// `RustFnInProcessSpawnerFactory` gets one baseline entry (`fn_id = "identity"`)
284/// baked in via [`mlua_swarm::worker::baseline::extend_with_baseline`]. This
285/// is the shared bootstrap / smoke worker SoT across each binary (the server / MCP adapter /
286/// one-shot runner) — it structurally replaces the old per-binary inline echo injection.
287///
288/// Usage: default Task path at server startup. If production needs additional
289/// backends, callers bring in a different registry via
290/// `build_router_with(engine, custom_registry)`. The enhance flow
291/// (= patch-spawner / patch-applier / verifier-router / committer axes) uses
292/// [`default_registry_with_enhance_flow`].
293///
294/// The Operator factory is an empty shell with zero registrations (= sids are
295/// dynamically registered per WS connect; see the `operator_ws` module).
296pub fn default_registry() -> SpawnerRegistry {
297    let rustfn_factory =
298        mlua_swarm::worker::baseline::extend_with_baseline(RustFnInProcessSpawnerFactory::new());
299
300    let mut reg = SpawnerRegistry::new();
301    reg.register::<SubprocessProcessSpawnerFactory>(Arc::new(SubprocessProcessSpawnerFactory));
302    reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(rustfn_factory));
303    reg.register::<OperatorSpawnerFactory>(Arc::new(OperatorSpawnerFactory::new()));
304    reg
305}
306
307/// Opt-in registry that merges [`default_registry`] with the enhance flow
308/// (Lua factory + AgentBlock factory).
309///
310/// Selected via the `the server` CLI flag `--enable-enhance-flow`. The enhance
311/// flow is a separate-axis wrapper: the Lua factory (= 3 Lua workers + 3 primitive
312/// bridges) and the AgentBlock factory (= patch-spawner path, expects
313/// `assets/operator_scripts/blueprint_patch_spawner.lua` + `ANTHROPIC_API_KEY`)
314/// are baked in as pipeline defaults. The baseline RustFn (`identity`) is pre-baked
315/// the same way as in `default_registry`.
316pub fn default_registry_with_enhance_flow() -> SpawnerRegistry {
317    let lua_factory =
318        mlua_swarm::enhance::blueprint::extend_factory(LuaInProcessSpawnerFactory::new());
319    // The Factory is stateless (= 1 process → 1 factory shared by all AgentDefs).
320    // Per-agent specialization (script_path / project_root, etc.) goes through AgentDef.spec.
321    // The enhance-flow patch-spawner is declared literally in agents[].spec of `default_blueprint.yaml`.
322    let agent_block_factory =
323        mlua_swarm::worker::agent_block::AgentBlockInProcessSpawnerFactory::new();
324    let rustfn_factory =
325        mlua_swarm::worker::baseline::extend_with_baseline(RustFnInProcessSpawnerFactory::new());
326
327    let mut reg = SpawnerRegistry::new();
328    reg.register::<SubprocessProcessSpawnerFactory>(Arc::new(SubprocessProcessSpawnerFactory));
329    reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(rustfn_factory));
330    reg.register::<LuaInProcessSpawnerFactory>(Arc::new(lua_factory));
331    reg.register::<mlua_swarm::worker::agent_block::AgentBlockInProcessSpawnerFactory>(Arc::new(
332        agent_block_factory,
333    ));
334    reg.register::<OperatorSpawnerFactory>(Arc::new(OperatorSpawnerFactory::new()));
335    reg
336}
337
338// ─── handlers ────────────────────────────────────────────────────────────
339
340async fn healthz() -> &'static str {
341    "ok"
342}
343
344#[derive(Deserialize)]
345struct AttachReq {
346    agent_id: String,
347    role: String,
348    ttl_secs: u64,
349}
350
351#[derive(Serialize)]
352struct AttachResp {
353    session_id: String,
354    role: String,
355}
356
357async fn sessions_attach(
358    State(state): State<AppState>,
359    Json(req): Json<AttachReq>,
360) -> Result<Json<AttachResp>, ApiError> {
361    let role = parse_role(&req.role)?;
362    let token = state
363        .engine
364        .attach(req.agent_id, role, Duration::from_secs(req.ttl_secs))
365        .await
366        .map_err(ApiError::engine)?;
367    let sid = token.nonce.clone();
368    state.sessions.lock().await.map.insert(sid.clone(), token);
369    Ok(Json(AttachResp {
370        session_id: sid,
371        role: req.role,
372    }))
373}
374
375async fn sessions_detach(
376    State(state): State<AppState>,
377    headers: HeaderMap,
378) -> Result<StatusCode, ApiError> {
379    let sid = extract_bearer(&headers)?;
380    let token = take_session_token(&state, &sid).await?;
381    state
382        .engine
383        .detach(&token)
384        .await
385        .map_err(ApiError::engine)?;
386    Ok(StatusCode::NO_CONTENT)
387}
388
389// ─── Unified /v1/tasks schema (= flow-eval path, Operator inject supported) ───────
390
391/// `/v1/tasks` POST schema. Uses the flow-eval path and supports Operator inject
392/// (kind / spawn_hook / senior_bridge). Expressing a one-shot task as a 1-Step
393/// Blueprint is the only correct model.
394#[derive(Deserialize)]
395struct FlowTasksReq {
396    blueprint: BlueprintRef,
397    init_ctx: Value,
398    /// TTL in seconds. When unspecified (`None`), falls back in this order:
399    /// (1) `metadata.default_run_ttl_secs` from the resolved BP,
400    /// (2) if absent, the server global `default_run_ttl()` (1800s).
401    #[serde(default)]
402    ttl_secs: Option<u64>,
403    #[serde(default)]
404    operator: Option<OperatorReq>,
405    /// Explicit Operator session sid (or role alias) this task's entire Spawn
406    /// stream should be routed to (runtime Operator match stage 1).
407    ///
408    /// When `Some`, it is validated at request time against
409    /// `state.engine.list_operator_ids()` (the live `engine.operators`
410    /// registry key set): an unknown/never-registered id returns `400`
411    /// immediately — this is a deliberate hard-fail, in contrast to
412    /// `OperatorDelegateWrapped::spawn`, which silently falls through to
413    /// `inner.spawn` on a registry miss. A sid that *was* registered but has
414    /// since disconnected (WS `tx` cleared, session entry retained for
415    /// reconnect) passes this check and surfaces as an explicit dispatch-time
416    /// error instead (`WSOperatorSession::send_and_await` returns `Err` when
417    /// `tx` is `None`), which also propagates as a request failure rather
418    /// than a silent fallback.
419    ///
420    /// On success this value **overrides** `operator.operator_backend_id`
421    /// (last-write-wins, `operator_sid` takes priority) before the flow is
422    /// dispatched — see `run_flow_form`. Dispatch still only delegates if the
423    /// Blueprint opts into `spawner_hints.layers = ["operator_delegate"]`
424    /// (unchanged precondition, same as the existing `operator_backend_id`
425    /// field).
426    ///
427    /// When unset, behavior is unchanged: whatever
428    /// `operator.operator_backend_id` / BP-level `operator_ref` alias
429    /// resolution already does still applies.
430    #[serde(default)]
431    operator_sid: Option<String>,
432}
433
434#[derive(Deserialize, Default)]
435struct OperatorReq {
436    /// `main_ai` / `automate` / `composite`. This is the "Runtime Global"
437    /// tier of the 4-tier `OperatorKind` cascade (see `mlua_swarm
438    /// ::ctx::collapse_operator_kind`); when unspecified, falls through to
439    /// the BP-level tiers (`OperatorDef.kind` / `Blueprint
440    /// .default_operator_kind`) instead of eagerly defaulting to `automate`.
441    #[serde(default)]
442    kind: Option<String>,
443    /// Operator id at attach time (= sessions tracking key in the EventLog); unspecified defaults to `"http-run"`.
444    #[serde(default)]
445    id: Option<String>,
446    /// Name of a hook pre-registered via `engine.register_spawn_hook`; `None` if unspecified.
447    #[serde(default)]
448    spawn_hook_id: Option<String>,
449    /// Name of a bridge pre-registered via `engine.register_senior_bridge`; `None` if unspecified.
450    #[serde(default)]
451    senior_bridge_id: Option<String>,
452    /// Name of an Operator backend pre-registered via `engine.register_operator`
453    /// (= the path that delegates the entire spawn to an external Operator);
454    /// `None` if unspecified. When `kind == MainAi/Composite` and this id is `Some`,
455    /// `OperatorDelegateMiddleware` bypasses `inner.spawn` and calls `operator.execute` instead.
456    /// This is a different axis from `operator.id` (= session tracking label);
457    /// `operator_backend_id` is the registry lookup key.
458    #[serde(default)]
459    operator_backend_id: Option<String>,
460    /// "Runtime Agent-level" tier (highest priority) of the `OperatorKind`
461    /// cascade — per-agent override, keyed by `AgentDef.name`, value is
462    /// `main_ai` / `automate` / `composite` (same parsing as `kind`).
463    /// `None` / absent means no per-agent override.
464    #[serde(default)]
465    per_agent_kinds: Option<HashMap<String, String>>,
466}
467
468/// Parse a wire-level kind string (`"main_ai"` / `"automate"` / `"composite"`)
469/// into `OperatorKind`. Shared by `OperatorReq.kind` and
470/// `OperatorReq.per_agent_kinds` values.
471fn parse_operator_kind_str(s: &str) -> Result<mlua_swarm::OperatorKind, ApiError> {
472    use mlua_swarm::OperatorKind;
473    match s {
474        "main_ai" => Ok(OperatorKind::MainAi),
475        "composite" => Ok(OperatorKind::Composite),
476        "automate" => Ok(OperatorKind::Automate),
477        other => Err(ApiError::bad_request(format!(
478            "operator kind: unknown value '{other}' (expected main_ai|automate|composite)"
479        ))),
480    }
481}
482
483#[derive(Serialize)]
484struct FlowTasksResp {
485    final_ctx: Value,
486    bound_version: Option<String>,
487    /// Resolved TTL (seconds) actually applied to the run. Exposes the
488    /// 3-layer cascade (request body → BP metadata → server default) so
489    /// clients can verify which value took effect without re-deriving it.
490    effective_ttl_secs: u64,
491    ttl_source: TtlSource,
492}
493
494#[derive(Serialize, Clone, Copy, Debug, PartialEq, Eq)]
495#[serde(rename_all = "snake_case")]
496enum TtlSource {
497    RequestBody,
498    BpMetadata,
499    ServerDefault,
500}
501
502/// Unified `/v1/tasks` POST entry (= Flow form only).
503/// Runs `Blueprint.flow` to completion via flow eval in a single round-trip.
504/// One-shot tasks are also expressed as a 1-Step Blueprint. Operator
505/// (kind / spawn_hook / senior_bridge) can be injected per request body.
506/// `operator_sid` (S2, runtime Operator match stage 1) additionally
507/// lets the caller pin the task to a specific already-registered Operator
508/// session sid, bypassing BP-level alias lookup — see `FlowTasksReq` doc.
509async fn tasks_start(
510    State(state): State<AppState>,
511    Json(req): Json<FlowTasksReq>,
512) -> Result<Json<FlowTasksResp>, ApiError> {
513    let resp = run_flow_form(&state, req).await?;
514    Ok(Json(resp))
515}
516
517/// Flow-form path (= via `TaskApplication.handle`).
518/// Core handler behind the `/v1/tasks` entry (`tasks_start`).
519///
520/// Engine stateless-executor refactor: the per-request
521/// sub_engine + 3-registry propagate loop is retired; the startup-built
522/// `state.task_app` (= a `TaskLaunchService` wrap around `state.engine`) is
523/// used directly. The Operator callback IF (`spawn_hook_id` /
524/// `senior_bridge_id` / `operator_backend_id`) is registered on
525/// `state.engine.register_*` at WS connect time — the engine is the SoT.
526/// See the `operator_ws` module doc for details.
527async fn run_flow_form(state: &AppState, req: FlowTasksReq) -> Result<FlowTasksResp, ApiError> {
528    use mlua_swarm::application::{BlueprintRef as AppBlueprintRef, TaskApplicationInput};
529    use mlua_swarm::Application;
530    use mlua_swarm::OperatorKind;
531
532    let mut op_req = req.operator.unwrap_or_default();
533
534    // S2: explicit `operator_sid` override (runtime Operator match stage 1).
535    // Resolved *before* building `operator_kind` / dispatching so an
536    // unknown sid fails fast with a 400, never silently falling back to the
537    // BP-level alias lookup. See `FlowTasksReq::operator_sid` doc for the
538    // disconnected-vs-unknown distinction.
539    if let Some(sid) = &req.operator_sid {
540        let known_ids = state.engine.list_operator_ids().await;
541        if !known_ids.iter().any(|id| id == sid) {
542            return Err(ApiError::bad_request(format!(
543                "operator_sid: no such registered operator session '{sid}'"
544            )));
545        }
546        op_req.operator_backend_id = Some(sid.clone());
547    }
548
549    // "Runtime Global" tier: `Some(_)` — including `Some(Automate)` — is
550    // always an explicit request that outranks the BP-level tiers; an
551    // absent/unset `kind` in the request body stays `None`, leaving the
552    // BP-level tiers (`OperatorDef.kind` / `Blueprint.default_operator_kind`)
553    // to decide instead of eagerly defaulting to `Automate`.
554    let operator_kind = op_req
555        .kind
556        .as_deref()
557        .map(parse_operator_kind_str)
558        .transpose()?;
559    let operator_id = op_req.id.unwrap_or_else(|| "http-run".to_string());
560    // "Runtime Agent-level" tier: per-agent overrides. Absent/empty = no
561    // override for any agent, letting the BP-level tiers decide per agent.
562    let mut operator_kind_overrides: HashMap<String, OperatorKind> = HashMap::new();
563    for (agent, kind_str) in op_req.per_agent_kinds.take().unwrap_or_default() {
564        operator_kind_overrides.insert(agent, parse_operator_kind_str(&kind_str)?);
565    }
566
567    let blueprint: AppBlueprintRef = match req.blueprint {
568        AppBlueprintRef::Inline { value } => AppBlueprintRef::Inline { value },
569        AppBlueprintRef::Id { id, version } => AppBlueprintRef::Id { id, version },
570    };
571
572    // TTL resolution cascade: (1) request body value, (2) BP metadata `default_run_ttl_secs`,
573    // (3) server global default (`default_run_ttl()`, 1800s).
574    let (ttl_secs, ttl_source) = match req.ttl_secs {
575        Some(v) => (v, TtlSource::RequestBody),
576        None => {
577            let (resolved_bp, _ver) = state
578                .task_app
579                .resolve(&blueprint)
580                .await
581                .map_err(|e| ApiError::bad_request(format!("bp resolve: {e}")))?;
582            match resolved_bp.metadata.default_run_ttl_secs {
583                Some(v) => (v, TtlSource::BpMetadata),
584                None => (default_run_ttl(), TtlSource::ServerDefault),
585            }
586        }
587    };
588
589    let out = state
590        .task_app
591        .handle(TaskApplicationInput {
592            blueprint,
593            operator_id: operator_id.clone(),
594            role: Role::Operator,
595            ttl: Duration::from_secs(ttl_secs),
596            init_ctx: req.init_ctx,
597            operator_kind,
598            bridge_id: op_req.senior_bridge_id,
599            hook_id: op_req.spawn_hook_id,
600            operator_backend_id: op_req.operator_backend_id,
601            operator_kind_overrides,
602        })
603        .await
604        .map_err(|e| ApiError::bad_request(format!("run: {e}")))?;
605
606    Ok(FlowTasksResp {
607        final_ctx: out.final_ctx,
608        bound_version: out.bound_version.map(|v| format!("{:?}", v)),
609        effective_ttl_secs: ttl_secs,
610        ttl_source,
611    })
612}
613
614// ─── helpers ─────────────────────────────────────────────────────────────
615
616async fn take_session_token(state: &AppState, sid: &str) -> Result<CapToken, ApiError> {
617    state
618        .sessions
619        .lock()
620        .await
621        .map
622        .remove(sid)
623        .ok_or_else(|| ApiError::not_found(format!("session: {sid}")))
624}
625
626/// Extracts sid from `Authorization: Bearer <sid>`. Strict — does not accept any other scheme prefix.
627fn extract_bearer(headers: &HeaderMap) -> Result<String, ApiError> {
628    let v = headers
629        .get(AUTHORIZATION)
630        .ok_or_else(|| ApiError::bad_request("missing Authorization header".into()))?
631        .to_str()
632        .map_err(|_| ApiError::bad_request("invalid Authorization header encoding".into()))?;
633    let sid = v
634        .strip_prefix("Bearer ")
635        .ok_or_else(|| ApiError::bad_request("Authorization must be 'Bearer <sid>'".into()))?
636        .trim();
637    if sid.is_empty() {
638        return Err(ApiError::bad_request("Bearer sid is empty".into()));
639    }
640    Ok(sid.to_string())
641}
642
643fn parse_role(s: &str) -> Result<Role, ApiError> {
644    match s.to_ascii_lowercase().as_str() {
645        "operator" => Ok(Role::Operator),
646        "worker" => Ok(Role::Worker),
647        "observer" => Ok(Role::Observer),
648        "senior" => Ok(Role::Senior),
649        other => Err(ApiError::bad_request(format!("unknown role: {other}"))),
650    }
651}
652
653// ─── error type ──────────────────────────────────────────────────────────
654
655/// Uniform error response type for the handlers in this module. Converts to
656/// a JSON `{"error": message}` body with the given status via [`IntoResponse`].
657#[derive(Debug)]
658pub struct ApiError {
659    status: StatusCode,
660    message: String,
661}
662
663impl ApiError {
664    /// Wraps an engine-side error as `500 Internal Server Error`.
665    pub fn engine(e: impl std::fmt::Display) -> Self {
666        Self {
667            status: StatusCode::INTERNAL_SERVER_ERROR,
668            message: format!("engine: {e}"),
669        }
670    }
671    /// Builds a `404 Not Found` with the given message.
672    pub fn not_found(m: String) -> Self {
673        Self {
674            status: StatusCode::NOT_FOUND,
675            message: m,
676        }
677    }
678    /// Builds a `400 Bad Request` with the given message.
679    pub fn bad_request(m: String) -> Self {
680        Self {
681            status: StatusCode::BAD_REQUEST,
682            message: m,
683        }
684    }
685}
686
687impl IntoResponse for ApiError {
688    fn into_response(self) -> Response {
689        (self.status, Json(json!({"error": self.message}))).into_response()
690    }
691}
692
693fn default_run_ttl() -> u64 {
694    // 1800s (= 30 min). Prevents op_token expiry across a flow.ir multi-step chain
695    // (= 5+ SubAgent dispatches at 30–60s each). Origin: the observed fvloop smoke
696    // where a post-gate mock-commit dispatch blew past 300s and expired — sibling of worker_token TTL.
697    1800
698}
699
700/// TTL cascade resolve helper (Blueprint metadata → server default fallback).
701/// Second-stage fallback, called when the POST `/v1/tasks` body does not set `ttl_secs`.
702/// (1) If BP metadata `default_run_ttl_secs` is `Some`, use it.
703/// (2) If `None`, fall back to the server global `default_run_ttl()` (1800s).
704///
705/// # Full cascade (combined in `run_flow_form`)
706///
707/// - request body `ttl_secs=Some(v)` → v (this helper is not called)
708/// - request body `None` + metadata `Some(v)` → v
709/// - request body `None` + metadata `None` → `default_run_ttl()` = 1800s
710#[cfg(test)]
711fn resolve_ttl_from_metadata(metadata_ttl: Option<u64>) -> u64 {
712    metadata_ttl.unwrap_or_else(default_run_ttl)
713}
714
715#[cfg(test)]
716mod tests {
717    use super::*;
718
719    /// TTL cascade case 1: when the request body sets it, that value is used as-is
720    /// (upper branch that does not go through the helper; semantic verify of the
721    /// `Some(v) => v` direct-return path in `run_flow_form`).
722    #[test]
723    fn ttl_cascade_request_body_wins_over_metadata() {
724        let req_ttl: Option<u64> = Some(100);
725        let metadata_ttl: Option<u64> = Some(3600);
726        let effective = match req_ttl {
727            Some(v) => v,
728            None => resolve_ttl_from_metadata(metadata_ttl),
729        };
730        assert_eq!(
731            effective, 100,
732            "request body ttl_secs=100 must win over metadata=3600 (cascade priority (1) > (2))"
733        );
734    }
735
736    /// TTL cascade case 2: request body omitted + BP metadata `Some(N)` → `N` is effective.
737    #[test]
738    fn ttl_cascade_metadata_used_when_body_missing() {
739        let req_ttl: Option<u64> = None;
740        let metadata_ttl: Option<u64> = Some(3600);
741        let effective = match req_ttl {
742            Some(v) => v,
743            None => resolve_ttl_from_metadata(metadata_ttl),
744        };
745        assert_eq!(
746            effective, 3600,
747            "body None + metadata=3600 must resolve to 3600 (cascade (2))"
748        );
749    }
750
751    /// TTL cascade case 3: request body omitted + BP metadata `None` → server default (1800s).
752    #[test]
753    fn ttl_cascade_server_default_when_both_missing() {
754        let req_ttl: Option<u64> = None;
755        let metadata_ttl: Option<u64> = None;
756        let effective = match req_ttl {
757            Some(v) => v,
758            None => resolve_ttl_from_metadata(metadata_ttl),
759        };
760        assert_eq!(
761            effective,
762            default_run_ttl(),
763            "body None + metadata None must fall back to default_run_ttl() = 1800s"
764        );
765        assert_eq!(effective, 1800, "default_run_ttl() literal = 1800s");
766    }
767
768    /// Helper unit: metadata `None` → 1800 (server default expansion).
769    #[test]
770    fn resolve_ttl_from_metadata_none_returns_server_default() {
771        assert_eq!(resolve_ttl_from_metadata(None), 1800);
772    }
773
774    /// Helper unit: metadata `Some(N)` → `N` (server default ignored).
775    #[test]
776    fn resolve_ttl_from_metadata_some_returns_value() {
777        assert_eq!(resolve_ttl_from_metadata(Some(7200)), 7200);
778        assert_eq!(resolve_ttl_from_metadata(Some(60)), 60);
779    }
780}