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. Always
12//!   synchronous, guarded against hanging (GH #33) by a readiness precheck
13//!   (`503` when the launch resolves to an operator-delegate path with zero
14//!   attached operators) and a `tokio::time::timeout` ceiling around the
15//!   dispatch await (`504` on expiry) — see `run_flow_form`'s doc comment.
16//! - `GET /v1/tasks` — list every persisted `TaskRecord` (newest first).
17//! - `GET /v1/tasks/:id` — a `TaskRecord` plus every `RunRecord` kicked from it.
18//! - `POST /v1/tasks/:id/runs` — re-kick an existing Task (new `RunId`, same
19//!   `blueprint_ref` / `input_ctx`).
20//! - `GET /v1/tasks/:id/runs/:run/steps` / `.../steps/:step` /
21//!   `.../steps/:step/content` — the metadata + content debug plane over a
22//!   Run's step OUTPUT (`:run` accepts `latest` or an explicit `R-<hex>`,
23//!   `projection::McpQueryAdapter`); see the `projection` module doc. This
24//!   is the operator / human-debug counterpart to the Worker axis's
25//!   `context.steps` pointer list on `GET /v1/worker/prompt`
26//!   (`projection-adapter` ST5 — replaces the ST2/ST4 single-value `GET
27//!   /v1/tasks/:id/ctx`).
28//! - `GET /v1/runs/:id` — a single `RunRecord` (its `step_entries` trace included).
29//! - `GET /v1/runs/:id/bindings` — immutable requested/effective AgentProvider
30//!   binding explain for that Run.
31//! - `POST /v1/runs/:id/resume` — resume an `Interrupted` Run under the same `run_id`.
32//! - `POST /v1/runs/:id/rerun-from` — GH #71 Layer A. Rerun a terminal Run from
33//!   a caller-specified step under the same `run_id` (physically truncates the
34//!   replay log at the cut point). See `tasks::run_rerun_from`.
35//! - `POST /v1/operators` / `GET /v1/operators/:sid` / `DELETE /v1/operators/:sid` /
36//!   `GET /v1/operators/:sid/ws` (WS upgrade) — REST-like Operator login flow,
37//!   Bearer-mandatory; the sole WS Operator session route. See `operator_ws::login`
38//!   module doc.
39//!
40//! The Enhance issue axis (`/issues`) lives in the `issues` module; callers merge
41//! `build_issues_router` to integrate it into the same server.
42//!
43//! # The 3 faces of the Operator role (= registered directly on the engine SoT)
44//!
45//! The engine stateless-executor refactor removed the three
46//! `AppState` registries (former `HookRegistry` / `BridgeRegistry` / `OperatorRegistry`);
47//! all registration now goes directly to the engine SoT via
48//! `engine.register_spawn_hook` / `register_senior_bridge` / `register_operator`.
49//! `WSOperatorSession` (in the `operator_ws` module) registers all three traits
50//! simultaneously under a single sid — one WS connection covers all 3 faces of
51//! the Operator role, the canonical pattern.
52//!
53//! # `build_*` family
54//!
55//! - [`build_router`] — minimal entry (= `default_registry()`)
56//! - [`build_router_with`] — caller provides a `SpawnerRegistry` and optional `BlueprintStore`
57//!
58//! The engine should be started with [`default_layer_registry`] (= `Engine::new_with_layers`);
59//! otherwise `Blueprint.spawner_hints` is ignored.
60
61#![warn(missing_docs)]
62
63pub mod binding;
64/// HTTP surface for inspecting/registering Blueprint state (`/v1/blueprints/*`).
65pub mod blueprints;
66/// Server config file support (`~/.mse/config.toml`, CLI > file > default merge).
67pub mod config;
68/// `/v1/data/*` endpoints (v9 Big Response handling, Store-owner direct path).
69pub mod data;
70/// `GET /v1/doctor` — read-only startup config / Store snapshot.
71pub mod doctor;
72/// HTTP surface for the `/v1/enhance/log` axis.
73pub mod enhance_log;
74/// `EnhanceSetting` HTTP CRUD (`/v1/enhance-settings*`).
75pub mod enhance_settings;
76/// HTTP surface for the Enhance issue axis (`/v1/issues*`).
77pub mod issues;
78/// WebSocket Operator Callback IF (`/v1/operators*`).
79pub mod operator_ws;
80/// `GET /v1/tasks/:id/runs/:run/steps*` (the metadata + content debug
81/// plane over a Run's step OUTPUT — `McpQueryAdapter`, a server-side
82/// `mlua_swarm::core::projection::ProjectionAdapter` impl reading through
83/// the Data-plane `OutputStore` with a persisted `RunRecord.result_ref`
84/// fallback). See the module doc for how this relates to
85/// `operator_ws::session`'s in-flight `FileProjectionAdapter` hook and
86/// `worker`'s Worker-axis `context.steps` pointer assembly.
87pub mod projection;
88/// HTTP surface for the Task/Run persistence axis (issue #13 ID hierarchy;
89/// `GET /v1/tasks`, `GET /v1/tasks/:id`, `POST /v1/tasks/:id/runs`,
90/// `GET /v1/runs/:id`). `POST /v1/tasks` itself stays in this module (it is
91/// the entry point `tasks_start` shares with the flow-eval path) — see the
92/// `tasks` module doc for the split rationale.
93pub mod tasks;
94/// `/v1/worker/*` endpoints (SubAgent self-fetch path).
95pub mod worker;
96pub use blueprints::{
97    build_blueprints_router, build_blueprints_router_with_refs, BindingRequirementsResponse,
98};
99pub use enhance_log::build_enhance_log_router;
100pub use enhance_settings::build_enhance_settings_router;
101pub use issues::{build_issues_router, GetIssueResponse, PostIssueRequest, PostIssueResponse};
102pub use operator_ws::{
103    operators_create, operators_delete, operators_info, operators_ws_connect, ClientMsg,
104    OperatorSessionEntry, ServerMsg, WSOperatorSession,
105};
106pub use projection::{McpQueryAdapter, ProjectionSource, StepList, StepPathQuery, StepSummary};
107pub use tasks::{
108    RunBindingDifference, RunBindingExplainEntry, RunBindingStatus, RunBindingsExplainResponse,
109    RunKickRequest, RunKickResponse, RunResumeResponse, TaskDetailResponse,
110};
111pub use worker::{
112    worker_artifact, worker_prompt, worker_result, ArtifactQuery, PromptQuery, WorkerResultReq,
113};
114
115use axum::{
116    extract::{DefaultBodyLimit, State},
117    http::{header::AUTHORIZATION, HeaderMap, StatusCode},
118    response::{IntoResponse, Response},
119    routing::{get, post},
120    Json, Router,
121};
122use mlua_swarm::application::{BlueprintRef, TaskApplication, TaskApplicationError};
123use mlua_swarm::blueprint::store::BlueprintStore;
124use mlua_swarm::core::config::CheckPolicy;
125use mlua_swarm::service::{TaskLaunchError, TaskLaunchService};
126use mlua_swarm::store::replay::{InMemoryReplayStore, ReplayStore};
127use mlua_swarm::store::run::{RunContext, RunRecord, RunStatus, RunStore};
128use mlua_swarm::store::task::{TaskRecord, TaskRecordStatus, TaskStore};
129use mlua_swarm::{
130    CapToken, Compiler, Engine, LayerRegistry, LuaInProcessSpawnerFactory, MainAIMiddleware,
131    OperatorDelegateMiddleware, OperatorSpawnerFactory, Role, RunId, RustFnInProcessSpawnerFactory,
132    SeniorEscalationMiddleware, SessionId, SpawnerRegistry, SubprocessProcessSpawnerFactory,
133    TaskId,
134};
135use serde::{Deserialize, Serialize};
136use serde_json::{json, Value};
137use std::collections::HashMap;
138use std::sync::Arc;
139use std::time::Duration;
140use tokio::sync::Mutex;
141
142/// In-memory session map backing `/v1/sessions` attach/detach.
143///
144/// The `sid` handed to the client on this REST path is the token nonce
145/// itself (a bearer secret), so the server never uses it as a map key —
146/// entries are keyed by its fingerprint
147/// (`mlua_swarm::types::token_fingerprint`; issue #14).
148#[derive(Default)]
149pub struct SessionStore {
150    /// Live session tokens keyed by the sid's fingerprint.
151    pub map: HashMap<String, CapToken>,
152}
153
154/// Shared axum handler state for the whole router. Cloned per-request (all
155/// fields are `Arc`/cheap-clone), constructed once in [`build_router_with_ws_factory`].
156#[derive(Clone)]
157pub struct AppState {
158    /// The engine SoT (attach/detach, dispatch, registries).
159    pub engine: Engine,
160    /// Live `/v1/sessions` attach records (Operator/Worker/etc session tokens).
161    pub sessions: Arc<Mutex<SessionStore>>,
162    /// Application used at the task entry to resolve `BlueprintRef`. Without a Store, runs in Inline-only mode.
163    pub task_app: Arc<TaskApplication>,
164    /// When `Some`, on WS connect a new `WSOperatorSession` is automatically registered
165    /// with this factory under the sid name (= a `kind=operator` + `operator_ref=<sid>` AgentDef
166    /// binds to the `WSOperatorSession` backend).
167    /// When `None`, no auto-registration happens; the session is only registered on
168    /// `engine.OperatorRegistry` (= only the `OperatorDelegateMiddleware` path is effective;
169    /// the `OperatorSpawnerFactory` path is dead).
170    pub ws_operator_factory: Option<Arc<OperatorSpawnerFactory>>,
171    /// Owner of the Store on the Data path (Big Response handling). Added in v9.
172    /// Independent layer — the Engine core and the Domain path (`/v1/worker/result`)
173    /// are not involved.
174    /// Default = `InMemoryOutputStore` (constructed inside `build_router_with_ws_factory`);
175    /// callers can swap in an sqlite/fs backend later (future carry).
176    pub data_store: Arc<dyn mlua_swarm::store::output::OutputStore>,
177    /// Login-flow session store (`POST /v1/operators` mint records). `sid` →
178    /// `OperatorSessionEntry`. This is the sole session store for the WS
179    /// Operator role. See `operator_ws::login` module doc.
180    pub operator_sessions:
181        Arc<Mutex<HashMap<SessionId, Arc<crate::operator_ws::login::OperatorSessionEntry>>>>,
182    /// S1 login-flow roles-exclusivity map. Role name → owning `sid`. Checked
183    /// (and updated) atomically under a single lock in
184    /// `operator_ws::login::operators_create` — a role already present here
185    /// causes `POST /v1/operators` to return `409 CONFLICT`. Entries are
186    /// released on `DELETE /v1/operators/:sid`.
187    pub roles_to_sid: Arc<Mutex<HashMap<String, SessionId>>>,
188    /// Persistence for `Task` records (issue #13 ID-hierarchy work-item
189    /// identity; see `mlua_swarm::store::task` module doc). Default =
190    /// `InMemoryTaskStore` (constructed inside `build_router_full`); callers
191    /// can swap in a `SqliteTaskStore` via the `task_store` argument.
192    pub task_store: Arc<dyn TaskStore>,
193    /// Persistence for `Run` records (one kick of a Task; see
194    /// `mlua_swarm::store::run` module doc). Default = `InMemoryRunStore`;
195    /// callers can swap in a `SqliteRunStore` via the `run_store` argument.
196    pub run_store: Arc<dyn RunStore>,
197    /// Per-run replay log — the Ctx-snapshot + step-output store the engine
198    /// appends to after every completed step (see `mlua_swarm::store::replay`
199    /// module doc). Threaded into `RunContext` at every dispatch site so a
200    /// later restart-equivalent recovery can reconstruct the run. Default =
201    /// `InMemoryReplayStore` (process-volatile); callers can swap in a
202    /// `SqliteReplayStore` via the `replay_store` argument.
203    pub replay_store: Arc<dyn ReplayStore>,
204    /// Public HTTP base URL the server is reachable at (e.g.
205    /// `"http://127.0.0.1:7777"`), sourced from the binary at boot time.
206    /// When `Some`, `WSOperatorSession` renders it literally into the
207    /// Spawn `directive`'s `base_url` line so the receiving operator can
208    /// paste the frame into a SubAgent prompt without a `mse_doctor`
209    /// detour (issue #8). `None` preserves the historical fallback
210    /// (a placeholder that points at `mse_doctor`).
211    pub base_url: Option<Arc<str>>,
212    /// Server-wide fallback ceiling (seconds) for the `POST /v1/tasks`
213    /// synchronous launch await (GH #33 Guard 2; see `run_flow_form`'s doc
214    /// comment). Sourced from `config::ResolvedConfig::sync_timeout_secs`.
215    /// A per-request `TaskLaunchRequest.timeout_secs` override, when
216    /// present, takes priority over this value.
217    pub sync_timeout_secs: u64,
218}
219
220/// Minimal entry point: builds a router with [`default_registry`] and no
221/// `BlueprintStore` (Inline-only mode) or `ws_operator_factory`.
222pub fn build_router(engine: Engine) -> Router {
223    build_router_with(engine, default_registry(), None)
224}
225
226/// Default `LayerRegistry` for the server. Hint keys:
227/// - `"main_ai"` → `MainAIMiddleware` (= fires SpawnHook before/after)
228/// - `"senior_escalation"` → `SeniorEscalationMiddleware` (= on `ok=false`, escalates via `SeniorBridge.ask`)
229/// - `"operator_delegate"` → `OperatorDelegateMiddleware` (= when an operator backend is registered, delegates the entire spawn)
230///
231/// Including any of these keys in `Blueprint.spawner_hints.layers` causes them to
232/// be wrapped into a `SpawnerStack` at `service::linker::link` time (= per-launch;
233/// the old `engine.bind` global-state path is retired).
234/// Callers (the engine builder side) receive it via
235/// `Engine::new_with_layers(cfg, mse_server::default_layer_registry())`.
236pub fn default_layer_registry() -> LayerRegistry {
237    LayerRegistry::new()
238        .with_hint("main_ai", |_engine| Arc::new(MainAIMiddleware::new()))
239        .with_hint("senior_escalation", |_engine| {
240            Arc::new(SeniorEscalationMiddleware::new())
241        })
242        .with_hint("operator_delegate", |_engine| {
243            Arc::new(OperatorDelegateMiddleware::new())
244        })
245}
246
247/// Build form where the caller supplies a registry and an optional `BlueprintStore`.
248/// The Operator callback path (= external HTTP / WS callers acting as an Operator)
249/// must be pre-registered via `engine.register_*` (= the engine is the SoT).
250/// See the `operator_ws` module doc and `OperatorInfo` (engine-side `ctx.rs`) for details.
251pub fn build_router_with(
252    engine: Engine,
253    registry: SpawnerRegistry,
254    store: Option<Arc<dyn BlueprintStore>>,
255) -> Router {
256    build_router_with_ws_factory(engine, registry, store, None)
257}
258
259/// 4-argument variant of `build_router_with`. Passing `ws_operator_factory = Some(arc)`
260/// causes each WS connect to auto-register a new `WSOperatorSession` under its sid
261/// name with the factory (= a `kind=operator` AgentDef with `operator_ref: <sid>`
262/// can then bind to the WS client backend). Callers are expected to also install
263/// the same `Arc` into the `SpawnerRegistry` via
264/// `reg.register::<OperatorSpawnerFactory>(arc.clone())`.
265pub fn build_router_with_ws_factory(
266    engine: Engine,
267    registry: SpawnerRegistry,
268    store: Option<Arc<dyn BlueprintStore>>,
269    ws_operator_factory: Option<Arc<OperatorSpawnerFactory>>,
270) -> Router {
271    build_router_with_ws_factory_and_output(engine, registry, store, ws_operator_factory, None)
272}
273
274/// 5-argument variant of [`build_router_with_ws_factory`]. Passing
275/// `output_store = Some(arc)` swaps the default `InMemoryOutputStore` for a
276/// caller-supplied backend (a `SqliteOutputStore`, for instance). `None`
277/// preserves the historical behaviour (fresh in-memory store per call).
278pub fn build_router_with_ws_factory_and_output(
279    engine: Engine,
280    registry: SpawnerRegistry,
281    store: Option<Arc<dyn BlueprintStore>>,
282    ws_operator_factory: Option<Arc<OperatorSpawnerFactory>>,
283    output_store: Option<Arc<dyn mlua_swarm::store::output::OutputStore>>,
284) -> Router {
285    build_router_full(
286        engine,
287        registry,
288        store,
289        ws_operator_factory,
290        output_store,
291        None,
292        None,
293        None,
294        None,
295        crate::config::default_sync_timeout_secs(),
296    )
297}
298
299/// 8-argument variant of [`build_router_with_ws_factory_and_output`].
300/// Passing `base_url = Some(...)` (e.g. `"http://127.0.0.1:7777"`) makes
301/// `WSOperatorSession` render the actual server bind into the Spawn
302/// directive's `base_url` line, so the receiving operator can copy the
303/// frame straight into a SubAgent prompt (issue #8). `None` preserves
304/// the historical fallback (`<check with mse_doctor>` placeholder).
305/// `task_store` / `run_store` swap the default `InMemoryTaskStore` /
306/// `InMemoryRunStore` (issue #13 ID-hierarchy persistence) for a
307/// caller-supplied backend (`SqliteTaskStore` / `SqliteRunStore`, for
308/// instance); `None` preserves the process-volatile default.
309/// `sync_timeout_secs` is the server-wide fallback ceiling for the `POST
310/// /v1/tasks` synchronous launch await (GH #33 Guard 2) — see
311/// `AppState::sync_timeout_secs` / `run_flow_form`'s doc comment.
312// This is the terminal builder in the `build_router*` delegation chain
313// (each variant adds one more caller-overridable store/factory); the
314// argument count grows with the number of pluggable backends, not with
315// unrelated responsibilities, so a plain allow is preferable to bundling
316// them into a config struct only this one function would consume.
317#[allow(clippy::too_many_arguments)]
318pub fn build_router_full(
319    engine: Engine,
320    registry: SpawnerRegistry,
321    store: Option<Arc<dyn BlueprintStore>>,
322    ws_operator_factory: Option<Arc<OperatorSpawnerFactory>>,
323    output_store: Option<Arc<dyn mlua_swarm::store::output::OutputStore>>,
324    base_url: Option<Arc<str>>,
325    task_store: Option<Arc<dyn TaskStore>>,
326    run_store: Option<Arc<dyn RunStore>>,
327    replay_store: Option<Arc<dyn ReplayStore>>,
328    sync_timeout_secs: u64,
329) -> Router {
330    build_router_full_with_legacy_worker_binding_policy(
331        engine,
332        registry,
333        store,
334        ws_operator_factory,
335        output_store,
336        base_url,
337        task_store,
338        run_store,
339        replay_store,
340        sync_timeout_secs,
341        mlua_swarm::LegacyWorkerBindingPolicy::Allow,
342    )
343}
344
345/// Full router builder with an explicit migration gate for deprecated
346/// `AgentProfile.worker_binding` Runner fallback. The existing
347/// [`build_router_full`] remains compatibility-defaulted to `Allow`.
348#[allow(clippy::too_many_arguments)]
349pub fn build_router_full_with_legacy_worker_binding_policy(
350    engine: Engine,
351    registry: SpawnerRegistry,
352    store: Option<Arc<dyn BlueprintStore>>,
353    ws_operator_factory: Option<Arc<OperatorSpawnerFactory>>,
354    output_store: Option<Arc<dyn mlua_swarm::store::output::OutputStore>>,
355    base_url: Option<Arc<str>>,
356    task_store: Option<Arc<dyn TaskStore>>,
357    run_store: Option<Arc<dyn RunStore>>,
358    replay_store: Option<Arc<dyn ReplayStore>>,
359    sync_timeout_secs: u64,
360    legacy_worker_binding_policy: mlua_swarm::LegacyWorkerBindingPolicy,
361) -> Router {
362    let operator_sessions = Arc::new(Mutex::new(HashMap::new()));
363    let roles_to_sid = Arc::new(Mutex::new(HashMap::new()));
364    let compiler = Compiler::new(registry);
365    let binding_provider = Arc::new(binding::OperatorSessionBindingProvider::new(
366        operator_sessions.clone(),
367        roles_to_sid.clone(),
368    ));
369    let launch = Arc::new(
370        TaskLaunchService::new(engine.clone(), compiler)
371            .with_binding_provider(binding_provider)
372            .with_legacy_worker_binding_policy(legacy_worker_binding_policy),
373    );
374    let task_app = Arc::new(match store {
375        Some(s) => TaskApplication::new(launch, s),
376        None => TaskApplication::new_inline_only(launch),
377    });
378    let data_store: Arc<dyn mlua_swarm::store::output::OutputStore> = match output_store {
379        Some(s) => s,
380        None => Arc::new(mlua_swarm::store::output::InMemoryOutputStore::new()),
381    };
382    // subtask-4 / ST2 rework: wire the SAME `data_store` instance into the
383    // engine's submit-time projection sink (`Engine::submit_output` /
384    // `submit_worker_result_trusted`), so an ordinary worker
385    // `/v1/worker/submit` — not just the explicit `POST /v1/data/emit` —
386    // lands in this store too. `projection::McpQueryAdapter` (`GET
387    // /v1/tasks/:id/runs/:run/steps*`) reads through this same `Arc`,
388    // which is what makes an in-flight run's already-submitted step
389    // OUTPUT queryable.
390    engine.set_output_store(data_store.clone());
391    let task_store: Arc<dyn TaskStore> = match task_store {
392        Some(s) => s,
393        None => Arc::new(mlua_swarm::store::task::InMemoryTaskStore::new()),
394    };
395    let run_store: Arc<dyn RunStore> = match run_store {
396        Some(s) => s,
397        None => Arc::new(mlua_swarm::store::run::InMemoryRunStore::new()),
398    };
399    let replay_store: Arc<dyn ReplayStore> = match replay_store {
400        Some(s) => s,
401        None => Arc::new(InMemoryReplayStore::new()),
402    };
403    let state = AppState {
404        engine,
405        sessions: Arc::new(Mutex::new(SessionStore::default())),
406        task_app,
407        ws_operator_factory,
408        data_store,
409        operator_sessions,
410        roles_to_sid,
411        task_store,
412        run_store,
413        replay_store,
414        base_url,
415        sync_timeout_secs,
416    };
417    Router::new()
418        .route("/v1/healthz", get(healthz))
419        .route("/v1/status", get(status_get))
420        // session = collection (POST = attach, DELETE = detach, sid via Authorization)
421        .route(
422            "/v1/sessions",
423            post(sessions_attach).delete(sessions_detach),
424        )
425        // task = flat, single level; authz resolved via Authorization: Bearer <sid>
426        .route("/v1/tasks", post(tasks_start).get(tasks::tasks_list))
427        .route("/v1/tasks/:id", get(tasks::task_get))
428        .route("/v1/tasks/:id/runs", post(tasks::task_rekick))
429        .route("/v1/tasks/:id/runs/:run/steps", get(projection::steps_list))
430        .route(
431            "/v1/tasks/:id/runs/:run/steps/:step",
432            get(projection::step_get),
433        )
434        .route(
435            "/v1/tasks/:id/runs/:run/steps/:step/content",
436            get(projection::step_content),
437        )
438        .route("/v1/runs/:id", get(tasks::run_get))
439        .route("/v1/runs/:id/bindings", get(tasks::run_bindings_explain))
440        // Resume an Interrupted Run under the SAME run_id (replay cursor +
441        // stored launch-input snapshot); see `tasks::run_resume`.
442        .route("/v1/runs/:id/resume", post(tasks::run_resume))
443        // Rerun-from-step on a terminal Run under the SAME run_id (physically
444        // truncates the replay log at the cut point); see
445        // `tasks::run_rerun_from` for the full contract (GH #71 Layer A).
446        .route("/v1/runs/:id/rerun-from", post(tasks::run_rerun_from))
447        // REST-like Operator login flow (Bearer-mandatory, roles exclusivity).
448        // Sole WS Operator session route; see `operator_ws::login` module doc.
449        .route("/v1/operators", post(operators_create))
450        .route("/v1/operators/:sid/ws", get(operators_ws_connect))
451        .route(
452            "/v1/operators/:sid",
453            get(operators_info).delete(operators_delete),
454        )
455        // SubAgent self-fetch path (the SubAgent self-fetch design). The SubAgent puts the
456        // CapToken handed over via WS Spawn into Bearer and hits the prompt / result
457        // endpoints directly over HTTP. See the `worker` module doc for details.
458        .route("/v1/worker/prompt", get(worker::worker_prompt))
459        .route("/v1/worker/result", post(worker::worker_result))
460        // Simplified endpoint (= worker POSTs with just token + raw body; task_id is auto-looked-up).
461        // `DefaultBodyLimit::max` is applied explicitly here (and on the sibling
462        // `/v1/worker/artifact` below) — same 2MB axum ships as its implicit
463        // global default, made visible rather than relied on silently.
464        .route(
465            "/v1/worker/submit",
466            post(worker::worker_submit).layer(DefaultBodyLimit::max(2 * 1024 * 1024)),
467        )
468        // GH #36 ST1: named multi-part worker output. A worker stages one
469        // named part per POST here, then completes the attempt with the
470        // ordinary `/v1/worker/submit` above — see the `worker` module doc.
471        .route(
472            "/v1/worker/artifact",
473            post(worker::worker_artifact).layer(DefaultBodyLimit::max(2 * 1024 * 1024)),
474        )
475        // GH #31: `Http`-mode fetch target for `system_ref.uri` (raw baked system
476        // bytes, same Bearer flow as `/v1/worker/prompt`) + live per-agent render-size
477        // lookup for `bp_doctor` (no Bearer, same trust tier as blueprints `get_head`).
478        .route(
479            "/v1/worker/prompt/system",
480            get(worker::worker_prompt_system),
481        )
482        .route(
483            "/v1/agents/:name/render-size",
484            get(worker::agent_render_size),
485        )
486        // GH #32: structured worker degradation reporting — independent channel,
487        // never touches OutputStore / the fold path. See the `worker` module doc.
488        .route("/v1/worker/degradation", post(worker::worker_degradation))
489        // Data path (v9 Big Response handling, independent from Domain / verdict flow)
490        .route("/v1/data/emit", post(data::data_emit))
491        .route(
492            "/v1/data/:key",
493            get(data::data_get).post(data::data_emit_named),
494        )
495        .with_state(state)
496}
497
498/// Default registry = Subprocess + RustFn (baseline `identity` worker pre-baked) + empty Operator factory.
499///
500/// `RustFnInProcessSpawnerFactory` gets one baseline entry (`fn_id = "identity"`)
501/// baked in via [`mlua_swarm::worker::baseline::extend_with_baseline`]. This
502/// is the shared bootstrap / smoke worker SoT across each binary (the server / MCP adapter /
503/// one-shot runner) — it structurally replaces the old per-binary inline echo injection.
504///
505/// Usage: default Task path at server startup. If production needs additional
506/// backends, callers bring in a different registry via
507/// `build_router_with(engine, custom_registry)`. The enhance flow
508/// (= patch-spawner / patch-applier / verifier-router / committer axes) uses
509/// [`default_registry_with_enhance_flow`].
510///
511/// The Operator factory is an empty shell with zero registrations (= sids are
512/// dynamically registered per WS connect; see the `operator_ws` module).
513pub fn default_registry() -> SpawnerRegistry {
514    let rustfn_factory =
515        mlua_swarm::worker::baseline::extend_with_baseline(RustFnInProcessSpawnerFactory::new());
516
517    let mut reg = SpawnerRegistry::new();
518    reg.register::<SubprocessProcessSpawnerFactory>(Arc::new(SubprocessProcessSpawnerFactory));
519    reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(rustfn_factory));
520    // Empty `LuaInProcessSpawnerFactory`: no `fn_id` is pre-registered here,
521    // but BP agents can still declare `kind: lua` by carrying an inline
522    // `spec.source` (or a `$file`-expanded Lua chunk). This lets a BP ship
523    // deterministic Lua gates on the vanilla registry, without opting into
524    // the enhance flow. See `LuaInProcessSpawnerFactory` docs for the spec
525    // shape.
526    reg.register::<LuaInProcessSpawnerFactory>(Arc::new(LuaInProcessSpawnerFactory::new()));
527    reg.register::<OperatorSpawnerFactory>(Arc::new(OperatorSpawnerFactory::new()));
528    reg
529}
530
531/// Opt-in registry that merges [`default_registry`] with the enhance flow
532/// (Lua factory + AgentBlock factory).
533///
534/// Selected via the `the server` CLI flag `--enable-enhance-flow`. The enhance
535/// flow is a separate-axis wrapper: the Lua factory (= 3 Lua workers + 3 primitive
536/// bridges) and the AgentBlock factory (= patch-spawner path, expects
537/// `assets/operator_scripts/blueprint_patch_spawner.lua` + `ANTHROPIC_API_KEY`)
538/// are baked in as pipeline defaults. The baseline RustFn (`identity`) is pre-baked
539/// the same way as in `default_registry`.
540pub fn default_registry_with_enhance_flow() -> SpawnerRegistry {
541    let lua_factory =
542        mlua_swarm::enhance::blueprint::extend_factory(LuaInProcessSpawnerFactory::new());
543    // The Factory is stateless (= 1 process → 1 factory shared by all AgentDefs).
544    // Per-agent specialization (script_path / project_root, etc.) goes through AgentDef.spec.
545    // The enhance-flow patch-spawner is declared literally in agents[].spec of `default_blueprint.yaml`.
546    let agent_block_factory =
547        mlua_swarm::worker::agent_block::AgentBlockInProcessSpawnerFactory::new();
548    let rustfn_factory =
549        mlua_swarm::worker::baseline::extend_with_baseline(RustFnInProcessSpawnerFactory::new());
550
551    let mut reg = SpawnerRegistry::new();
552    reg.register::<SubprocessProcessSpawnerFactory>(Arc::new(SubprocessProcessSpawnerFactory));
553    reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(rustfn_factory));
554    reg.register::<LuaInProcessSpawnerFactory>(Arc::new(lua_factory));
555    reg.register::<mlua_swarm::worker::agent_block::AgentBlockInProcessSpawnerFactory>(Arc::new(
556        agent_block_factory,
557    ));
558    reg.register::<OperatorSpawnerFactory>(Arc::new(OperatorSpawnerFactory::new()));
559    reg
560}
561
562// ─── handlers ────────────────────────────────────────────────────────────
563
564async fn healthz() -> &'static str {
565    "ok"
566}
567
568/// Response body for `GET /v1/status` (issue #35 ST4 — lifecycle
569/// occupancy guard). Cheap-to-poll summary of "is it safe to kill this
570/// server right now".
571#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
572pub struct StatusResponse {
573    /// Count of `Run`s currently `Running` (`RunStore::list_running`).
574    /// Degrades to `0` on a store error rather than 500ing — see
575    /// module doc rationale.
576    pub running_runs: usize,
577    /// Count of attached Operator ids (`engine.list_operator_ids()`,
578    /// same idiom as `run_flow_form`'s Guard 1).
579    pub attached_operators: usize,
580}
581
582/// `GET /v1/status`. Infallible summary for the ST4 occupancy guard —
583/// store/engine query failures degrade the corresponding count to `0`
584/// (logged via `tracing::warn!`) rather than 500ing, since this
585/// endpoint may be polled frequently by a lifecycle-check caller that
586/// should not itself become a hang/error surface.
587async fn status_get(State(state): State<AppState>) -> Json<StatusResponse> {
588    let running_runs = state
589        .run_store
590        .list_running()
591        .await
592        .map(|v| v.len())
593        .unwrap_or_else(|e| {
594            tracing::warn!(error = %e, "status_get: list_running failed");
595            0
596        });
597    let attached_operators = state.engine.list_operator_ids().await.len();
598    Json(StatusResponse {
599        running_runs,
600        attached_operators,
601    })
602}
603
604#[derive(Deserialize)]
605struct AttachReq {
606    agent_id: String,
607    role: String,
608    ttl_secs: u64,
609}
610
611#[derive(Serialize)]
612struct AttachResp {
613    session_id: String,
614    role: String,
615}
616
617async fn sessions_attach(
618    State(state): State<AppState>,
619    Json(req): Json<AttachReq>,
620) -> Result<Json<AttachResp>, ApiError> {
621    let role = parse_role(&req.role)?;
622    let token = state
623        .engine
624        .attach(req.agent_id, role, Duration::from_secs(req.ttl_secs))
625        .await
626        .map_err(ApiError::engine)?;
627    // The wire `session_id` stays the nonce (Bearer credential contract);
628    // the server-side map key is its fingerprint (issue #14).
629    let sid = token.nonce.clone();
630    let key = token.fingerprint();
631    state.sessions.lock().await.map.insert(key, token);
632    Ok(Json(AttachResp {
633        session_id: sid,
634        role: req.role,
635    }))
636}
637
638async fn sessions_detach(
639    State(state): State<AppState>,
640    headers: HeaderMap,
641) -> Result<StatusCode, ApiError> {
642    let sid = extract_bearer(&headers)?;
643    let token = take_session_token(&state, &sid).await?;
644    state
645        .engine
646        .detach(&token)
647        .await
648        .map_err(ApiError::engine)?;
649    Ok(StatusCode::NO_CONTENT)
650}
651
652// ─── Unified /v1/tasks schema (= flow-eval path, Operator inject supported) ───────
653
654/// `/v1/tasks` POST schema. Uses the flow-eval path and supports Operator inject
655/// (kind / spawn_hook / senior_bridge). Expressing a one-shot task as a 1-Step
656/// Blueprint is the only correct model.
657///
658/// `pub` (issue #19 ST5) so its `schemars`-derived JSON Schema can be
659/// generated cross-crate by `mlua-swarm-cli`'s `mse://api/http-endpoints`
660/// MCP resource; fields stay module-private (no public field-level API
661/// surface is intended).
662#[derive(Deserialize, schemars::JsonSchema)]
663pub struct TaskLaunchRequest {
664    /// `BlueprintRef` selects Inline (a full Blueprint value) or Id (a
665    /// store lookup). Left opaque here — its own schema nests the full
666    /// `Blueprint` schema (owned by `mse://api/blueprint-schema`), and
667    /// mixing the two into this HTTP-endpoint resource would violate
668    /// their separation of concerns (see the resource's module doc).
669    #[schemars(with = "Value")]
670    blueprint: BlueprintRef,
671    /// flow.ir's initial `ctx` — every `Step.in` `$.<path>` reads from
672    /// here. This field's role is limited to the flow-ir eval seed
673    /// (issue #19); the Task-level execution context lives in the
674    /// sibling top-level fields below (`project_root` / `work_dir` /
675    /// `task_metadata`), promoted out of `init_ctx` to remove the
676    /// prior "free bag nested in free JSON" duplication.
677    ///
678    /// Backward compat: the pre-#19 shape — the same three keys nested
679    /// directly inside this object — is still honored as a fallback
680    /// when the sibling field is absent; see `run_flow_form`'s 2-stage
681    /// resolution and `TaskInputMiddleware::from_init_ctx`.
682    #[schemars(with = "Value")]
683    init_ctx: Value,
684    /// Task-level project root (issue #19 canonical Task IF field —
685    /// promoted out of `init_ctx`). Takes priority over a same-named
686    /// key nested inside `init_ctx` (backward-compat fallback).
687    #[serde(default)]
688    project_root: Option<String>,
689    /// Task-level working directory (issue #19), same priority rule as
690    /// `project_root`.
691    #[serde(default)]
692    work_dir: Option<String>,
693    /// Task-level arbitrary metadata bag (issue #19), same priority
694    /// rule as `project_root`.
695    #[serde(default)]
696    #[schemars(with = "Option<Value>")]
697    task_metadata: Option<Value>,
698    /// TTL in seconds. When unspecified (`None`), falls back in this order:
699    /// (1) `metadata.default_run_ttl_secs` from the resolved BP,
700    /// (2) if absent, the server global `default_run_ttl()` (1800s).
701    #[serde(default)]
702    ttl_secs: Option<u64>,
703    #[serde(default)]
704    operator: Option<OperatorReq>,
705    /// Explicit Operator session sid (or role alias) this task's entire Spawn
706    /// stream should be routed to (runtime Operator match stage 1).
707    ///
708    /// When `Some`, it is validated at request time against
709    /// `state.engine.list_operator_ids()` (the live `engine.operators`
710    /// registry key set): an unknown/never-registered id returns `400`
711    /// immediately — this is a deliberate hard-fail, in contrast to
712    /// `OperatorDelegateWrapped::spawn`, which silently falls through to
713    /// `inner.spawn` on a registry miss. A sid that *was* registered but has
714    /// since disconnected (WS `tx` cleared, session entry retained for
715    /// reconnect) passes this check and surfaces as an explicit dispatch-time
716    /// error instead (`WSOperatorSession::send_and_await` returns `Err` when
717    /// `tx` is `None`), which also propagates as a request failure rather
718    /// than a silent fallback.
719    ///
720    /// On success this value **overrides** `operator.operator_backend_id`
721    /// (last-write-wins, `operator_sid` takes priority) before the flow is
722    /// dispatched — see `run_flow_form`. Dispatch still only delegates if the
723    /// Blueprint opts into `spawner_hints.layers = ["operator_delegate"]`
724    /// (unchanged precondition, same as the existing `operator_backend_id`
725    /// field).
726    ///
727    /// When unset, behavior is unchanged: whatever
728    /// `operator.operator_backend_id` / BP-level `operator_ref` alias
729    /// resolution already does still applies.
730    #[serde(default)]
731    operator_sid: Option<String>,
732    /// Per-request override for the sync launch's timeout ceiling (GH #33
733    /// Guard 2, see `run_flow_form`'s doc comment). `None` (the default;
734    /// existing clients are unaffected) falls back to
735    /// `AppState::sync_timeout_secs` (server config), then the built-in
736    /// default (300s). `Some(0)` is rejected with `400` — omit the field
737    /// to defer to the server default rather than sending an explicit
738    /// zero.
739    #[serde(default)]
740    timeout_secs: Option<u64>,
741    /// Human-facing description of the work item (e.g. "resolve issue #10"),
742    /// stashed verbatim into the minted `TaskRecord.goal`. Omitted / `None`
743    /// stores an empty string — the flow-eval path itself never reads it.
744    #[serde(default)]
745    goal: Option<String>,
746    /// The "launch request" tier (tier 1, highest
747    /// priority) of the `check_policy` cascade
748    /// (`launch request > blueprint > server config`). `None` (the default;
749    /// existing clients are unaffected) leaves the tier unspecified so the
750    /// Blueprint-declared `check_policy` and, failing that, the server-wide
751    /// `EngineCfg.check_policy` default decide. Wire form is snake_case
752    /// (`"silent"` / `"warn"` / `"strict"`). Threaded verbatim into
753    /// `TaskApplicationInput.check_policy`.
754    #[serde(default)]
755    check_policy: Option<CheckPolicy>,
756    /// GH #37: opt into the detached (asynchronous) launch. `false` (the
757    /// default; existing clients are unaffected) keeps the synchronous
758    /// launch: the handler drives the flow eval inline and returns the
759    /// `final_ctx` on completion. `true` spawns the flow eval as a
760    /// detached background task and returns `202 Accepted` immediately
761    /// with `{task_id, run_id, status: "running"}` (`final_ctx` is
762    /// `null`) — the run's only lifetime bound is `ttl_secs`, and its
763    /// outcome is observed via `GET /v1/runs/:id` (or the `swarm_status`
764    /// MCP tool). Mutually exclusive with `timeout_secs` (the sync-launch
765    /// ceiling has no meaning for a detached run; combining them is a
766    /// `400`).
767    #[serde(default)]
768    detach: bool,
769}
770
771/// Operator inject sub-schema of [`TaskLaunchRequest`] (`kind` / `id` /
772/// `spawn_hook_id` / `senior_bridge_id` / `operator_backend_id` /
773/// `per_agent_kinds`). `pub` for the same cross-crate schema-generation
774/// reason as `TaskLaunchRequest`.
775#[derive(Deserialize, Default, schemars::JsonSchema)]
776pub struct OperatorReq {
777    /// `main_ai` / `automate` / `composite`. This is the "Runtime Global"
778    /// tier of the 4-tier `OperatorKind` cascade (see `mlua_swarm
779    /// ::ctx::collapse_operator_kind`); when unspecified, falls through to
780    /// the BP-level tiers (`OperatorDef.kind` / `Blueprint
781    /// .default_operator_kind`) instead of eagerly defaulting to `automate`.
782    #[serde(default)]
783    kind: Option<String>,
784    /// Operator id at attach time (= sessions tracking key in the EventLog); unspecified defaults to `"http-run"`.
785    #[serde(default)]
786    id: Option<String>,
787    /// Name of a hook pre-registered via `engine.register_spawn_hook`; `None` if unspecified.
788    #[serde(default)]
789    spawn_hook_id: Option<String>,
790    /// Name of a bridge pre-registered via `engine.register_senior_bridge`; `None` if unspecified.
791    #[serde(default)]
792    senior_bridge_id: Option<String>,
793    /// Name of an Operator backend pre-registered via `engine.register_operator`
794    /// (= the path that delegates the entire spawn to an external Operator);
795    /// `None` if unspecified. When `kind == MainAi/Composite` and this id is `Some`,
796    /// `OperatorDelegateMiddleware` bypasses `inner.spawn` and calls `operator.execute` instead.
797    /// This is a different axis from `operator.id` (= session tracking label);
798    /// `operator_backend_id` is the registry lookup key.
799    #[serde(default)]
800    operator_backend_id: Option<String>,
801    /// "Runtime Agent-level" tier (highest priority) of the `OperatorKind`
802    /// cascade — per-agent override, keyed by `AgentDef.name`, value is
803    /// `main_ai` / `automate` / `composite` (same parsing as `kind`).
804    /// `None` / absent means no per-agent override.
805    #[serde(default)]
806    per_agent_kinds: Option<HashMap<String, String>>,
807}
808
809/// Parse a wire-level kind string (`"main_ai"` / `"automate"` / `"composite"`)
810/// into `OperatorKind`. Shared by `OperatorReq.kind` and
811/// `OperatorReq.per_agent_kinds` values.
812fn parse_operator_kind_str(s: &str) -> Result<mlua_swarm::OperatorKind, ApiError> {
813    use mlua_swarm::OperatorKind;
814    match s {
815        "main_ai" => Ok(OperatorKind::MainAi),
816        "composite" => Ok(OperatorKind::Composite),
817        "automate" => Ok(OperatorKind::Automate),
818        other => Err(ApiError::bad_request(format!(
819            "operator kind: unknown value '{other}' (expected main_ai|automate|composite)"
820        ))),
821    }
822}
823
824/// `/v1/tasks` POST response body. `pub` for the same cross-crate
825/// schema-generation reason as [`TaskLaunchRequest`].
826#[derive(Serialize, schemars::JsonSchema)]
827pub struct TaskLaunchResponse {
828    /// The final flow.ir `ctx` after every `Step.out` has been written.
829    #[schemars(with = "Value")]
830    final_ctx: Value,
831    /// Debug-formatted `BlueprintVersion` the run resolved against, when
832    /// the Blueprint came from a store lookup (`None` for `Inline` refs).
833    bound_version: Option<String>,
834    /// Resolved TTL (seconds) actually applied to the run. Exposes the
835    /// 3-layer cascade (request body → BP metadata → server default) so
836    /// clients can verify which value took effect without re-deriving it.
837    effective_ttl_secs: u64,
838    /// Which layer of the TTL cascade won.
839    ttl_source: TtlSource,
840    /// The `TaskRecord` minted for this request (issue #13 ID-hierarchy
841    /// persistence). `GET /v1/tasks/:id` re-fetches it; `POST
842    /// /v1/tasks/:id/runs` re-kicks it under a fresh `RunId`.
843    #[schemars(with = "String")]
844    task_id: TaskId,
845    /// The `RunRecord` minted for this specific kick. `GET /v1/runs/:id`
846    /// re-fetches it (`step_entries` included).
847    #[schemars(with = "String")]
848    run_id: RunId,
849    /// Launch outcome at response time (GH #37). The synchronous path
850    /// (default) reports `done` — the flow eval completed before this
851    /// response was built. A detached launch (`detach: true`) reports
852    /// `running` — the eval continues in the background; poll `GET
853    /// /v1/runs/:id` for the terminal status and result.
854    status: RunStatus,
855}
856
857/// `tasks_start`'s reply — a [`TaskLaunchResponse`] plus the HTTP status
858/// it rides out on (`200 OK` for the synchronous path, `202 Accepted` for
859/// a detached launch, GH #37). A tuple struct with the body first so
860/// handler-level tests keep their established `.0` access to the response
861/// body regardless of which path produced it.
862pub struct TaskLaunchReply(pub TaskLaunchResponse, pub StatusCode);
863
864impl IntoResponse for TaskLaunchReply {
865    fn into_response(self) -> Response {
866        (self.1, Json(self.0)).into_response()
867    }
868}
869
870/// Which layer of the TTL cascade (request body → BP metadata → server
871/// default) resolved [`TaskLaunchResponse::effective_ttl_secs`]. `pub` for
872/// the same cross-crate schema-generation reason as `TaskLaunchRequest`.
873#[derive(Serialize, Clone, Copy, Debug, PartialEq, Eq, schemars::JsonSchema)]
874#[serde(rename_all = "snake_case")]
875pub enum TtlSource {
876    /// The request body's `ttl_secs` was set explicitly.
877    RequestBody,
878    /// The request body omitted `ttl_secs`; the resolved Blueprint's
879    /// `metadata.default_run_ttl_secs` was set.
880    BpMetadata,
881    /// Both the request body and the Blueprint metadata omitted a TTL;
882    /// the server-global `default_run_ttl()` (1800s) applied.
883    ServerDefault,
884}
885
886/// Unified `/v1/tasks` POST entry (= Flow form only).
887/// Runs `Blueprint.flow` to completion via flow eval in a single round-trip.
888/// One-shot tasks are also expressed as a 1-Step Blueprint. Operator
889/// (kind / spawn_hook / senior_bridge) can be injected per request body.
890/// `operator_sid` (S2, runtime Operator match stage 1) additionally
891/// lets the caller pin the task to a specific already-registered Operator
892/// session sid, bypassing BP-level alias lookup — see `TaskLaunchRequest` doc.
893async fn tasks_start(
894    State(state): State<AppState>,
895    Json(req): Json<TaskLaunchRequest>,
896) -> Result<TaskLaunchReply, ApiError> {
897    run_flow_form(&state, req).await
898}
899
900/// Flow-form path (= via `TaskApplication::handle_with_run`).
901/// Core handler behind the `/v1/tasks` entry (`tasks_start`).
902///
903/// Engine stateless-executor refactor: the per-request
904/// sub_engine + 3-registry propagate loop is retired; the startup-built
905/// `state.task_app` (= a `TaskLaunchService` wrap around `state.engine`) is
906/// used directly. The Operator callback IF (`spawn_hook_id` /
907/// `senior_bridge_id` / `operator_backend_id`) is registered on
908/// `state.engine.register_*` at WS connect time — the engine is the SoT.
909/// See the `operator_ws` module doc for details.
910///
911/// # GH #33 — sync-hang guards
912///
913/// This handler is always synchronous end-to-end (no sync/async branch);
914/// two fail-loud guards keep a bad launch from hanging the HTTP request
915/// forever:
916///
917/// - **Guard 1 (readiness precheck, `503`)**: when the request/BP
918///   references an operator backend (`operator.operator_backend_id`, set
919///   directly or via `operator_sid`) and `state.engine.list_operator_ids()`
920///   is empty, the request fails immediately rather than dispatching into
921///   a session with nothing attached to serve it. Coarse by design — a
922///   launch that cannot be cheaply determined to route through an operator
923///   is never rejected here (Guard 2 still covers the hang in that case).
924/// - **Guard 2 (sync timeout, `504`)**: the single
925///   `state.task_app.handle_with_run` await is wrapped in
926///   `tokio::time::timeout`. Ceiling cascade, highest priority first:
927///   request `timeout_secs` (rejecting `Some(0)` with `400`), then
928///   `AppState::sync_timeout_secs` (server config), then the built-in
929///   default (300s). On expiry the timed-out future is dropped — this
930///   cancels the in-process flow eval (the flow is abandoned, not
931///   resumed; intended v1 semantics) — and the Task/Run records are
932///   best-effort marked `Failed` so they do not stay `Running` forever.
933///
934/// # GH #37 — detached launch (`detach: true`)
935///
936/// The sync semantics above tie the flow-eval driver's lifetime to this
937/// request's future — a long-running detached worker that outlives the
938/// ceiling gets its (individually successful) `/v1/worker/*` submits
939/// orphaned when the driver is cancelled. `detach: true` decouples them:
940/// the eval (plus `finalize_run`) runs in a `tokio::spawn`ed background
941/// task whose only lifetime bound is the resolved `ttl_secs` (marked
942/// `Failed` on expiry, same best-effort persistence as Guard 2), and the
943/// handler returns `202 Accepted` with `status: "running"` immediately.
944/// Guard 1 still applies (checked before any store write); Guard 2's
945/// ceiling does not (`timeout_secs` + `detach` together is a `400`).
946/// Client disconnect after the `202` cannot cancel the run.
947async fn run_flow_form(
948    state: &AppState,
949    req: TaskLaunchRequest,
950) -> Result<TaskLaunchReply, ApiError> {
951    use mlua_swarm::application::{BlueprintRef as AppBlueprintRef, TaskApplicationInput};
952    use mlua_swarm::OperatorKind;
953
954    // Snapshot everything the TaskRecord needs before `req.blueprint` /
955    // `req.init_ctx` are moved into the dispatch path below.
956    let blueprint_ref_json = serde_json::to_value(&req.blueprint)
957        .map_err(|e| ApiError::bad_request(format!("blueprint snapshot: {e}")))?;
958    let input_ctx_snapshot = req.init_ctx.clone();
959    let goal = req.goal.clone().unwrap_or_default();
960
961    // issue #19 ST2: resolve the Task-level canonical fields
962    // (`project_root` / `work_dir` / `task_metadata`) once, at the wire
963    // boundary. Sibling top-level fields on the request body take
964    // priority; the pre-#19 shape (same key nested inside `init_ctx`) is
965    // only a fallback for legacy callers. The result is threaded straight
966    // through as `TaskApplicationInput.task_input` — `init_ctx` itself is
967    // NOT mutated, so it stays a pure flow-ir eval seed identical to
968    // whatever the caller sent.
969    let task_input_spec = build_task_input_spec_from_request(&req);
970    // Issue #19 ST4: snapshot the resolved spec into the `TaskRecord` (JSON,
971    // same "bare `Value`" rationale as `blueprint_ref_json` /
972    // `input_ctx_snapshot` above) so `POST /v1/tasks/:id/runs` can resolve
973    // it back out on rekick without re-deriving it from a since-stale
974    // request body. Cloned rather than computed from `task_input_spec`
975    // after the fact — the original is still moved into
976    // `TaskApplicationInput.task_input` below.
977    let task_input_spec_snapshot = task_input_spec
978        .clone()
979        .map(|spec| serde_json::to_value(&spec))
980        .transpose()
981        .map_err(|e| ApiError::bad_request(format!("task_input_spec snapshot: {e}")))?;
982    let init_ctx = req.init_ctx.clone();
983
984    let mut op_req = req.operator.unwrap_or_default();
985
986    // S2: explicit `operator_sid` override (runtime Operator match stage 1).
987    // Resolved *before* building `operator_kind` / dispatching so an
988    // unknown sid fails fast with a 400, never silently falling back to the
989    // BP-level alias lookup. See `TaskLaunchRequest::operator_sid` doc for the
990    // disconnected-vs-unknown distinction.
991    if let Some(sid) = &req.operator_sid {
992        let known_ids = state.engine.list_operator_ids().await;
993        if !known_ids.iter().any(|id| id == sid) {
994            return Err(ApiError::bad_request(format!(
995                "operator_sid: no such registered operator session '{sid}'"
996            )));
997        }
998        op_req.operator_backend_id = Some(sid.clone());
999    }
1000
1001    // GH #33 Guard 2 ceiling resolution: request field > server config >
1002    // built-in default (300s, `config::default_sync_timeout_secs`).
1003    // Validated up front — before any TaskRecord/RunRecord side effects —
1004    // so a caller-supplied `Some(0)` fails fast with `400` rather than
1005    // minting records for a launch that was never going to dispatch.
1006    // GH #37: `detach: true` makes the sync ceiling meaningless (the
1007    // detached run is bounded by `ttl_secs` alone) — combining the two
1008    // is rejected here, same fail-fast-before-side-effects ordering.
1009    let detach = req.detach;
1010    let sync_timeout_secs = match (detach, req.timeout_secs) {
1011        (true, Some(_)) => {
1012            return Err(ApiError::bad_request(
1013                "timeout_secs is the synchronous launch ceiling and does not apply to a \
1014                 detached launch (detach: true), whose lifetime bound is ttl_secs — omit \
1015                 timeout_secs"
1016                    .into(),
1017            ));
1018        }
1019        (false, Some(0)) => {
1020            return Err(ApiError::bad_request(
1021                "timeout_secs: 0 is invalid; omit the field to use the server default".into(),
1022            ));
1023        }
1024        (false, Some(v)) => v,
1025        (_, None) => state.sync_timeout_secs,
1026    };
1027
1028    // GH #33 Guard 1: operator readiness precheck. Coarse signal — this
1029    // handler can cheaply see whether the request/BP references an
1030    // operator backend (`operator.operator_backend_id`, set directly or
1031    // resolved above from `operator_sid`), but not the full
1032    // `OperatorDelegateMiddleware` routing decision (that also considers
1033    // BP-level `kind` tiers, resolved only at dispatch time). When a
1034    // backend is referenced and *zero* operators are attached at all,
1035    // fail fast rather than dispatching into a session nothing can serve.
1036    // A launch this coarse check cannot positively identify as
1037    // operator-delegate is never rejected here — Guard 2 (the timeout
1038    // wrap below) still covers the hang in that case.
1039    if let Some(backend_id) = op_req.operator_backend_id.as_deref() {
1040        let attached = state.engine.list_operator_ids().await;
1041        if attached.is_empty() {
1042            return Err(ApiError::unavailable(format!(
1043                "no operator attached to serve this launch (operator backend '{backend_id}' \
1044                 requested): attach an operator via POST /v1/operators + WS, or use the \
1045                 poll-style flow (GET /v1/worker/prompt + POST /v1/worker/submit)"
1046            )));
1047        }
1048    }
1049
1050    // "Runtime Global" tier: `Some(_)` — including `Some(Automate)` — is
1051    // always an explicit request that outranks the BP-level tiers; an
1052    // absent/unset `kind` in the request body stays `None`, leaving the
1053    // BP-level tiers (`OperatorDef.kind` / `Blueprint.default_operator_kind`)
1054    // to decide instead of eagerly defaulting to `Automate`.
1055    let operator_kind = op_req
1056        .kind
1057        .as_deref()
1058        .map(parse_operator_kind_str)
1059        .transpose()?;
1060    let operator_id = op_req.id.unwrap_or_else(|| "http-run".to_string());
1061    // "Runtime Agent-level" tier: per-agent overrides. Absent/empty = no
1062    // override for any agent, letting the BP-level tiers decide per agent.
1063    let mut operator_kind_overrides: HashMap<String, OperatorKind> = HashMap::new();
1064    for (agent, kind_str) in op_req.per_agent_kinds.take().unwrap_or_default() {
1065        operator_kind_overrides.insert(agent, parse_operator_kind_str(&kind_str)?);
1066    }
1067
1068    let blueprint: AppBlueprintRef = match req.blueprint {
1069        AppBlueprintRef::Inline { value } => AppBlueprintRef::Inline { value },
1070        AppBlueprintRef::Id { id, version } => AppBlueprintRef::Id { id, version },
1071    };
1072
1073    // TTL resolution cascade: (1) request body value, (2) BP metadata `default_run_ttl_secs`,
1074    // (3) server global default (`default_run_ttl()`, 1800s).
1075    let (ttl_secs, ttl_source) = match req.ttl_secs {
1076        Some(v) => (v, TtlSource::RequestBody),
1077        None => {
1078            let (resolved_bp, _ver) = state
1079                .task_app
1080                .resolve(&blueprint)
1081                .await
1082                .map_err(|e| ApiError::bad_request(format!("bp resolve: {e}")))?;
1083            match resolved_bp.metadata.default_run_ttl_secs {
1084                Some(v) => (v, TtlSource::BpMetadata),
1085                None => (default_run_ttl(), TtlSource::ServerDefault),
1086            }
1087        }
1088    };
1089
1090    // Build the launch input up front so a snapshot of it can be persisted
1091    // into the RunRecord below — an Interrupted Run is resumed from that
1092    // snapshot (`POST /v1/runs/:id/resume`) under the same run_id.
1093    let input = TaskApplicationInput {
1094        blueprint,
1095        operator_id: operator_id.clone(),
1096        role: Role::Operator,
1097        ttl: Duration::from_secs(ttl_secs),
1098        init_ctx,
1099        operator_kind,
1100        bridge_id: op_req.senior_bridge_id,
1101        hook_id: op_req.spawn_hook_id,
1102        operator_backend_id: op_req.operator_backend_id,
1103        operator_kind_overrides,
1104        task_input: task_input_spec,
1105        // The request-body top-level `check_policy` (tier 1)
1106        // flows straight into the cascade resolved once in
1107        // `TaskLaunchService::launch`.
1108        check_policy: req.check_policy,
1109    };
1110    let input_json = Some(tasks::snapshot_launch_input(&input)?);
1111
1112    // issue #13 ID-hierarchy persistence: mint the work-item identity (Task)
1113    // and this kick's identity (Run) *before* dispatching, so a Task/Run
1114    // pair always exists even if the flow itself fails mid-way (the
1115    // Failed-status paths below still have a row to update).
1116    let task_id = TaskId::new();
1117    let run_id = RunId::new();
1118    let now = tasks::now_secs();
1119    state
1120        .task_store
1121        .create(TaskRecord {
1122            id: task_id.clone(),
1123            goal,
1124            blueprint_ref: blueprint_ref_json,
1125            input_ctx: input_ctx_snapshot,
1126            task_input_spec: task_input_spec_snapshot,
1127            status: TaskRecordStatus::Running,
1128            created_at: now,
1129            updated_at: now,
1130        })
1131        .await
1132        .map_err(ApiError::engine)?;
1133    state
1134        .run_store
1135        .create(RunRecord {
1136            id: run_id.clone(),
1137            task_id: task_id.clone(),
1138            status: RunStatus::Running,
1139            step_entries: Vec::new(),
1140            degradations: Vec::new(),
1141            operator_sid: req.operator_sid.clone(),
1142            result_ref: None,
1143            input_json,
1144            created_at: now,
1145            updated_at: now,
1146        })
1147        .await
1148        .map_err(ApiError::engine)?;
1149
1150    let run_ctx = RunContext::new(run_id.clone(), state.run_store.clone())
1151        .with_replay_store(state.replay_store.clone());
1152
1153    // GH #37 detached launch: the eval driver runs in its own spawned
1154    // task — its lifetime is bound to `ttl_secs`, not to this request's
1155    // future (client disconnect / handler completion cannot cancel it).
1156    // The spawned task owns the run to its terminal status: `finalize_run`
1157    // on completion, or the same best-effort `Failed` marking as Guard 2
1158    // if the ttl ceiling expires first.
1159    if detach {
1160        let bg_state = state.clone();
1161        let bg_task_id = task_id.clone();
1162        let bg_run_id = run_id.clone();
1163        tokio::spawn(async move {
1164            let outcome = match tokio::time::timeout(
1165                Duration::from_secs(ttl_secs),
1166                bg_state.task_app.handle_with_run(input, Some(run_ctx)),
1167            )
1168            .await
1169            {
1170                Ok(outcome) => outcome,
1171                Err(_elapsed) => {
1172                    let reason = json!({
1173                        "error": format!("detached run exceeded {ttl_secs}s ttl ceiling"),
1174                    });
1175                    if let Err(e) = bg_state.run_store.set_result(&bg_run_id, reason).await {
1176                        tracing::warn!(%bg_run_id, error = %e, "run_flow_form: detached ttl set_result failed");
1177                    }
1178                    if let Err(e) = bg_state
1179                        .run_store
1180                        .update_status(&bg_run_id, RunStatus::Failed)
1181                        .await
1182                    {
1183                        tracing::warn!(%bg_run_id, error = %e, "run_flow_form: detached ttl run update_status(Failed) failed");
1184                    }
1185                    if let Err(e) = bg_state
1186                        .task_store
1187                        .update_status(&bg_task_id, TaskRecordStatus::Failed)
1188                        .await
1189                    {
1190                        tracing::warn!(%bg_task_id, error = %e, "run_flow_form: detached ttl task update_status(Failed) failed");
1191                    }
1192                    return;
1193                }
1194            };
1195            // `finalize_run` persists both the Ok and Err outcomes itself;
1196            // the passthrough return value has no consumer here.
1197            let _ = tasks::finalize_run(&bg_state, &bg_task_id, &bg_run_id, outcome).await;
1198        });
1199        return Ok(TaskLaunchReply(
1200            TaskLaunchResponse {
1201                final_ctx: Value::Null,
1202                bound_version: None,
1203                effective_ttl_secs: ttl_secs,
1204                ttl_source,
1205                task_id,
1206                run_id,
1207                status: RunStatus::Running,
1208            },
1209            StatusCode::ACCEPTED,
1210        ));
1211    }
1212
1213    // GH #33 Guard 2: the single await point this handler blocks on. On
1214    // expiry the timed-out future is dropped, cancelling the in-process
1215    // flow eval — the flow is abandoned, not resumed (intended v1
1216    // semantics; stage-granularity resume is a coarser guarantee than
1217    // this handler makes, out of scope here).
1218    let outcome = match tokio::time::timeout(
1219        Duration::from_secs(sync_timeout_secs),
1220        state.task_app.handle_with_run(input, Some(run_ctx)),
1221    )
1222    .await
1223    {
1224        Ok(outcome) => outcome,
1225        Err(_elapsed) => {
1226            // Best effort: mark the Task/Run so they do not stay `Running`
1227            // forever. Reuses the existing `Failed` variant (no new
1228            // schema-crate enum additions) and stashes a reason string
1229            // into `RunRecord.result_ref` — the only free-form field the
1230            // Run schema carries; secondary persistence failures here are
1231            // logged and swallowed, mirroring `tasks::finalize_run`'s
1232            // error-path convention.
1233            let reason = json!({
1234                "error": format!("sync launch exceeded {sync_timeout_secs}s timeout ceiling"),
1235            });
1236            if let Err(e) = state.run_store.set_result(&run_id, reason).await {
1237                tracing::warn!(%run_id, error = %e, "run_flow_form: timeout run set_result failed");
1238            }
1239            if let Err(e) = state
1240                .run_store
1241                .update_status(&run_id, RunStatus::Failed)
1242                .await
1243            {
1244                tracing::warn!(%run_id, error = %e, "run_flow_form: timeout run update_status(Failed) failed");
1245            }
1246            if let Err(e) = state
1247                .task_store
1248                .update_status(&task_id, TaskRecordStatus::Failed)
1249                .await
1250            {
1251                tracing::warn!(%task_id, error = %e, "run_flow_form: timeout task update_status(Failed) failed");
1252            }
1253            return Err(ApiError::timeout(format!(
1254                "sync launch exceeded {sync_timeout_secs}s timeout ceiling: the in-process flow \
1255                 eval was abandoned (dropping the future cancels it); attach an operator that \
1256                 acks promptly (POST /v1/operators + WS), or raise timeout_secs / sync_timeout_secs"
1257            )));
1258        }
1259    };
1260
1261    let out = tasks::finalize_run(state, &task_id, &run_id, outcome)
1262        .await
1263        .map_err(flow_eval_error_to_api_error)?;
1264
1265    Ok(TaskLaunchReply(
1266        TaskLaunchResponse {
1267            final_ctx: out.final_ctx,
1268            bound_version: out.bound_version.map(|v| format!("{:?}", v)),
1269            effective_ttl_secs: ttl_secs,
1270            ttl_source,
1271            task_id,
1272            run_id,
1273            status: RunStatus::Done,
1274        },
1275        StatusCode::OK,
1276    ))
1277}
1278
1279/// issue #19 ST2 direct sibling-field resolver — extracts the three
1280/// Task-level canonical fields (`project_root` / `work_dir` /
1281/// `task_metadata`) once at the wire boundary. Sibling top-level body
1282/// fields take priority; the pre-#19 shape (same key nested inside
1283/// `init_ctx`) is only a fallback for legacy callers. Unlike the ST1
1284/// `resolve_task_level_init_ctx` bridge this replaced, `init_ctx` is
1285/// NOT mutated — the resolved values are handed straight to
1286/// [`mlua_swarm::service::TaskLaunchInput::task_input`], keeping
1287/// `init_ctx` a pure flow-ir eval seed.
1288///
1289/// Returns `None` when all three fields resolve to `None` (no
1290/// middleware is layered onto the spawner stack downstream — the
1291/// [`mlua_swarm::middleware::task_input::TaskInputMiddleware::new_from_fields`]
1292/// contract).
1293fn build_task_input_spec_from_request(
1294    req: &TaskLaunchRequest,
1295) -> Option<mlua_swarm::service::TaskInputSpec> {
1296    let project_root = req.project_root.clone().or_else(|| {
1297        req.init_ctx
1298            .get("project_root")
1299            .and_then(Value::as_str)
1300            .map(String::from)
1301    });
1302    let work_dir = req.work_dir.clone().or_else(|| {
1303        req.init_ctx
1304            .get("work_dir")
1305            .and_then(Value::as_str)
1306            .map(String::from)
1307    });
1308    let task_metadata = req.task_metadata.clone().or_else(|| {
1309        req.init_ctx
1310            .get("task_metadata")
1311            .filter(|v| v.is_object())
1312            .cloned()
1313    });
1314
1315    if project_root.is_none() && work_dir.is_none() && task_metadata.is_none() {
1316        None
1317    } else {
1318        Some(mlua_swarm::service::TaskInputSpec {
1319            project_root,
1320            work_dir,
1321            task_metadata,
1322        })
1323    }
1324}
1325
1326// ─── helpers ─────────────────────────────────────────────────────────────
1327
1328async fn take_session_token(state: &AppState, sid: &str) -> Result<CapToken, ApiError> {
1329    // `sid` on this path is the token nonce itself (a bearer secret), so
1330    // both the map key and the not-found diagnostic use its fingerprint
1331    // (issue #14 — never echo the nonce back in an error body).
1332    let key = mlua_swarm::types::token_fingerprint(sid);
1333    state
1334        .sessions
1335        .lock()
1336        .await
1337        .map
1338        .remove(&key)
1339        .ok_or_else(|| ApiError::not_found(format!("session: fp={key}")))
1340}
1341
1342/// Extracts sid from `Authorization: Bearer <sid>`. Strict — does not accept any other scheme prefix.
1343fn extract_bearer(headers: &HeaderMap) -> Result<String, ApiError> {
1344    let v = headers
1345        .get(AUTHORIZATION)
1346        .ok_or_else(|| ApiError::bad_request("missing Authorization header".into()))?
1347        .to_str()
1348        .map_err(|_| ApiError::bad_request("invalid Authorization header encoding".into()))?;
1349    let sid = v
1350        .strip_prefix("Bearer ")
1351        .ok_or_else(|| ApiError::bad_request("Authorization must be 'Bearer <sid>'".into()))?
1352        .trim();
1353    if sid.is_empty() {
1354        return Err(ApiError::bad_request("Bearer sid is empty".into()));
1355    }
1356    Ok(sid.to_string())
1357}
1358
1359fn parse_role(s: &str) -> Result<Role, ApiError> {
1360    match s.to_ascii_lowercase().as_str() {
1361        "operator" => Ok(Role::Operator),
1362        "worker" => Ok(Role::Worker),
1363        "observer" => Ok(Role::Observer),
1364        "senior" => Ok(Role::Senior),
1365        other => Err(ApiError::bad_request(format!("unknown role: {other}"))),
1366    }
1367}
1368
1369// ─── error type ──────────────────────────────────────────────────────────
1370
1371/// GH #76 error surface: adapter that lifts a [`TaskApplicationError`] into an
1372/// [`ApiError`], surfacing the structured
1373/// [`TaskLaunchError::FlowEval`] fields
1374/// (`failed_step` / `verdict_value` / `partial_ctx`) into the response
1375/// body's `details` object when the abort originated from a Blueprint
1376/// step. Every other error variant collapses to the pre-#76
1377/// `bad_request(format!("run: {e}"))` shape byte-for-byte, so callers
1378/// that only match on the `{"error": message}` message keep working.
1379fn flow_eval_error_to_api_error(e: TaskApplicationError) -> ApiError {
1380    if let TaskApplicationError::Launch(TaskLaunchError::FlowEval {
1381        message,
1382        failed_step,
1383        verdict_value,
1384        partial_ctx,
1385    }) = &e
1386    {
1387        let details = json!({
1388            "failed_step": failed_step,
1389            "verdict_value": verdict_value,
1390            "partial_ctx": partial_ctx,
1391        });
1392        return ApiError::bad_request(format!("run: flow eval: {message}")).with_details(details);
1393    }
1394    ApiError::bad_request(format!("run: {e}"))
1395}
1396
1397/// Uniform error response type for the handlers in this module. Converts to
1398/// a JSON `{"error": message}` body with the given status via [`IntoResponse`].
1399///
1400/// GH #76 error surface: an optional `details` field carries the structured
1401/// [`mlua_swarm::service::TaskLaunchError::FlowEval`] envelope
1402/// (`failed_step` / `verdict_value` / `partial_ctx`) when the abort
1403/// originated from a Blueprint step. When present, the JSON body becomes
1404/// `{"error": message, "details": {...}}` — a pure ADDITIVE schema change
1405/// for consumers that already ignore unknown keys. Absent (the default)
1406/// preserves the pre-#76 `{"error": message}` shape byte-for-byte for
1407/// every other error site.
1408#[derive(Debug)]
1409pub struct ApiError {
1410    status: StatusCode,
1411    message: String,
1412    details: Option<Value>,
1413}
1414
1415impl ApiError {
1416    /// Wraps an engine-side error as `500 Internal Server Error`.
1417    pub fn engine(e: impl std::fmt::Display) -> Self {
1418        Self {
1419            status: StatusCode::INTERNAL_SERVER_ERROR,
1420            message: format!("engine: {e}"),
1421            details: None,
1422        }
1423    }
1424    /// Builds a `404 Not Found` with the given message.
1425    pub fn not_found(m: String) -> Self {
1426        Self {
1427            status: StatusCode::NOT_FOUND,
1428            message: m,
1429            details: None,
1430        }
1431    }
1432    /// Builds a `400 Bad Request` with the given message.
1433    pub fn bad_request(m: String) -> Self {
1434        Self {
1435            status: StatusCode::BAD_REQUEST,
1436            message: m,
1437            details: None,
1438        }
1439    }
1440    /// Builds a `409 Conflict` with the given message (`POST
1441    /// /v1/runs/:id/resume` — the Run is not `Interrupted`, or a concurrent
1442    /// resume already won the `Interrupted -> Running` compare-and-set).
1443    pub fn conflict(m: String) -> Self {
1444        Self {
1445            status: StatusCode::CONFLICT,
1446            message: m,
1447            details: None,
1448        }
1449    }
1450    /// Builds a `503 Service Unavailable` with the given message (GH #33
1451    /// Guard 1 — operator readiness precheck).
1452    pub fn unavailable(m: String) -> Self {
1453        Self {
1454            status: StatusCode::SERVICE_UNAVAILABLE,
1455            message: m,
1456            details: None,
1457        }
1458    }
1459    /// Builds a `504 Gateway Timeout` with the given message (GH #33
1460    /// Guard 2 — sync launch timeout ceiling).
1461    pub fn timeout(m: String) -> Self {
1462        Self {
1463            status: StatusCode::GATEWAY_TIMEOUT,
1464            message: m,
1465            details: None,
1466        }
1467    }
1468    /// Builds a `410 Gone` with the given message (GH #37 — worker
1469    /// submit/artifact addressed at a Run that already reached a terminal
1470    /// status; the silent-`204`-then-orphan alternative is the failure
1471    /// shape this replaces).
1472    pub fn gone(m: String) -> Self {
1473        Self {
1474            status: StatusCode::GONE,
1475            message: m,
1476            details: None,
1477        }
1478    }
1479    /// Builds a `413 Payload Too Large` with the given message (GH #42 —
1480    /// `@file:` sentinel resolves to a file larger than the shared
1481    /// `DefaultBodyLimit`; same size ceiling as the inline body path).
1482    pub fn payload_too_large(m: String) -> Self {
1483        Self {
1484            status: StatusCode::PAYLOAD_TOO_LARGE,
1485            message: m,
1486            details: None,
1487        }
1488    }
1489    /// Builds a `422 Unprocessable Entity` with the given message (GH #50
1490    /// — a `worker_submit` / `worker_artifact` value violates the
1491    /// dispatching agent's declared `VerdictContract`: rejected before it
1492    /// reaches `submit_worker_result_trusted` / `stage_worker_artifact_trusted`,
1493    /// i.e. before it can land in the flow ctx).
1494    pub fn unprocessable(m: impl Into<String>) -> Self {
1495        Self {
1496            status: StatusCode::UNPROCESSABLE_ENTITY,
1497            message: m.into(),
1498            details: None,
1499        }
1500    }
1501    /// GH #76 error surface: attach a structured details payload alongside the
1502    /// string message. Consumed by [`IntoResponse`] to emit
1503    /// `{"error": message, "details": {...}}`. The current sole caller
1504    /// is `run_flow_form`'s `TaskApplicationError::Launch(FlowEval)` arm,
1505    /// which lifts `failed_step` / `verdict_value` / `partial_ctx` out of
1506    /// the structured [`mlua_swarm::service::TaskLaunchError::FlowEval`]
1507    /// variant into this field.
1508    pub fn with_details(mut self, details: Value) -> Self {
1509        self.details = Some(details);
1510        self
1511    }
1512}
1513
1514impl IntoResponse for ApiError {
1515    fn into_response(self) -> Response {
1516        let body = match self.details {
1517            Some(details) => json!({"error": self.message, "details": details}),
1518            None => json!({"error": self.message}),
1519        };
1520        (self.status, Json(body)).into_response()
1521    }
1522}
1523
1524fn default_run_ttl() -> u64 {
1525    // 1800s (= 30 min). Prevents op_token expiry across a flow.ir multi-step chain
1526    // (= 5+ SubAgent dispatches at 30–60s each). Origin: the observed fvloop smoke
1527    // where a post-gate mock-commit dispatch blew past 300s and expired — sibling of worker_token TTL.
1528    1800
1529}
1530
1531/// TTL cascade resolve helper (Blueprint metadata → server default fallback).
1532/// Second-stage fallback, called when the POST `/v1/tasks` body does not set `ttl_secs`.
1533/// (1) If BP metadata `default_run_ttl_secs` is `Some`, use it.
1534/// (2) If `None`, fall back to the server global `default_run_ttl()` (1800s).
1535///
1536/// # Full cascade (combined in `run_flow_form`)
1537///
1538/// - request body `ttl_secs=Some(v)` → v (this helper is not called)
1539/// - request body `None` + metadata `Some(v)` → v
1540/// - request body `None` + metadata `None` → `default_run_ttl()` = 1800s
1541#[cfg(test)]
1542fn resolve_ttl_from_metadata(metadata_ttl: Option<u64>) -> u64 {
1543    metadata_ttl.unwrap_or_else(default_run_ttl)
1544}
1545
1546#[cfg(test)]
1547mod tests {
1548    use super::*;
1549
1550    /// TTL cascade case 1: when the request body sets it, that value is used as-is
1551    /// (upper branch that does not go through the helper; semantic verify of the
1552    /// `Some(v) => v` direct-return path in `run_flow_form`).
1553    #[test]
1554    fn ttl_cascade_request_body_wins_over_metadata() {
1555        let req_ttl: Option<u64> = Some(100);
1556        let metadata_ttl: Option<u64> = Some(3600);
1557        let effective = match req_ttl {
1558            Some(v) => v,
1559            None => resolve_ttl_from_metadata(metadata_ttl),
1560        };
1561        assert_eq!(
1562            effective, 100,
1563            "request body ttl_secs=100 must win over metadata=3600 (cascade priority (1) > (2))"
1564        );
1565    }
1566
1567    /// TTL cascade case 2: request body omitted + BP metadata `Some(N)` → `N` is effective.
1568    #[test]
1569    fn ttl_cascade_metadata_used_when_body_missing() {
1570        let req_ttl: Option<u64> = None;
1571        let metadata_ttl: Option<u64> = Some(3600);
1572        let effective = match req_ttl {
1573            Some(v) => v,
1574            None => resolve_ttl_from_metadata(metadata_ttl),
1575        };
1576        assert_eq!(
1577            effective, 3600,
1578            "body None + metadata=3600 must resolve to 3600 (cascade (2))"
1579        );
1580    }
1581
1582    /// TTL cascade case 3: request body omitted + BP metadata `None` → server default (1800s).
1583    #[test]
1584    fn ttl_cascade_server_default_when_both_missing() {
1585        let req_ttl: Option<u64> = None;
1586        let metadata_ttl: Option<u64> = None;
1587        let effective = match req_ttl {
1588            Some(v) => v,
1589            None => resolve_ttl_from_metadata(metadata_ttl),
1590        };
1591        assert_eq!(
1592            effective,
1593            default_run_ttl(),
1594            "body None + metadata None must fall back to default_run_ttl() = 1800s"
1595        );
1596        assert_eq!(effective, 1800, "default_run_ttl() literal = 1800s");
1597    }
1598
1599    /// Helper unit: metadata `None` → 1800 (server default expansion).
1600    #[test]
1601    fn resolve_ttl_from_metadata_none_returns_server_default() {
1602        assert_eq!(resolve_ttl_from_metadata(None), 1800);
1603    }
1604
1605    /// Helper unit: metadata `Some(N)` → `N` (server default ignored).
1606    #[test]
1607    fn resolve_ttl_from_metadata_some_returns_value() {
1608        assert_eq!(resolve_ttl_from_metadata(Some(7200)), 7200);
1609        assert_eq!(resolve_ttl_from_metadata(Some(60)), 60);
1610    }
1611
1612    // ──────────────────────────────────────────────────────────────────
1613    // `TaskLaunchRequest.check_policy` wire field (T5)
1614    // ──────────────────────────────────────────────────────────────────
1615
1616    /// T5: a `POST /v1/tasks` body carrying a top-level `check_policy`
1617    /// deserializes into `TaskLaunchRequest.check_policy` using the
1618    /// snake_case wire form.
1619    #[test]
1620    fn task_launch_request_parses_check_policy_wire_field() {
1621        let body = json!({
1622            "blueprint": { "kind": "id", "id": "some-bp" },
1623            "init_ctx": {},
1624            "check_policy": "silent",
1625        });
1626        let req: TaskLaunchRequest =
1627            serde_json::from_value(body).expect("request must deserialize");
1628        assert_eq!(req.check_policy, Some(CheckPolicy::Silent));
1629    }
1630
1631    /// A body that omits `check_policy` leaves the field `None` (existing
1632    /// clients are unaffected — `#[serde(default)]`).
1633    #[test]
1634    fn task_launch_request_check_policy_defaults_to_none_when_omitted() {
1635        let body = json!({
1636            "blueprint": { "kind": "id", "id": "some-bp" },
1637            "init_ctx": {},
1638        });
1639        let req: TaskLaunchRequest =
1640            serde_json::from_value(body).expect("request must deserialize");
1641        assert_eq!(req.check_policy, None);
1642    }
1643
1644    // ──────────────────────────────────────────────────────────────────
1645    // issue #19 ST2: `build_task_input_spec_from_request` direct resolver
1646    // ──────────────────────────────────────────────────────────────────
1647
1648    fn task_req(
1649        init_ctx: Value,
1650        project_root: Option<&str>,
1651        work_dir: Option<&str>,
1652        task_metadata: Option<Value>,
1653    ) -> TaskLaunchRequest {
1654        TaskLaunchRequest {
1655            blueprint: BlueprintRef::Id {
1656                id: mlua_swarm::blueprint::store::BlueprintId::new("ut"),
1657                version: Default::default(),
1658            },
1659            init_ctx,
1660            project_root: project_root.map(String::from),
1661            work_dir: work_dir.map(String::from),
1662            task_metadata,
1663            ttl_secs: None,
1664            operator: None,
1665            operator_sid: None,
1666            timeout_secs: None,
1667            goal: None,
1668            detach: false,
1669            check_policy: None,
1670        }
1671    }
1672
1673    /// (a) Sibling fields only — no legacy keys in `init_ctx` — are
1674    /// returned in the `TaskInputSpec` unchanged. `init_ctx` itself is
1675    /// untouched by this resolver (checked separately at the call site).
1676    #[test]
1677    fn build_task_input_spec_from_request_returns_sibling_fields_when_present() {
1678        let req = task_req(
1679            json!({"free": "form"}),
1680            Some("/repo/sibling"),
1681            Some("/repo/sibling/work"),
1682            Some(json!({"issue": 19})),
1683        );
1684        let spec = build_task_input_spec_from_request(&req).expect("spec must be Some");
1685        assert_eq!(spec.project_root.as_deref(), Some("/repo/sibling"));
1686        assert_eq!(spec.work_dir.as_deref(), Some("/repo/sibling/work"));
1687        assert_eq!(spec.task_metadata, Some(json!({"issue": 19})));
1688    }
1689
1690    /// (b) No sibling fields — the pre-#19 shape (same 3 keys nested
1691    /// inside `init_ctx`) is used as the fallback source.
1692    #[test]
1693    fn build_task_input_spec_from_request_falls_back_to_legacy_init_ctx_shape() {
1694        let req = task_req(
1695            json!({
1696                "project_root": "/repo/legacy",
1697                "work_dir": "/repo/legacy/work",
1698                "task_metadata": {"issue": 17},
1699            }),
1700            None,
1701            None,
1702            None,
1703        );
1704        let spec = build_task_input_spec_from_request(&req).expect("spec must be Some");
1705        assert_eq!(spec.project_root.as_deref(), Some("/repo/legacy"));
1706        assert_eq!(spec.work_dir.as_deref(), Some("/repo/legacy/work"));
1707        assert_eq!(spec.task_metadata, Some(json!({"issue": 17})));
1708    }
1709
1710    /// (c) Both present — the sibling field must win over the legacy
1711    /// `init_ctx`-nested value.
1712    #[test]
1713    fn build_task_input_spec_from_request_sibling_wins_over_legacy_shape() {
1714        let req = task_req(
1715            json!({
1716                "project_root": "/repo/legacy",
1717                "work_dir": "/repo/legacy/work",
1718                "task_metadata": {"issue": 17},
1719            }),
1720            Some("/repo/sibling"),
1721            Some("/repo/sibling/work"),
1722            Some(json!({"issue": 19})),
1723        );
1724        let spec = build_task_input_spec_from_request(&req).expect("spec must be Some");
1725        assert_eq!(
1726            spec.project_root.as_deref(),
1727            Some("/repo/sibling"),
1728            "sibling field must win over the legacy init_ctx-nested value"
1729        );
1730        assert_eq!(spec.work_dir.as_deref(), Some("/repo/sibling/work"));
1731        assert_eq!(spec.task_metadata, Some(json!({"issue": 19})));
1732    }
1733
1734    /// (d) All three fields absent from both sibling and legacy shapes —
1735    /// resolver returns `None`, and no middleware is layered downstream.
1736    #[test]
1737    fn build_task_input_spec_from_request_returns_none_when_no_fields_present() {
1738        let req = task_req(json!({"unrelated": "value"}), None, None, None);
1739        assert!(build_task_input_spec_from_request(&req).is_none());
1740    }
1741
1742    /// Minimal `AppState` for the `status_get` handler-fn-direct-call test
1743    /// below — same construction shape as `tasks.rs::test_state()`
1744    /// (mirrors what `build_router_full` does internally, skipping the
1745    /// `Router` wrapper).
1746    fn status_test_state() -> AppState {
1747        let engine = Engine::new(mlua_swarm::EngineCfg::default());
1748        let compiler = mlua_swarm::Compiler::new(default_registry());
1749        let launch = Arc::new(mlua_swarm::TaskLaunchService::new(engine.clone(), compiler));
1750        AppState {
1751            engine,
1752            sessions: Arc::new(Mutex::new(SessionStore::default())),
1753            task_app: Arc::new(mlua_swarm::TaskApplication::new_inline_only(launch)),
1754            ws_operator_factory: None,
1755            data_store: Arc::new(mlua_swarm::store::output::InMemoryOutputStore::new()),
1756            operator_sessions: Arc::new(Mutex::new(HashMap::new())),
1757            roles_to_sid: Arc::new(Mutex::new(HashMap::new())),
1758            task_store: Arc::new(mlua_swarm::store::task::InMemoryTaskStore::new()),
1759            run_store: Arc::new(mlua_swarm::store::run::InMemoryRunStore::new()),
1760            replay_store: Arc::new(mlua_swarm::store::replay::InMemoryReplayStore::new()),
1761            base_url: None,
1762            sync_timeout_secs: 300,
1763        }
1764    }
1765
1766    /// issue #35 ST4 Acceptance Criteria: `GET /v1/status` reports the
1767    /// count of `Running` `Run`s (`RunStore::list_running`) and attached
1768    /// Operator ids (`engine.list_operator_ids()`), called directly as a
1769    /// handler fn (no `Router` wrapper — this crate's established
1770    /// unit-test convention).
1771    #[tokio::test]
1772    async fn status_get_reports_running_runs_and_operators() {
1773        let state = status_test_state();
1774
1775        let now = std::time::SystemTime::now()
1776            .duration_since(std::time::UNIX_EPOCH)
1777            .map(|d| d.as_secs())
1778            .unwrap_or(0);
1779        state
1780            .run_store
1781            .create(RunRecord {
1782                id: RunId::new(),
1783                task_id: TaskId::new(),
1784                status: RunStatus::Running,
1785                step_entries: Vec::new(),
1786                degradations: Vec::new(),
1787                operator_sid: None,
1788                result_ref: None,
1789                input_json: None,
1790                created_at: now,
1791                updated_at: now,
1792            })
1793            .await
1794            .expect("seed running RunRecord");
1795
1796        // Throwaway `Operator` impl — only registration/list-count matters
1797        // for this test, `execute` is never dispatched (same idiom as
1798        // `tasks.rs::StallingOperator`).
1799        struct NoopOperator;
1800        #[async_trait::async_trait]
1801        impl mlua_swarm::Operator for NoopOperator {
1802            async fn execute(
1803                &self,
1804                _ctx: &mlua_swarm::Ctx,
1805                _system: Option<String>,
1806                _prompt: Value,
1807                _worker: Option<mlua_swarm::WorkerBinding>,
1808                _worker_token: mlua_swarm::CapToken,
1809            ) -> Result<mlua_swarm::WorkerResult, mlua_swarm::WorkerError> {
1810                unimplemented!("not exercised by this test — only registration/list matters")
1811            }
1812        }
1813        state
1814            .engine
1815            .register_operator("test-op", Arc::new(NoopOperator))
1816            .await;
1817
1818        let Json(resp) = status_get(State(state)).await;
1819        assert_eq!(resp.running_runs, 1);
1820        assert_eq!(resp.attached_operators, 1);
1821    }
1822
1823    // ──────────────────────────────────────────────────────────────────
1824    // GH #76 error surface: ApiError.details + flow_eval_error_to_api_error mapper
1825    // ──────────────────────────────────────────────────────────────────
1826
1827    /// `ApiError::with_details` populates the optional `details` field, and
1828    /// [`IntoResponse`] renders it into the JSON body as a sibling of
1829    /// `error`. Pre-#76 shape (no details) stays byte-for-byte
1830    /// `{"error": message}`.
1831    #[tokio::test]
1832    async fn api_error_details_render_into_response_body() {
1833        use axum::body::to_bytes;
1834        use axum::response::IntoResponse;
1835
1836        // Baseline: no details → pre-#76 shape.
1837        let bare = ApiError::bad_request("something".to_string()).into_response();
1838        let (parts, body) = bare.into_parts();
1839        assert_eq!(parts.status, StatusCode::BAD_REQUEST);
1840        let bytes = to_bytes(body, 1024).await.expect("bare body");
1841        let parsed: serde_json::Value = serde_json::from_slice(&bytes).expect("parse bare");
1842        assert_eq!(parsed, json!({"error": "something"}));
1843
1844        // With details → additive `details` key.
1845        let with_details = ApiError::bad_request("run: flow eval: blocked".to_string())
1846            .with_details(json!({
1847                "failed_step": "gate",
1848                "verdict_value": {"verdict": "BLOCKED"},
1849                "partial_ctx": {"steps": {}},
1850            }));
1851        let resp = with_details.into_response();
1852        let (parts, body) = resp.into_parts();
1853        assert_eq!(parts.status, StatusCode::BAD_REQUEST);
1854        let bytes = to_bytes(body, 4096).await.expect("details body");
1855        let parsed: serde_json::Value = serde_json::from_slice(&bytes).expect("parse details");
1856        assert_eq!(parsed["error"], "run: flow eval: blocked");
1857        assert_eq!(parsed["details"]["failed_step"], "gate");
1858        assert_eq!(parsed["details"]["verdict_value"]["verdict"], "BLOCKED");
1859        assert!(parsed["details"]["partial_ctx"].is_object());
1860    }
1861
1862    /// The mapper lifts the structured `TaskLaunchError::FlowEval` fields
1863    /// into `ApiError.details` while preserving the pre-#76 message prefix
1864    /// (`"run: flow eval: <msg>"`). Regression: every other
1865    /// `TaskApplicationError` variant collapses to the pre-#76 shape (no
1866    /// `details`).
1867    #[test]
1868    fn flow_eval_error_to_api_error_lifts_structural_fields_into_details() {
1869        let err = TaskApplicationError::Launch(TaskLaunchError::FlowEval {
1870            message: "blocked: {\"verdict\":\"BLOCKED\"}".to_string(),
1871            failed_step: Some("gate".to_string()),
1872            verdict_value: Some(json!({"verdict": "BLOCKED"})),
1873            partial_ctx: Some(json!({"steps": {}})),
1874        });
1875        let api_err = flow_eval_error_to_api_error(err);
1876        assert_eq!(api_err.status, StatusCode::BAD_REQUEST);
1877        assert!(
1878            api_err.message.starts_with("run: flow eval: "),
1879            "message must preserve pre-#76 `run: flow eval: <msg>` prefix, got: {}",
1880            api_err.message
1881        );
1882        let details = api_err.details.expect("details must be Some for FlowEval");
1883        assert_eq!(details["failed_step"], "gate");
1884        assert_eq!(details["verdict_value"]["verdict"], "BLOCKED");
1885        assert!(details["partial_ctx"].is_object());
1886    }
1887
1888    /// Regression: a non-`FlowEval` error must still map to a `bad_request`
1889    /// without a `details` field — the pre-#76 shape for e.g.
1890    /// `TaskApplicationError::NoStore`.
1891    #[test]
1892    fn flow_eval_error_to_api_error_non_flow_eval_falls_back_to_message_only() {
1893        let api_err = flow_eval_error_to_api_error(TaskApplicationError::NoStore);
1894        assert_eq!(api_err.status, StatusCode::BAD_REQUEST);
1895        assert!(api_err.message.starts_with("run: "));
1896        assert!(
1897            api_err.details.is_none(),
1898            "non-FlowEval errors must not carry a details field (pre-#76 shape)"
1899        );
1900    }
1901
1902    /// A `FlowEval` with every optional field `None` (upstream flow-ir
1903    /// error path — no dispatcher breadcrumb, no run_ctx snapshot) still
1904    /// lifts into `details` — the shape is `null` per key, which serialize
1905    /// as JSON `null`. Consumers must treat missing / `null` as "not
1906    /// available", both are legitimate.
1907    #[test]
1908    fn flow_eval_error_to_api_error_with_all_none_still_populates_details_with_nulls() {
1909        let err = TaskApplicationError::Launch(TaskLaunchError::FlowEval {
1910            message: "unresolved extern".to_string(),
1911            failed_step: None,
1912            verdict_value: None,
1913            partial_ctx: None,
1914        });
1915        let api_err = flow_eval_error_to_api_error(err);
1916        let details = api_err
1917            .details
1918            .expect("details Some even when fields are None");
1919        assert_eq!(details["failed_step"], Value::Null);
1920        assert_eq!(details["verdict_value"], Value::Null);
1921        assert_eq!(details["partial_ctx"], Value::Null);
1922    }
1923}