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