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