Skip to main content

mlua_swarm_server/
worker.rs

1//! HTTP `/v1/worker/*` endpoints (SubAgent self-fetch path).
2//!
3//! # 7-Entry pointer #6 (Output Event design)
4//!
5//! **This endpoint accesses `OutputStore` directly and does NOT go through the engine.**
6//! It is one of the seven entry points enumerated in project `CLAUDE.md` §"Output Event
7//! Design SoT". For the canonical description, see the crate root doc of
8//! `mlua-swarm-output-store` (`cargo doc -p mlua-swarm-output-store`).
9//!
10//! # Path
11//!
12//! A thin-payload path where a SubAgent (= worker process launched by a MainAI) uses
13//! the capability token it received via WS Spawn to self-fetch its prompt and
14//! submit its result — putting the token in `Authorization: Bearer <encoded CapToken>`.
15//!
16//! ## Routes
17//!
18//! - `GET /v1/worker/prompt?task_id=<tid>` — via `engine.fetch_worker_payload`,
19//!   returns `{task_id, attempt, agent, system?, prompt, context?}`.
20//!   `context.steps` (`projection-adapter` ST5, [`assemble_step_pointers`])
21//!   is assembled fresh on every fetch: a `ContextPolicy.steps`-filtered
22//!   pointer list to preceding steps' OUTPUT, resolved through
23//!   `crate::projection::McpQueryAdapter`'s Data-plane + `result_ref`
24//!   enumeration — no separate MCP tool call needed to discover a prior
25//!   step's OUTPUT.
26//! - `POST /v1/worker/result` with body `{task_id, value, ok}` — appends one `Final`
27//!   to the output tail via `engine.submit_output(Final)` (= the canonical path
28//!   through which the dispatch layer decides Pass/Blocked) and updates
29//!   `task.last_result` via `engine.post_result`.
30//! - `POST /v1/worker/artifact?name=<name>` (GH #36 ST1) — stages one named
31//!   part per POST via `engine.stage_worker_artifact_trusted`. Completing the
32//!   attempt is still `POST /v1/worker/submit` / `/v1/worker/result` — this
33//!   route only stages; the dispatch layer's Final-pull folds every staged
34//!   part into `{"out": <final>, "parts": {<name>: <value>, ...}}`. At
35//!   staging time the submit-time projection sink also materializes the part
36//!   raw to `<ctx-dir>/<name>` (the IN file the next Agent step reads;
37//!   fail-open skip when no `work_dir` / `project_root` resolves).
38//! - `GET /v1/worker/prompt/system?task_id=<tid>&attempt=<n>` (GH #31) —
39//!   raw baked `system` bytes for `(task_id, attempt)`, the `Http`-mode
40//!   fetch target for `system_ref.uri`. Same Bearer flow as
41//!   `/v1/worker/prompt`; body is `text/plain`, not JSON.
42//! - `GET /v1/agents/:name/render-size` (GH #31) — no Bearer required, same
43//!   trust tier as `GET /v1/blueprints/:id/head`. Live per-agent most-recently
44//!   observed render size, backing `bp_doctor`'s post-render check.
45//! - `POST /v1/worker/degradation` (GH #32) — structured JSON `{tool, error,
46//!   fallback, note?}`, same Bearer flow as [`worker_submit`]. An
47//!   **independent channel**: entries are appended to `RunRecord.degradations`
48//!   via `RunStore::append_degradation` directly and never touch
49//!   `OutputStore` / the fold path (Crux invariant 2 — a degradation must
50//!   never surface as step OUTPUT). `step_ref` / `attempt` / `at` are
51//!   server-injected, never trusted from the client. Silent `204` (no
52//!   append) when the dispatch task carries no Run linkage — same
53//!   fail-open contract as [`reject_if_run_terminal`]'s own resolution
54//!   steps, since a pre-run-tracking dispatch has nowhere to record a
55//!   degradation and that must not become a client-visible error.
56//!
57//! ## Bearer authentication
58//!
59//! The Bearer value is the string produced by `CapToken::encode()` (= URL-safe
60//! base64 of serde_json). The server decodes it with `CapToken::decode` and then,
61//! inside the engine, verifies HMAC sig + role × verb gate + TTL via
62//! `verify_token_for_task` (= self-contained capability token; no server-side
63//! store lookup required).
64//!
65//! Tokens are minted during the "2) mint outside the lock" phase of
66//! `engine.dispatch_attempt` (`Role::Worker`, 600s TTL, `scopes=["*"]`).
67//! The verb gate covers `FetchPrompt` / `EmitOutput` / `PostResult` — the worker
68//! leaf capability set (`crate::types::WORKER_LEAF_VERBS`).
69
70use axum::{
71    extract::{Query, State},
72    http::{header, header::AUTHORIZATION, HeaderMap, StatusCode},
73    Json,
74};
75use mlua_swarm::core::agent_context::StepPointer;
76use mlua_swarm::core::step_naming::StepNaming;
77use mlua_swarm::store::run::{DegradationEntry, RunStatus, RunStoreError};
78use mlua_swarm::{CapToken, ContentRef, EngineError, OutputEvent, RunId, StepId, WorkerPayload};
79use mlua_swarm_schema::{ContextPolicy, VerdictChannel};
80use serde::Deserialize;
81use serde_json::Value;
82
83use crate::projection::McpQueryAdapter;
84use crate::{ApiError, AppState};
85
86/// Query params for `GET /v1/worker/prompt`.
87#[derive(Debug, Deserialize)]
88pub struct PromptQuery {
89    /// Task the fetched prompt belongs to; cross-checked against the Bearer
90    /// handle/token. Typed [`StepId`] since issue #14 — the wire shape stays
91    /// a plain string; a bad prefix is rejected at deserialize.
92    pub task_id: StepId,
93}
94
95/// `GET /v1/worker/prompt?task_id=<tid>`. Bearer = encoded `CapToken` or short `wh-` handle.
96/// Thin HTTP wrapper over `engine.fetch_worker_payload` / `fetch_worker_payload_trusted`.
97/// Short-handle path (recommended for SubAgents): handle → task_id
98/// cross-check → trusted fetch.
99/// Full-`CapToken` path: token decode → verify → fetch.
100pub async fn worker_prompt(
101    State(state): State<AppState>,
102    headers: HeaderMap,
103    Query(q): Query<PromptQuery>,
104) -> Result<Json<WorkerPayload>, ApiError> {
105    let task_id = q.task_id;
106    let bearer = extract_bearer_raw(&headers)?;
107    let mut payload = if let Some(handle) = parse_worker_handle(&bearer) {
108        // Short-handle path: verify handle → task_id (security: confirm the handle is bound to this task).
109        let resolved = state
110            .engine
111            .task_id_from_handle(handle)
112            .await
113            .map_err(map_handle_lookup_err)?;
114        if resolved != task_id {
115            return Err(ApiError::bad_request(format!(
116                "handle {handle} is bound to task {resolved}, not {task_id}"
117            )));
118        }
119        state
120            .engine
121            .fetch_worker_payload_trusted(&task_id)
122            .await
123            .map_err(|e| ApiError::engine(format!("fetch_worker_payload_trusted: {e}")))?
124    } else {
125        // Full CapToken path (the alternate Bearer form).
126        let token = CapToken::decode(bearer.trim())
127            .map_err(|e| ApiError::bad_request(format!("invalid token: {e}")))?;
128        state
129            .engine
130            .fetch_worker_payload(&token, &task_id)
131            .await
132            .map_err(|e| ApiError::engine(format!("fetch_worker_payload: {e}")))?
133    };
134    assemble_step_pointers(&state, &mut payload).await;
135    Ok(Json(payload))
136}
137
138/// Assembles `payload.context.steps` — the `ContextPolicy.steps`-filtered
139/// pointer list to preceding steps' OUTPUT (`projection-adapter` ST5's
140/// Worker axis; see `mlua_swarm::core::agent_context`'s module doc).
141/// Resolved fresh on every fetch (not baked at spawn time), so a step
142/// submitted after this agent spawned — but before it fetches its prompt
143/// — is still visible.
144///
145/// GH #23 subtask-3: `resolved_steps` (from
146/// `McpQueryAdapter::list_steps_by_run_id`) always reports the CANONICAL
147/// name (see `crate::projection`'s module doc), so both the self-exclusion
148/// check and the `ContextPolicy` match are done against canonical names —
149/// `payload.agent` (the raw `Step.ref` this fetching agent was dispatched
150/// under) is canonicalized via `Engine::step_naming_for(&payload.task_id)`
151/// (the FETCHING agent's own dispatch id — the same `StepNaming` `Arc`
152/// every step of this Blueprint launch shares, see [`StepNaming`]'s module
153/// doc), and `policy.allows_step` itself is left untouched (schema crate
154/// stays name-agnostic) — [`allows_step_canonical`] is the caller-side seam
155/// that resolves each policy-declared name through the table before
156/// comparing.
157///
158/// No-op (`context.steps` stays empty) when: the payload carries no
159/// `context` at all; the context has no `run_id` (a spawn that never
160/// threaded one through — pre-run-tracking callers, or a spawner stack
161/// without the Run-tracking layer); or the addressed Run cannot be
162/// resolved. All three are fail-open, matching this crate's other
163/// best-effort projection hooks (a missing pointer list must never turn a
164/// would-have-succeeded fetch into a failure).
165async fn assemble_step_pointers(state: &AppState, payload: &mut WorkerPayload) {
166    let Some(context) = payload.context.as_mut() else {
167        return;
168    };
169    let Some(run_id_str) = context.run_id.clone() else {
170        return;
171    };
172    let Ok(run_id) = RunId::parse(run_id_str) else {
173        return;
174    };
175
176    let adapter = McpQueryAdapter::new(
177        state.data_store.clone(),
178        state.run_store.clone(),
179        state.engine.clone(),
180    );
181    let Ok((run, resolved_steps)) = adapter.list_steps_by_run_id(&run_id).await else {
182        return;
183    };
184
185    let naming = state.engine.step_naming_for(&payload.task_id).await;
186    let policy = state
187        .engine
188        .context_policy_for(&payload.task_id, payload.attempt)
189        .await;
190    let self_canonical = naming
191        .as_deref()
192        .and_then(|n| n.canonical_of_producer(&payload.agent))
193        .map(str::to_string)
194        .unwrap_or_else(|| payload.agent.clone());
195
196    let mut pointers = Vec::new();
197    for step in &resolved_steps {
198        if step.name == self_canonical
199            || !allows_step_canonical(&policy, naming.as_deref(), &step.name)
200        {
201            continue;
202        }
203        if let Some((size_bytes, file_path, content_url, sha256)) =
204            crate::projection::resolve_step_pointer_fields(state, &run, step).await
205        {
206            pointers.push(StepPointer {
207                name: step.name.clone(),
208                size_bytes,
209                file_path,
210                content_url,
211                sha256,
212            });
213        }
214    }
215    context.steps = pointers;
216}
217
218/// GH #23 subtask-3: caller-side canonical/alias expansion for
219/// `ContextPolicy.allows_step` — same precedence as
220/// `ContextPolicy::allows_step` itself (`steps_exclude` wins; `steps:
221/// None` = pass-all, `Some(list)` = named-only), but each
222/// policy-declared name is resolved through the Blueprint's `StepNaming`
223/// table before comparison, so a Blueprint author's `steps: [...]` entry
224/// naming either the canonical projection name OR any alias (`Step.ref` /
225/// the `out` ctx-path's top-level segment) matches the same step.
226/// `ContextPolicy::allows_step` (schema crate) is untouched — this is the
227/// GH #23 seam, kept out of the name-agnostic schema type. `naming: None`
228/// degrades to a literal string comparison, byte-identical to
229/// `ContextPolicy::allows_step` itself (defensive-only fallback, matching
230/// `crate::projection::McpQueryAdapter::step_naming_for_run`'s own
231/// contract).
232fn allows_step_canonical(
233    policy: &ContextPolicy,
234    naming: Option<&StepNaming>,
235    canonical_name: &str,
236) -> bool {
237    let resolves_to = |raw: &str| -> bool {
238        match naming {
239            Some(n) => n
240                .resolve(raw)
241                .map(|c| c == canonical_name)
242                .unwrap_or(raw == canonical_name),
243            None => raw == canonical_name,
244        }
245    };
246    if policy
247        .steps_exclude
248        .iter()
249        .any(|excluded| resolves_to(excluded))
250    {
251        return false;
252    }
253    match &policy.steps {
254        None => true,
255        Some(list) => list.iter().any(|included| resolves_to(included)),
256    }
257}
258
259/// Body for `POST /v1/worker/result`.
260#[derive(Debug, Deserialize)]
261pub struct WorkerResultReq {
262    /// Task this result belongs to (looked up together with the Bearer
263    /// token). Typed [`StepId`] since issue #14 (see [`PromptQuery`]).
264    pub task_id: StepId,
265    /// `WorkerResult.value` (= the value returned by the Operator: LLM inference result or tool execution result).
266    pub value: Value,
267    /// `WorkerResult.ok`. `false` makes the dispatch path decide Blocked
268    /// (= same semantics as `OutputEvent::Final { ok: false, .. }` from a
269    /// `SpawnerAdapter`). Defaults to `true`.
270    #[serde(default = "default_ok_true")]
271    pub ok: bool,
272    /// Optional explicit attempt. Normally omitted (= the server looks up `task.attempt`).
273    /// A carry for race-condition tests that need to write to a fixed attempt.
274    #[serde(default)]
275    pub attempt: Option<u32>,
276}
277
278fn default_ok_true() -> bool {
279    true
280}
281
282/// `POST /v1/worker/result`. Bearer = encoded `CapToken`.
283/// Fires `engine.submit_output(Final)` + `engine.post_result`.
284pub async fn worker_result(
285    State(state): State<AppState>,
286    headers: HeaderMap,
287    Json(req): Json<WorkerResultReq>,
288) -> Result<StatusCode, ApiError> {
289    let token = decode_worker_bearer(&headers)?;
290    let task_id = req.task_id.clone();
291
292    // Use body-explicit attempt if provided; otherwise the current task.attempt.
293    let attempt = match req.attempt {
294        Some(n) => n,
295        None => state
296            .engine
297            .task_attempt(&task_id)
298            .await
299            .map_err(|e| ApiError::engine(format!("task_attempt: {e}")))?,
300    };
301
302    let event = OutputEvent::Final {
303        content: ContentRef::Inline {
304            value: req.value.clone(),
305        },
306        ok: req.ok,
307    };
308    // GH #51: completion-time verdict-contract enforcement now runs
309    // inside `Engine::submit_output` itself (see
310    // `map_completion_result`'s doc) — this route previously called no
311    // gate at all.
312    map_completion_result(
313        state
314            .engine
315            .submit_output(&token, &task_id, attempt, event)
316            .await,
317        "submit_output",
318    )?;
319    state
320        .engine
321        .post_result(&token, &task_id, req.value)
322        .await
323        .map_err(|e| ApiError::engine(format!("post_result: {e}")))?;
324    Ok(StatusCode::NO_CONTENT)
325}
326
327/// Body-level protocol prefix recognized by [`worker_submit`] and
328/// [`worker_artifact`] (GH #42). When the trimmed request body starts with
329/// this sentinel, the rest is treated as an absolute path; the file is
330/// read and its contents replace the submitted body. Non-sentinel bodies
331/// are unchanged.
332///
333/// See [`resolve_file_sentinel`] for the resolution rules and guards.
334const FILE_SENTINEL_PREFIX: &str = "@file:";
335
336/// Byte ceiling on the resolved-file body, matching the HTTP
337/// `DefaultBodyLimit` applied to inline bodies at the router (2 MiB, see
338/// the `/v1/worker/submit` layer in `crate::app_router`). Sentinel
339/// bodies bypass that axum body layer (the request itself is small), so
340/// the guard is checked in [`resolve_file_sentinel`] instead.
341const FILE_SENTINEL_MAX_BYTES: u64 = 2 * 1024 * 1024;
342
343/// `AgentContextView.extra` key that opts a step into `@file:` sentinel
344/// resolution (GH #43). Declared through the GH #21 meta channels
345/// (`Blueprint.metas` / `AgentMeta.ctx` / step-level `$step_meta`) and
346/// folded into the view at spawn time by `AgentContextMiddleware`.
347///
348/// Default-deny: absent, or any value other than the strict boolean
349/// `true` (a string `"true"` does not count), rejects the sentinel with
350/// `400`. The v0.9.x line has no sentinel at all, so deny-by-default is
351/// the released-behavior-compatible default; a step whose output
352/// contract legitimately needs file submission opts in with one
353/// declaration.
354const FILE_SENTINEL_ALLOW_KEY: &str = "allow_file_submit";
355
356/// Resolves the `@file:<abs-path>` sentinel (GH #42) when present at the
357/// start of `body_str`. When absent, returns `body_str` unchanged — this
358/// is the byte-for-byte compatible path for all pre-#42 workers.
359///
360/// # Sentinel form
361///
362/// The trimmed body is `@file:<abs-path>` on a single line — a worker
363/// materializes the large payload to a file under its task's `work_dir`
364/// with its existing `Write` capability, then submits the sentinel body
365/// instead of streaming the payload back through the LLM.
366///
367/// # Guards
368///
369/// - Empty / multi-line path → `400`.
370/// - Relative path → `400` (the allowlist works only in
371///   canonicalized-absolute form).
372/// - `AgentContextView` not materialized for `(task_id, attempt)` → `400`
373///   (spawn must have run through `AgentContextMiddleware`; without a
374///   view there is no allowlist root to check against).
375/// - `view.extra[`[`FILE_SENTINEL_ALLOW_KEY`]`]` is not boolean `true` →
376///   `400` (GH #43 — file submission is opt-in per step; default-deny).
377/// - `view.work_dir` is `None` → `400`.
378/// - Canonicalized path is not under canonicalized `work_dir` → `400`
379///   (blocks `..`-escapes and symlinks pointing outside the allowlist).
380/// - File does not exist → `404`.
381/// - File size > [`FILE_SENTINEL_MAX_BYTES`] → `413`.
382/// - Any other I/O / canonicalize error → `500`.
383///
384/// The resolved contents are `trim_end()`-ed to match the inline path's
385/// own trailing-whitespace strip, so the downstream `Value::String` is
386/// observationally identical whether the body arrived inline or via
387/// sentinel.
388async fn resolve_file_sentinel(
389    state: &AppState,
390    task_id: &StepId,
391    attempt: u32,
392    body_str: String,
393) -> Result<String, ApiError> {
394    let Some(rest) = body_str.strip_prefix(FILE_SENTINEL_PREFIX) else {
395        return Ok(body_str);
396    };
397    let path_str = rest.trim();
398    if path_str.is_empty() {
399        return Err(ApiError::bad_request(
400            "@file: sentinel: empty path".to_string(),
401        ));
402    }
403    if path_str.contains('\n') || path_str.contains('\r') {
404        return Err(ApiError::bad_request(
405            "@file: sentinel: path must be a single line".to_string(),
406        ));
407    }
408    let path = std::path::Path::new(path_str);
409    if !path.is_absolute() {
410        return Err(ApiError::bad_request(format!(
411            "@file: sentinel: path must be absolute (got {path_str:?})"
412        )));
413    }
414    let view = state
415        .engine
416        .agent_context_for(task_id, attempt)
417        .await
418        .ok_or_else(|| {
419            ApiError::bad_request(
420                "@file: sentinel: no AgentContextView for this task/attempt \
421                 (spawn must run through AgentContextMiddleware to enable \
422                 sentinel resolution)"
423                    .to_string(),
424            )
425        })?;
426    // GH #43: file submission is opt-in per step (default-deny). Strict
427    // boolean `true` only — folded from the Blueprint meta channels by
428    // `AgentContextMiddleware` at spawn time.
429    if view.extra.get(FILE_SENTINEL_ALLOW_KEY) != Some(&Value::Bool(true)) {
430        return Err(ApiError::bad_request(format!(
431            "@file: sentinel: file submission is not allowed for this step \
432             (declare `{FILE_SENTINEL_ALLOW_KEY}: true` via `$step_meta` / \
433             `AgentMeta.ctx` / `Blueprint.metas`; strict boolean `true` \
434             required)"
435        )));
436    }
437    let work_dir = view.work_dir.ok_or_else(|| {
438        ApiError::bad_request("@file: sentinel: task has no resolved work_dir".to_string())
439    })?;
440    let work_dir_canon = tokio::fs::canonicalize(&work_dir).await.map_err(|e| {
441        ApiError::engine(format!(
442            "@file: sentinel: canonicalize work_dir {work_dir:?}: {e}"
443        ))
444    })?;
445    let path_canon = match tokio::fs::canonicalize(path).await {
446        Ok(p) => p,
447        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
448            return Err(ApiError::not_found(format!(
449                "@file: sentinel: file not found: {path_str}"
450            )));
451        }
452        Err(e) => {
453            return Err(ApiError::engine(format!(
454                "@file: sentinel: canonicalize {path_str:?}: {e}"
455            )));
456        }
457    };
458    if !path_canon.starts_with(&work_dir_canon) {
459        return Err(ApiError::bad_request(format!(
460            "@file: sentinel: path {} is not under work_dir {} (canonicalized: {} vs {})",
461            path_str,
462            work_dir,
463            path_canon.display(),
464            work_dir_canon.display(),
465        )));
466    }
467    let meta = tokio::fs::metadata(&path_canon)
468        .await
469        .map_err(|e| ApiError::engine(format!("@file: sentinel: metadata {path_str:?}: {e}")))?;
470    if meta.len() > FILE_SENTINEL_MAX_BYTES {
471        return Err(ApiError::payload_too_large(format!(
472            "@file: sentinel: file size {} exceeds limit {}",
473            meta.len(),
474            FILE_SENTINEL_MAX_BYTES
475        )));
476    }
477    let bytes = tokio::fs::read(&path_canon)
478        .await
479        .map_err(|e| ApiError::engine(format!("@file: sentinel: read {path_str:?}: {e}")))?;
480    // Match the `trim_end()` the inline path applies (see `worker_submit`).
481    Ok(String::from_utf8_lossy(&bytes).trim_end().to_string())
482}
483
484/// GH #50 (Subtask 2) — submit-time verdict contract gate, shared by
485/// [`worker_submit`] (`channel = Body`) and [`worker_artifact`]
486/// (`channel = Part`, only when `name == "verdict"`). Enforcement Point 2
487/// (the submit-time complement to `Compiler::compile`'s register-time lint
488/// in `mlua_swarm::blueprint::compiler`, Enforcement Point 1) — called
489/// after the final value string is resolved and BEFORE it is handed to
490/// `submit_worker_result_trusted` / `stage_worker_artifact_trusted`, so a
491/// rejected value never reaches the flow ctx.
492///
493/// No-op (`Ok(())`) in every case that must preserve pre-GH-#50 behavior
494/// byte-for-byte:
495/// - the dispatching agent declared no `VerdictContract` at all (opt-in).
496/// - the agent's declared contract addresses the OTHER channel — a
497///   channel/shape mismatch is the compile-time lint's job (Enforcement
498///   Point 1); this gate only validates value membership for the channel
499///   it was called for.
500/// - `value` IS a member of the contract's declared `values`.
501///
502/// `Err(ApiError::unprocessable(..))` (HTTP 422) otherwise, echoing the
503/// expected token set.
504async fn check_verdict_contract(
505    state: &AppState,
506    task_id: &StepId,
507    channel: VerdictChannel,
508    value: &str,
509) -> Result<(), ApiError> {
510    let Some(contract) = state.engine.verdict_contract_for_task(task_id).await else {
511        return Ok(());
512    };
513    if contract.channel != channel {
514        return Ok(());
515    }
516    if contract.values.iter().any(|v| v == value) {
517        return Ok(());
518    }
519    Err(ApiError::unprocessable(format!(
520        "verdict contract violation: {value:?} is not a member of the declared values {:?}",
521        contract.values
522    )))
523}
524
525/// GH #51 — maps the 2 completion-time verdict-contract `EngineError`
526/// variants (raised by the embedded choke point inside
527/// `Engine::submit_worker_result_trusted` / `Engine::submit_output`) to
528/// their `422` HTTP shape; every other `EngineError` variant falls back
529/// to the pre-existing generic `500` `ApiError::engine` wrapping,
530/// unchanged. Shared by [`worker_submit`] and [`worker_result`] — both
531/// routes surface the SAME embedded engine-side check, so their
532/// HTTP-layer error translation is identical too (this is HTTP
533/// status-code translation, not the verdict-contract logic itself, which
534/// stays the single engine-side choke point per GH #51's "not duplicated
535/// into each route handler" constraint).
536///
537/// `context` labels the wrapped `EngineError`'s `Display` text for the
538/// fallback `500` case only, matching the pre-existing
539/// `format!("<call>: {e}")` style each call site used before this
540/// helper.
541fn map_completion_result<T>(result: Result<T, EngineError>, context: &str) -> Result<T, ApiError> {
542    result.map_err(|e| match e {
543        EngineError::VerdictValueRejected { value, allowed } => ApiError::unprocessable(format!(
544            "verdict contract violation: {value:?} is not a member of the declared values {allowed:?}"
545        )),
546        EngineError::VerdictPartMissing { allowed } => ApiError::unprocessable(format!(
547            "verdict contract violation: no staged \"verdict\" part found for this attempt; declared values {allowed:?}"
548        )),
549        other => ApiError::engine(format!("{context}: {other}")),
550    })
551}
552
553/// `POST /v1/worker/submit`. Bearer = encoded `CapToken`. Body = raw text/octet.
554///
555/// Simplification-axis endpoint for SubAgents. Removes the JSON construction,
556/// duplicated `task_id`, and JSON-escape burden of `/v1/worker/result` — the
557/// worker completes a POST with just token + raw body. Origin: the recent clean-up
558/// of the SubAgent contract drift (fewer IDs to pass around, multi-line escape
559/// accidents eliminated).
560///
561/// **GH #42 `@file:` sentinel**: workers whose result body is too large to
562/// re-emit inline (multi-KB structured output) may `Write` the payload to
563/// a file under their task's `work_dir` and submit the body
564/// `@file:<abs-path>` instead — see [`resolve_file_sentinel`].
565/// Non-sentinel bodies pass through unchanged. The step must opt in via
566/// `allow_file_submit: true` (GH #43, default-deny — see
567/// [`FILE_SENTINEL_ALLOW_KEY`]).
568///
569/// Behavior:
570/// - `task_id` is auto-looked-up server-side from the token (already bound to the `CapToken`).
571/// - Body raw bytes go as-is into `Value::String` for `submit_output` + `post_result`.
572/// - `ok=true` fixed (= the submit endpoint is success-path only). For the error
573///   path, use `/v1/worker/result` with an explicit `ok=false`.
574#[derive(Debug, Deserialize, Default)]
575pub struct SubmitQuery {
576    /// Optional. `ok=false` signals failure (= `DispatchOutcome::Blocked`, caught
577    /// by the flow.ir Try path). Unspecified (`None`) is treated as `ok=true`
578    /// (= normal success).
579    #[serde(default)]
580    pub ok: Option<bool>,
581}
582
583/// `POST /v1/worker/submit`. Simplified counterpart of [`worker_result`]:
584/// the caller sends only the raw result body, `task_id` is resolved
585/// server-side from the Bearer handle/token, and `ok` defaults to `true`
586/// unless overridden via [`SubmitQuery::ok`]. See the module doc for the
587/// short-handle vs full-`CapToken` Bearer forms.
588pub async fn worker_submit(
589    State(state): State<AppState>,
590    headers: HeaderMap,
591    Query(q): Query<SubmitQuery>,
592    body: axum::body::Bytes,
593) -> Result<StatusCode, ApiError> {
594    // Bearer accepts either (a) `wh-<8 hex>` short handle (recommended for
595    // SubAgents) or (b) base64-wrapped CapToken JSON (the full-token form).
596    let bearer = extract_bearer_raw(&headers)?;
597    let task_id = if let Some(handle) = parse_worker_handle(&bearer) {
598        state
599            .engine
600            .task_id_from_handle(handle)
601            .await
602            .map_err(map_handle_lookup_err)?
603    } else {
604        let token = CapToken::decode(bearer.trim())
605            .map_err(|e| ApiError::bad_request(format!("invalid token: {e}")))?;
606        state
607            .engine
608            .task_id_from_token(&token)
609            .await
610            .map_err(|e| ApiError::engine(format!("task_id_from_token: {e}")))?
611    };
612    let attempt = state
613        .engine
614        .task_attempt(&task_id)
615        .await
616        .map_err(|e| ApiError::engine(format!("task_attempt: {e}")))?;
617    // GH #37: fail loud (410) instead of silently accepting a submit whose
618    // addressed Run is already terminal — see `reject_if_run_terminal`.
619    reject_if_run_terminal(&state, &task_id, attempt).await?;
620    // Strip trailing whitespace (newlines, etc.) so flow.ir `Eq` string matches
621    // don't drift on `"BLOCKED\n" == "BLOCKED"` false results. Origin: the recent clean-up
622    // verdict_loop smoke — sharp-edge removal. Internal `\n` inside the raw bytes
623    // is preserved (= only trailing).
624    let body_str = String::from_utf8_lossy(&body).trim_end().to_string();
625    // GH #42: `@file:<abs-path>` sentinel — pass through unchanged when
626    // absent (byte-for-byte compat with pre-#42 callers).
627    let body_str = resolve_file_sentinel(&state, &task_id, attempt, body_str).await?;
628    // GH #51: the `channel: "body"` submit-time check formerly performed
629    // here (`check_verdict_contract(&state, &task_id, VerdictChannel::Body,
630    // ..)`) is now performed inside `Engine::submit_worker_result_trusted`
631    // itself — the single completion-time choke point shared by all 3
632    // completion routes (see `map_completion_result`'s doc). No separate
633    // call is needed here; `check_verdict_contract` remains in use by
634    // `worker_artifact`'s staging-time `name == "verdict"` early
635    // validation, unchanged.
636    let value = Value::String(body_str);
637
638    // The handle path = trusted internal API (= the server-minted handle is validated
639    // by the earlier lookup); the full-token path = existing verify-by-token API.
640    // Both are reflected identically into final + last_result.
641    // `?ok=false` in the query signals failure (= `DispatchOutcome::Blocked`,
642    // the flow.ir Try catch path).
643    let ok = q.ok.unwrap_or(true);
644    map_completion_result(
645        state
646            .engine
647            .submit_worker_result_trusted(&task_id, attempt, value, ok)
648            .await,
649        "submit_worker_result_trusted",
650    )?;
651    Ok(StatusCode::NO_CONTENT)
652}
653
654/// Query params for `POST /v1/worker/artifact`.
655#[derive(Debug, Deserialize)]
656pub struct ArtifactQuery {
657    /// Artifact name (GH #36 ST1: named multi-part worker output). Required
658    /// and non-empty (400 otherwise) — becomes the object key
659    /// `Engine::dispatch_attempt_with`'s Final-pull folds this part under
660    /// (`{"out": <final>, "parts": {<name>: <value>, ...}}`, see that
661    /// method's doc). No character restriction is enforced here (a BP
662    /// author references it via bracket notation, e.g. `$.out.parts["a.b"]`).
663    pub name: String,
664}
665
666/// `POST /v1/worker/artifact?name=<name>`. Bearer = same short-handle /
667/// full-`CapToken` forms as [`worker_submit`]. Body = raw text/octet.
668///
669/// Simplification-axis sibling of [`worker_submit`] (GH #36 ST1): lets a
670/// worker with more than one named result POST each part independently —
671/// same 1-part-per-POST simplicity as `/v1/worker/submit`, no Single Big
672/// JSON the worker has to construct/escape itself — then complete the
673/// attempt with an ordinary `/v1/worker/submit` (unchanged). Staging alone
674/// never completes the attempt; `dispatch_attempt_with` only pulls the
675/// tail's `Final` (whichever endpoint submits it) and folds every staged
676/// `Artifact` into `"parts"` at that point.
677///
678/// Behavior:
679/// - `task_id` is auto-looked-up server-side from the token/handle, same as
680///   [`worker_submit`].
681/// - `name` is required and non-empty; missing or blank → 400.
682/// - Body raw bytes go as-is into `Value::String` (same trailing-whitespace
683///   trim as `worker_submit`) and are staged via
684///   [`mlua_swarm::core::engine::Engine::stage_worker_artifact_trusted`].
685/// - Staging the same `name` twice within one attempt: last write wins (the
686///   Final-pull fold walks the tail in event order — see its doc).
687pub async fn worker_artifact(
688    State(state): State<AppState>,
689    headers: HeaderMap,
690    Query(q): Query<ArtifactQuery>,
691    body: axum::body::Bytes,
692) -> Result<StatusCode, ApiError> {
693    let name = q.name.trim();
694    if name.is_empty() {
695        return Err(ApiError::bad_request("name must not be empty".into()));
696    }
697    let name = name.to_string();
698
699    let bearer = extract_bearer_raw(&headers)?;
700    let task_id = if let Some(handle) = parse_worker_handle(&bearer) {
701        state
702            .engine
703            .task_id_from_handle(handle)
704            .await
705            .map_err(map_handle_lookup_err)?
706    } else {
707        let token = CapToken::decode(bearer.trim())
708            .map_err(|e| ApiError::bad_request(format!("invalid token: {e}")))?;
709        state
710            .engine
711            .task_id_from_token(&token)
712            .await
713            .map_err(|e| ApiError::engine(format!("task_id_from_token: {e}")))?
714    };
715    let attempt = state
716        .engine
717        .task_attempt(&task_id)
718        .await
719        .map_err(|e| ApiError::engine(format!("task_attempt: {e}")))?;
720    // GH #37: fail loud (410) instead of silently staging a part whose
721    // addressed Run is already terminal — see `reject_if_run_terminal`.
722    reject_if_run_terminal(&state, &task_id, attempt).await?;
723    let body_str = String::from_utf8_lossy(&body).trim_end().to_string();
724    // GH #42: same `@file:<abs-path>` sentinel as `worker_submit`.
725    let body_str = resolve_file_sentinel(&state, &task_id, attempt, body_str).await?;
726    // GH #50: submit-time verdict contract gate (Enforcement Point 2),
727    // ONLY for the literal `"verdict"` part name (Pattern B's staging
728    // channel — see `blueprint-authoring.md`'s "Returning verdicts to
729    // drive BP flow" section). Every other part name skips the gate
730    // entirely, unchanged from pre-GH-#50 behavior.
731    if name == "verdict" {
732        check_verdict_contract(&state, &task_id, VerdictChannel::Part, &body_str).await?;
733    }
734    let value = Value::String(body_str);
735
736    state
737        .engine
738        .stage_worker_artifact_trusted(&task_id, attempt, name, value)
739        .await
740        .map_err(|e| ApiError::engine(format!("stage_worker_artifact_trusted: {e}")))?;
741    Ok(StatusCode::NO_CONTENT)
742}
743
744/// Body for `POST /v1/worker/degradation` (GH #32).
745#[derive(Debug, Deserialize)]
746pub struct DegradationBody {
747    /// The tool (or capability) the worker attempted to use.
748    pub tool: String,
749    /// The error that triggered the fallback, in the worker's own words.
750    pub error: String,
751    /// What the worker substituted instead of failing.
752    pub fallback: String,
753    /// Optional free-form context from the worker.
754    #[serde(default)]
755    pub note: Option<String>,
756}
757
758/// `POST /v1/worker/degradation` (GH #32). Bearer = same short-handle /
759/// full-`CapToken` forms as [`worker_submit`]. Body = JSON, not raw bytes —
760/// this endpoint carries structured data, unlike its raw-bytes siblings.
761///
762/// Independent channel: appends a [`DegradationEntry`] to
763/// `RunRecord.degradations` via `RunStore::append_degradation` directly.
764/// Never touches `OutputStore` / the fold path (Crux invariant 2 — a
765/// degradation must not surface as step OUTPUT / `$.step.parts`).
766///
767/// Behavior:
768/// - `task_id` is auto-looked-up server-side from the token/handle, same as
769///   [`worker_submit`] / [`worker_artifact`].
770/// - GH #37 terminal-run guard applies first — a degradation addressed at
771///   an already-terminal Run is rejected with `410 Gone`
772///   ([`reject_if_run_terminal`]), same as a submit/artifact would be.
773/// - `step_ref` / `attempt` / `at` are server-injected — `step_ref` is the
774///   fetching agent's resolved name (`AgentContextView.agent`, the best
775///   proxy for `Step.ref` available at this layer), `attempt` is the
776///   task's current attempt, `at` is now (Unix epoch seconds). The client
777///   body never supplies any of the three.
778/// - No Run linkage in `agent_ctx` (a pre-run-tracking dispatch), an
779///   unparseable `run_id`, or an `append_degradation` call against a Run
780///   the store doesn't actually hold (`RunStoreError::NotFound` — the same
781///   condition [`reject_if_run_terminal`] itself fails open on) all take
782///   the same silent `204 No Content` path, logged via `tracing::warn!` —
783///   this is a legitimate no-tracking codepath, not a client error. Any
784///   other `RunStore` failure propagates as `ApiError::engine`.
785pub async fn worker_degradation(
786    State(state): State<AppState>,
787    headers: HeaderMap,
788    Json(body): Json<DegradationBody>,
789) -> Result<StatusCode, ApiError> {
790    let bearer = extract_bearer_raw(&headers)?;
791    let task_id = if let Some(handle) = parse_worker_handle(&bearer) {
792        state
793            .engine
794            .task_id_from_handle(handle)
795            .await
796            .map_err(map_handle_lookup_err)?
797    } else {
798        let token = CapToken::decode(bearer.trim())
799            .map_err(|e| ApiError::bad_request(format!("invalid token: {e}")))?;
800        state
801            .engine
802            .task_id_from_token(&token)
803            .await
804            .map_err(|e| ApiError::engine(format!("task_id_from_token: {e}")))?
805    };
806    let attempt = state
807        .engine
808        .task_attempt(&task_id)
809        .await
810        .map_err(|e| ApiError::engine(format!("task_attempt: {e}")))?;
811    // GH #37: the same terminal-run guard `worker_submit` / `worker_artifact`
812    // apply — a dead Run must not accumulate signals.
813    reject_if_run_terminal(&state, &task_id, attempt).await?;
814
815    // Same `with_state` resolution pattern as `reject_if_run_terminal`: an
816    // engine-level failure here is fail-open too (`_ => ...`), matching
817    // that guard's own "every resolution step is fail-open" contract —
818    // this lookup isn't a second, stricter gate on top of it.
819    let tid = task_id.clone();
820    let (run_id_str, agent) = match state
821        .engine
822        .with_state("worker_degradation_run_lookup", move |s| {
823            s.agent_ctx.get(&(tid, attempt)).and_then(|e| {
824                e.view
825                    .run_id
826                    .clone()
827                    .map(|run_id| (run_id, e.view.agent.clone()))
828            })
829        })
830        .await
831    {
832        Ok(Some(pair)) => pair,
833        _ => {
834            tracing::warn!(%task_id, "worker_degradation: no run linkage for this task; entry dropped");
835            return Ok(StatusCode::NO_CONTENT);
836        }
837    };
838    let Ok(run_id) = RunId::parse(run_id_str) else {
839        tracing::warn!(%task_id, "worker_degradation: run_id failed to parse; entry dropped");
840        return Ok(StatusCode::NO_CONTENT);
841    };
842
843    let entry = DegradationEntry {
844        tool: body.tool,
845        error: body.error,
846        fallback: body.fallback,
847        note: body.note,
848        step_ref: Some(agent),
849        attempt: Some(attempt),
850        at: crate::tasks::now_secs(),
851    };
852    match state.run_store.append_degradation(&run_id, entry).await {
853        Ok(()) => Ok(StatusCode::NO_CONTENT),
854        Err(RunStoreError::NotFound(_)) => {
855            tracing::warn!(%task_id, %run_id, "worker_degradation: run not found in run_store; entry dropped");
856            Ok(StatusCode::NO_CONTENT)
857        }
858        Err(e) => Err(ApiError::engine(format!("append_degradation: {e}"))),
859    }
860}
861
862/// GH #37: terminal-run guard shared by [`worker_submit`] / [`worker_artifact`].
863///
864/// Resolves the dispatch task's `AgentContextView.run_id` (threaded at
865/// spawn time when a `RunContext` accompanied the launch) and rejects the
866/// submit with `410 Gone` when the addressed Run has already reached a
867/// terminal status (`Done` / `Failed` / `Interrupted`) — the flow-eval
868/// driver for that Run is gone, so the staged/final value could never be
869/// folded into a flow context. Before this guard, such a submit was
870/// silently accepted with `204` and the worker's output orphaned — the
871/// exact failure shape observed when a long-running worker outlived the
872/// GH #33 sync launch ceiling.
873///
874/// Every resolution step is fail-open (missing agent-ctx entry / missing
875/// `run_id` / unparseable id / unknown Run → `Ok(())`), matching this
876/// crate's other best-effort projection hooks: a pre-run-tracking dispatch
877/// must keep working exactly as before.
878async fn reject_if_run_terminal(
879    state: &AppState,
880    task_id: &StepId,
881    attempt: u32,
882) -> Result<(), ApiError> {
883    let tid = task_id.clone();
884    let run_id_str = match state
885        .engine
886        .with_state("worker_terminal_run_guard", move |s| {
887            s.agent_ctx
888                .get(&(tid, attempt))
889                .and_then(|e| e.view.run_id.clone())
890        })
891        .await
892    {
893        Ok(Some(rid)) => rid,
894        _ => return Ok(()),
895    };
896    let Ok(run_id) = RunId::parse(run_id_str) else {
897        return Ok(());
898    };
899    let Ok(rec) = state.run_store.get(&run_id).await else {
900        return Ok(());
901    };
902    match rec.status {
903        RunStatus::Done | RunStatus::Failed | RunStatus::Interrupted => {
904            Err(ApiError::gone(format!(
905                "run {run_id} is already terminal ({:?}): this attempt's output cannot be \
906                 delivered to a flow context; re-kick the task (POST /v1/tasks/:id/runs) and \
907                 fetch a fresh prompt",
908                rec.status
909            )))
910        }
911        RunStatus::Pending | RunStatus::Running => Ok(()),
912    }
913}
914
915/// Query params for `GET /v1/worker/prompt/system`. Field names are fixed to
916/// `task_id` / `attempt` — this is the exact shape the engine bakes into
917/// `system_ref.uri`'s query string for `Http` mode (GH #31), so the names
918/// here must match verbatim.
919#[derive(Debug, Deserialize)]
920pub struct PromptSystemQuery {
921    /// Task the fetched raw system prompt belongs to; cross-checked
922    /// against the Bearer handle/token, same as [`PromptQuery::task_id`].
923    pub task_id: StepId,
924    /// Attempt number the baked system prompt was recorded under.
925    pub attempt: u32,
926}
927
928/// `GET /v1/worker/prompt/system?task_id=<tid>&attempt=<n>` (GH #31). The
929/// `Http`-mode fetch target for `system_ref.uri`: serves the exact baked
930/// `system` bytes for `(task_id, attempt)` as a raw `text/plain` body — not
931/// JSON-wrapped, since `mse_worker_fetch` needs the precise byte sequence to
932/// sha256-verify against `system_ref.sha256`.
933///
934/// Same Bearer auth flow as [`worker_prompt`] (short handle or full
935/// `CapToken`); 404 via [`ApiError::not_found`] if no baked system exists for
936/// that `(task_id, attempt)`.
937pub async fn worker_prompt_system(
938    State(state): State<AppState>,
939    headers: HeaderMap,
940    Query(q): Query<PromptSystemQuery>,
941) -> Result<impl axum::response::IntoResponse, ApiError> {
942    let task_id = q.task_id;
943    let attempt = q.attempt;
944    let bearer = extract_bearer_raw(&headers)?;
945    if let Some(handle) = parse_worker_handle(&bearer) {
946        let resolved = state
947            .engine
948            .task_id_from_handle(handle)
949            .await
950            .map_err(map_handle_lookup_err)?;
951        if resolved != task_id {
952            return Err(ApiError::bad_request(format!(
953                "handle {handle} is bound to task {resolved}, not {task_id}"
954            )));
955        }
956    } else {
957        let token = CapToken::decode(bearer.trim())
958            .map_err(|e| ApiError::bad_request(format!("invalid token: {e}")))?;
959        state
960            .engine
961            .verify_token_for_task(&token, mlua_swarm::Verb::FetchPrompt, &task_id)
962            .await
963            .map_err(|e| ApiError::engine(format!("verify_token_for_task: {e}")))?;
964    }
965    let system = state
966        .engine
967        .raw_system_prompt(&task_id, attempt)
968        .await
969        .map_err(|e| ApiError::engine(format!("raw_system_prompt: {e}")))?
970        .ok_or_else(|| {
971            ApiError::not_found(format!(
972                "no baked system prompt for task {task_id} attempt {attempt}"
973            ))
974        })?;
975    Ok((
976        [(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
977        system,
978    ))
979}
980
981/// Response body for `GET /v1/agents/:name/render-size`.
982#[derive(Debug, serde::Serialize)]
983pub struct AgentRenderSizeResponse {
984    /// The agent name looked up (echoed back verbatim from the path param).
985    pub agent: String,
986    /// Most-recently-baked `system_prompt` render size in bytes for this
987    /// agent, or `None` if `bake_worker_system_prompt` has never recorded
988    /// one (a freshly-added agent that has never been dispatched).
989    pub last_rendered_bytes: Option<usize>,
990}
991
992/// `GET /v1/agents/:name/render-size` (GH #31). Live per-agent-name lookup
993/// of the most-recently-baked `system_prompt` render size, backing
994/// `bp_doctor`'s post-render size check. No Bearer required — same
995/// unauthenticated trust tier as `GET /v1/blueprints/:id/head`
996/// (`blueprints::get_head`), an operator-diagnostic route.
997///
998/// `last_rendered_bytes: null` is a normal, expected response (a
999/// freshly-added agent that has never been dispatched yet) — always
1000/// `200 OK`, never a 404.
1001pub async fn agent_render_size(
1002    State(state): State<AppState>,
1003    axum::extract::Path(name): axum::extract::Path<String>,
1004) -> Json<AgentRenderSizeResponse> {
1005    let last_rendered_bytes = state.engine.agent_last_rendered_size(&name).await;
1006    Json(AgentRenderSizeResponse {
1007        agent: name,
1008        last_rendered_bytes,
1009    })
1010}
1011
1012/// Extracts the raw string from the `Authorization` header (= strips the `Bearer `
1013/// prefix). To let `worker_submit` accept both short handles and full tokens, we
1014/// fetch the raw value before any decode.
1015fn extract_bearer_raw(headers: &HeaderMap) -> Result<String, ApiError> {
1016    let v = headers
1017        .get(AUTHORIZATION)
1018        .ok_or_else(|| ApiError::bad_request("missing Authorization header".into()))?
1019        .to_str()
1020        .map_err(|_| ApiError::bad_request("invalid Authorization header encoding".into()))?;
1021    let s = v
1022        .strip_prefix("Bearer ")
1023        .ok_or_else(|| ApiError::bad_request("Authorization must be 'Bearer <token>'".into()))?
1024        .trim();
1025    if s.is_empty() {
1026        return Err(ApiError::bad_request("Bearer is empty".into()));
1027    }
1028    Ok(s.to_string())
1029}
1030
1031/// Maps a `task_id_from_handle` failure on the short-handle Bearer path. A
1032/// `wh-`-prefixed handle that parsed as well-formed (via
1033/// [`parse_worker_handle`]) but is unknown to the engine
1034/// (`EngineError::TokenNotFound`) is a handle that was minted before the
1035/// engine's in-memory state was wiped — typically a server restart. "Once
1036/// valid, now gone" is exactly `410 Gone`: the worker should re-kick its
1037/// task and fetch a fresh handle rather than treat this as a server fault.
1038/// Every other engine error stays a `500` (unchanged), same wrapping style
1039/// as the pre-existing call sites.
1040///
1041/// Only reached on the handle path (`parse_worker_handle` returned `Some`),
1042/// so it never re-labels a full-`CapToken` decode/verify failure.
1043fn map_handle_lookup_err(e: EngineError) -> ApiError {
1044    match e {
1045        EngineError::TokenNotFound(_) => ApiError::gone(
1046            "worker handle is no longer valid (the engine's in-flight state was reset, \
1047             e.g. by a server restart): re-kick the task (POST /v1/tasks/:id/runs) and \
1048             fetch a fresh prompt/handle"
1049                .to_string(),
1050        ),
1051        other => ApiError::engine(format!("task_id_from_handle: {other}")),
1052    }
1053}
1054
1055/// Decides whether the Bearer is a short handle (`wh-XXXXXXXX`). Returns
1056/// `Some(handle)` on a match, `None` otherwise (= caller proceeds to try decoding
1057/// as full `CapToken` JSON).
1058fn parse_worker_handle(s: &str) -> Option<&str> {
1059    let s = s.trim();
1060    if s.starts_with("wh-")
1061        && s.len() >= 5
1062        && s.len() <= 64
1063        && s[3..].chars().all(|c| c.is_ascii_alphanumeric())
1064    {
1065        Some(s)
1066    } else {
1067        None
1068    }
1069}
1070
1071/// Decodes an encoded `CapToken` from `Authorization: Bearer <encoded CapToken>`.
1072/// Kept separate from `extract_bearer` (sid-only) — kept as a distinct fn so
1073/// that sid strings and encoded tokens are not confused, distinguishing them by type.
1074fn decode_worker_bearer(headers: &HeaderMap) -> Result<CapToken, ApiError> {
1075    let v = headers
1076        .get(AUTHORIZATION)
1077        .ok_or_else(|| ApiError::bad_request("missing Authorization header".into()))?
1078        .to_str()
1079        .map_err(|_| ApiError::bad_request("invalid Authorization header encoding".into()))?;
1080    let encoded = v
1081        .strip_prefix("Bearer ")
1082        .ok_or_else(|| ApiError::bad_request("Authorization must be 'Bearer <token>'".into()))?
1083        .trim();
1084    if encoded.is_empty() {
1085        return Err(ApiError::bad_request("Bearer token is empty".into()));
1086    }
1087    CapToken::decode(encoded).map_err(|e| ApiError::bad_request(format!("invalid token: {e}")))
1088}
1089
1090// ──────────────────────────────────────────────────────────────────────────
1091// UT — `assemble_step_pointers` (`projection-adapter` ST5 Worker axis)
1092// ──────────────────────────────────────────────────────────────────────────
1093
1094#[cfg(test)]
1095mod tests {
1096    use super::*;
1097    use axum::response::IntoResponse;
1098    use mlua_swarm::core::agent_context::AgentContextView;
1099    use mlua_swarm::core::config::EngineCfg;
1100    use mlua_swarm::core::engine::Engine;
1101    use mlua_swarm::store::output::{InMemoryOutputStore, OutputStore};
1102    use mlua_swarm::store::run::{InMemoryRunStore, RunRecord, RunStatus, RunStore, StepEntry};
1103    use mlua_swarm::store::task::InMemoryTaskStore;
1104    use mlua_swarm::{RunId, StepId, TaskId};
1105    use serde_json::json;
1106    use std::collections::HashMap;
1107    use std::sync::Arc;
1108    use tokio::sync::Mutex;
1109
1110    /// Per-module test-helper convention (this crate's established
1111    /// pattern — see e.g. `projection::tests::test_state`): a minimal
1112    /// `AppState` wired with the caller-supplied `data_store` / `run_store`
1113    /// so a test can seed both directly rather than driving a real
1114    /// dispatch through them.
1115    fn test_state(data_store: Arc<dyn OutputStore>, run_store: Arc<dyn RunStore>) -> AppState {
1116        let engine = Engine::new(EngineCfg::default());
1117        let compiler = mlua_swarm::Compiler::new(crate::default_registry());
1118        let launch = Arc::new(mlua_swarm::TaskLaunchService::new(engine.clone(), compiler));
1119        AppState {
1120            engine,
1121            sessions: Arc::new(Mutex::new(crate::SessionStore::default())),
1122            task_app: Arc::new(mlua_swarm::TaskApplication::new_inline_only(launch)),
1123            ws_operator_factory: None,
1124            data_store,
1125            operator_sessions: Arc::new(Mutex::new(HashMap::new())),
1126            roles_to_sid: Arc::new(Mutex::new(HashMap::new())),
1127            task_store: Arc::new(InMemoryTaskStore::new()),
1128            run_store,
1129            replay_store: Arc::new(mlua_swarm::store::replay::InMemoryReplayStore::new()),
1130            base_url: None,
1131            sync_timeout_secs: 300,
1132        }
1133    }
1134
1135    async fn append_final(
1136        data_store: &Arc<dyn OutputStore>,
1137        task_id: &str,
1138        producer: &str,
1139        value: Value,
1140    ) {
1141        data_store
1142            .append(
1143                task_id,
1144                1,
1145                producer,
1146                OutputEvent::Final {
1147                    content: ContentRef::Inline { value },
1148                    ok: true,
1149                },
1150                vec![],
1151            )
1152            .await
1153            .expect("append final");
1154    }
1155
1156    fn step_entry(step_id: &StepId, step_ref: &str) -> StepEntry {
1157        StepEntry {
1158            step_id: step_id.clone(),
1159            step_ref: Some(step_ref.to_string()),
1160            status: Some("passed".to_string()),
1161            binding_digest: None,
1162            at: 0,
1163        }
1164    }
1165
1166    fn run_record(task_id: &TaskId, run_id: &RunId, step_entries: Vec<StepEntry>) -> RunRecord {
1167        RunRecord {
1168            id: run_id.clone(),
1169            task_id: task_id.clone(),
1170            status: RunStatus::Running,
1171            step_entries,
1172            degradations: Vec::new(),
1173            operator_sid: None,
1174            result_ref: None,
1175            input_json: None,
1176            created_at: 0,
1177            updated_at: 0,
1178        }
1179    }
1180
1181    fn consumer_payload(consumer_step_id: &StepId, run_id: &RunId) -> WorkerPayload {
1182        WorkerPayload {
1183            task_id: consumer_step_id.clone(),
1184            attempt: 1,
1185            agent: "consumer".to_string(),
1186            system: None,
1187            prompt: String::new(),
1188            context: Some(AgentContextView {
1189                task_id: consumer_step_id.to_string(),
1190                agent: "consumer".to_string(),
1191                attempt: 1,
1192                run_id: Some(run_id.to_string()),
1193                ..Default::default()
1194            }),
1195            system_ref: None,
1196        }
1197    }
1198
1199    /// Test 1: `ContextPolicy.steps` unspecified (no policy seeded at all
1200    /// — `Engine::context_policy_for`'s "no entry" default is `None` /
1201    /// pass-all) → the fetch payload carries every submitted step's
1202    /// `StepPointer`.
1203    #[tokio::test]
1204    async fn context_policy_unspecified_yields_every_submitted_step() {
1205        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1206        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1207        let task_id = TaskId::new();
1208        let run_id = RunId::new();
1209        let planner_id = StepId::new();
1210        let coder_id = StepId::new();
1211
1212        append_final(
1213            &data_store,
1214            planner_id.as_str(),
1215            "planner",
1216            json!({"plan": "x"}),
1217        )
1218        .await;
1219        append_final(
1220            &data_store,
1221            coder_id.as_str(),
1222            "coder",
1223            json!({"code": "y"}),
1224        )
1225        .await;
1226        run_store
1227            .create(run_record(
1228                &task_id,
1229                &run_id,
1230                vec![
1231                    step_entry(&planner_id, "planner"),
1232                    step_entry(&coder_id, "coder"),
1233                ],
1234            ))
1235            .await
1236            .expect("create run");
1237
1238        let state = test_state(data_store, run_store);
1239        let consumer_id = StepId::new();
1240        let mut payload = consumer_payload(&consumer_id, &run_id);
1241        assemble_step_pointers(&state, &mut payload).await;
1242
1243        let names: Vec<&str> = payload
1244            .context
1245            .as_ref()
1246            .expect("context")
1247            .steps
1248            .iter()
1249            .map(|p| p.name.as_str())
1250            .collect();
1251        assert!(names.contains(&"planner"), "names: {names:?}");
1252        assert!(names.contains(&"coder"), "names: {names:?}");
1253    }
1254
1255    /// Test 2: `steps: ["planner"]` → only `planner`'s pointer is present.
1256    #[tokio::test]
1257    async fn context_policy_steps_include_list_filters_to_named_steps() {
1258        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1259        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1260        let task_id = TaskId::new();
1261        let run_id = RunId::new();
1262        let planner_id = StepId::new();
1263        let coder_id = StepId::new();
1264        append_final(&data_store, planner_id.as_str(), "planner", json!("x")).await;
1265        append_final(&data_store, coder_id.as_str(), "coder", json!("y")).await;
1266        run_store
1267            .create(run_record(
1268                &task_id,
1269                &run_id,
1270                vec![
1271                    step_entry(&planner_id, "planner"),
1272                    step_entry(&coder_id, "coder"),
1273                ],
1274            ))
1275            .await
1276            .expect("create run");
1277
1278        let state = test_state(data_store, run_store);
1279        let consumer_id = StepId::new();
1280        state
1281            .engine
1282            .with_state("test.seed_policy", {
1283                let consumer_id = consumer_id.clone();
1284                move |s| {
1285                    s.agent_ctx.insert(
1286                        (consumer_id, 1),
1287                        mlua_swarm::core::state::AgentCtxEntry {
1288                            policy: mlua_swarm_schema::ContextPolicy {
1289                                steps: Some(vec!["planner".to_string()]),
1290                                ..Default::default()
1291                            },
1292                            ..Default::default()
1293                        },
1294                    );
1295                }
1296            })
1297            .await
1298            .expect("seed policy");
1299
1300        let mut payload = consumer_payload(&consumer_id, &run_id);
1301        assemble_step_pointers(&state, &mut payload).await;
1302
1303        let names: Vec<&str> = payload
1304            .context
1305            .as_ref()
1306            .expect("context")
1307            .steps
1308            .iter()
1309            .map(|p| p.name.as_str())
1310            .collect();
1311        assert_eq!(names, vec!["planner"], "names: {names:?}");
1312    }
1313
1314    /// Test 3: `steps: []` → the pointer list is empty.
1315    #[tokio::test]
1316    async fn context_policy_steps_empty_list_yields_no_pointers() {
1317        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1318        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1319        let task_id = TaskId::new();
1320        let run_id = RunId::new();
1321        let planner_id = StepId::new();
1322        append_final(&data_store, planner_id.as_str(), "planner", json!("x")).await;
1323        run_store
1324            .create(run_record(
1325                &task_id,
1326                &run_id,
1327                vec![step_entry(&planner_id, "planner")],
1328            ))
1329            .await
1330            .expect("create run");
1331
1332        let state = test_state(data_store, run_store);
1333        let consumer_id = StepId::new();
1334        state
1335            .engine
1336            .with_state("test.seed_policy", {
1337                let consumer_id = consumer_id.clone();
1338                move |s| {
1339                    s.agent_ctx.insert(
1340                        (consumer_id, 1),
1341                        mlua_swarm::core::state::AgentCtxEntry {
1342                            policy: mlua_swarm_schema::ContextPolicy {
1343                                steps: Some(vec![]),
1344                                ..Default::default()
1345                            },
1346                            ..Default::default()
1347                        },
1348                    );
1349                }
1350            })
1351            .await
1352            .expect("seed policy");
1353
1354        let mut payload = consumer_payload(&consumer_id, &run_id);
1355        assemble_step_pointers(&state, &mut payload).await;
1356
1357        assert!(payload.context.expect("context").steps.is_empty());
1358    }
1359
1360    /// Test 4: `steps_exclude` wins over `steps` for a name in both.
1361    #[tokio::test]
1362    async fn context_policy_steps_exclude_wins_over_steps() {
1363        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1364        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1365        let task_id = TaskId::new();
1366        let run_id = RunId::new();
1367        let planner_id = StepId::new();
1368        let coder_id = StepId::new();
1369        append_final(&data_store, planner_id.as_str(), "planner", json!("x")).await;
1370        append_final(&data_store, coder_id.as_str(), "coder", json!("y")).await;
1371        run_store
1372            .create(run_record(
1373                &task_id,
1374                &run_id,
1375                vec![
1376                    step_entry(&planner_id, "planner"),
1377                    step_entry(&coder_id, "coder"),
1378                ],
1379            ))
1380            .await
1381            .expect("create run");
1382
1383        let state = test_state(data_store, run_store);
1384        let consumer_id = StepId::new();
1385        state
1386            .engine
1387            .with_state("test.seed_policy", {
1388                let consumer_id = consumer_id.clone();
1389                move |s| {
1390                    s.agent_ctx.insert(
1391                        (consumer_id, 1),
1392                        mlua_swarm::core::state::AgentCtxEntry {
1393                            policy: mlua_swarm_schema::ContextPolicy {
1394                                steps: Some(vec!["planner".to_string(), "coder".to_string()]),
1395                                steps_exclude: vec!["planner".to_string()],
1396                                ..Default::default()
1397                            },
1398                            ..Default::default()
1399                        },
1400                    );
1401                }
1402            })
1403            .await
1404            .expect("seed policy");
1405
1406        let mut payload = consumer_payload(&consumer_id, &run_id);
1407        assemble_step_pointers(&state, &mut payload).await;
1408
1409        let names: Vec<&str> = payload
1410            .context
1411            .as_ref()
1412            .expect("context")
1413            .steps
1414            .iter()
1415            .map(|p| p.name.as_str())
1416            .collect();
1417        assert_eq!(names, vec!["coder"], "names: {names:?}");
1418    }
1419
1420    /// Test 5 (in-flight window, subtask-4-style invariant): the Run has
1421    /// NOT finalized (`result_ref: None`, mirroring a Run still `Running`)
1422    /// yet the fetch payload still carries a `StepPointer` for a step
1423    /// already visible through the Data-plane store — the same mechanism
1424    /// `crates/mlua-swarm-server/src/projection.rs`'s
1425    /// `steps_list_returns_in_flight_step_output_before_run_completes`
1426    /// proves end-to-end through a real gated 2-step dispatch; this test
1427    /// isolates the same invariant at the `assemble_step_pointers` level.
1428    #[tokio::test]
1429    async fn in_flight_step_output_is_visible_before_run_finalizes() {
1430        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1431        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1432        let task_id = TaskId::new();
1433        let run_id = RunId::new();
1434        let step1_id = StepId::new();
1435        append_final(
1436            &data_store,
1437            step1_id.as_str(),
1438            "step1",
1439            json!({"step1_out": "hi"}),
1440        )
1441        .await;
1442        let mut run = run_record(&task_id, &run_id, vec![step_entry(&step1_id, "step1")]);
1443        run.status = RunStatus::Running;
1444        run.result_ref = None; // the in-flight window: not yet finalized.
1445        run_store.create(run).await.expect("create run");
1446
1447        let state = test_state(data_store, run_store);
1448        let consumer_id = StepId::new();
1449        let mut payload = consumer_payload(&consumer_id, &run_id);
1450        assemble_step_pointers(&state, &mut payload).await;
1451
1452        let steps = &payload.context.expect("context").steps;
1453        assert_eq!(steps.len(), 1);
1454        assert_eq!(steps[0].name, "step1");
1455    }
1456
1457    /// Test 6: the fetching agent's own name is always excluded, even if
1458    /// (e.g. a loop re-dispatching the same agent) it also appears in
1459    /// `run.step_entries` with a resolvable Data-plane record.
1460    #[tokio::test]
1461    async fn self_agent_name_is_always_excluded() {
1462        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1463        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1464        let task_id = TaskId::new();
1465        let run_id = RunId::new();
1466        let planner_id = StepId::new();
1467        let consumer_prior_id = StepId::new();
1468        append_final(&data_store, planner_id.as_str(), "planner", json!("x")).await;
1469        append_final(
1470            &data_store,
1471            consumer_prior_id.as_str(),
1472            "consumer",
1473            json!("self"),
1474        )
1475        .await;
1476        run_store
1477            .create(run_record(
1478                &task_id,
1479                &run_id,
1480                vec![
1481                    step_entry(&planner_id, "planner"),
1482                    step_entry(&consumer_prior_id, "consumer"),
1483                ],
1484            ))
1485            .await
1486            .expect("create run");
1487
1488        let state = test_state(data_store, run_store);
1489        let consumer_id = StepId::new();
1490        let mut payload = consumer_payload(&consumer_id, &run_id);
1491        assemble_step_pointers(&state, &mut payload).await;
1492
1493        let names: Vec<&str> = payload
1494            .context
1495            .as_ref()
1496            .expect("context")
1497            .steps
1498            .iter()
1499            .map(|p| p.name.as_str())
1500            .collect();
1501        assert!(!names.contains(&"consumer"), "names: {names:?}");
1502        assert!(names.contains(&"planner"), "names: {names:?}");
1503    }
1504
1505    /// Test 7 (pointer-only invariant): a `StepPointer`'s serialized JSON
1506    /// carries no preview / content-bytes field — only `name` /
1507    /// `size_bytes` / `file_path?` / `content_url` / `sha256`.
1508    #[tokio::test]
1509    async fn step_pointer_serializes_with_no_preview_or_content_bytes() {
1510        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1511        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1512        let task_id = TaskId::new();
1513        let run_id = RunId::new();
1514        let planner_id = StepId::new();
1515        append_final(
1516            &data_store,
1517            planner_id.as_str(),
1518            "planner",
1519            json!({"plan": "do the thing, at length".repeat(50)}),
1520        )
1521        .await;
1522        run_store
1523            .create(run_record(
1524                &task_id,
1525                &run_id,
1526                vec![step_entry(&planner_id, "planner")],
1527            ))
1528            .await
1529            .expect("create run");
1530
1531        let state = test_state(data_store, run_store);
1532        let consumer_id = StepId::new();
1533        let mut payload = consumer_payload(&consumer_id, &run_id);
1534        assemble_step_pointers(&state, &mut payload).await;
1535
1536        let steps = &payload.context.expect("context").steps;
1537        assert_eq!(steps.len(), 1);
1538        let json_value = serde_json::to_value(&steps[0]).expect("serialize StepPointer");
1539        let obj = json_value.as_object().expect("object");
1540        for forbidden in ["preview", "content", "value", "bytes"] {
1541            assert!(
1542                !obj.contains_key(forbidden),
1543                "StepPointer must not carry a {forbidden:?} field: {obj:?}"
1544            );
1545        }
1546        assert!(obj.contains_key("name"));
1547        assert!(obj.contains_key("size_bytes"));
1548        assert!(obj.contains_key("content_url"));
1549        assert!(obj.contains_key("sha256"));
1550    }
1551
1552    /// A single-step Blueprint whose `planner` agent declares
1553    /// `AgentMeta.projection_name = "plan-out"` — the `StepNaming` fixture
1554    /// for [`declared_projection_name_pointer_name_is_canonical_and_policy_matches_it`],
1555    /// mirroring `crate::projection::tests`' own
1556    /// `declared_projection_name_blueprint` helper (duplicated here rather
1557    /// than shared — this crate's established per-module test-helper
1558    /// convention).
1559    fn declared_name_bp() -> mlua_swarm::blueprint::Blueprint {
1560        use mlua_flow_ir::{Expr, Node};
1561        use mlua_swarm::blueprint::{
1562            current_schema_version, AgentDef, AgentKind, AgentMeta, Blueprint, BlueprintMetadata,
1563            CompilerHints, CompilerStrategy,
1564        };
1565        Blueprint {
1566            schema_version: current_schema_version(),
1567            id: "worker-test-declared-name-bp".into(),
1568            flow: Node::Step {
1569                ref_: "planner".to_string(),
1570                in_: Expr::Path {
1571                    at: "$.in".parse().expect("literal test path: $.in"),
1572                },
1573                out: Expr::Path {
1574                    at: "$.plan".parse().expect("literal test path: $.plan"),
1575                },
1576            },
1577            agents: vec![AgentDef {
1578                name: "planner".to_string(),
1579                kind: AgentKind::RustFn,
1580                spec: json!({"fn_id": "planner"}),
1581                profile: None,
1582                meta: Some(AgentMeta {
1583                    projection_name: Some("plan-out".to_string()),
1584                    ..Default::default()
1585                }),
1586                runner: None,
1587                runner_ref: None,
1588                verdict: None,
1589            }],
1590            operators: vec![],
1591            metas: vec![],
1592            hints: CompilerHints::default(),
1593            strategy: CompilerStrategy::default(),
1594            metadata: BlueprintMetadata::default(),
1595            spawner_hints: Default::default(),
1596            default_agent_kind: AgentKind::Operator,
1597            default_operator_kind: None,
1598            default_init_ctx: None,
1599            default_agent_ctx: None,
1600            default_context_policy: None,
1601            projection_placement: None,
1602            audits: vec![],
1603            degradation_policy: None,
1604            runners: vec![],
1605            default_runner: None,
1606            check_policy: None,
1607            blueprint_ref_includes: Vec::new(),
1608        }
1609    }
1610
1611    /// Test 8 (GH #23 subtask-3, declared-name E2E — Worker axis half): a
1612    /// declared `projection_name` makes `StepPointer.name` the CANONICAL
1613    /// name (not the raw `Step.ref` the Data-plane / `step_entries` still
1614    /// index by), and `ContextPolicy.steps` naming the canonical name
1615    /// matches it.
1616    #[tokio::test]
1617    async fn declared_projection_name_pointer_name_is_canonical_and_policy_matches_it() {
1618        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1619        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1620        let task_id = TaskId::new();
1621        let run_id = RunId::new();
1622        let planner_id = StepId::new();
1623
1624        // The Data-plane store is keyed by the CANONICAL name — GH #23
1625        // subtask-2's sink already writes it that way.
1626        append_final(
1627            &data_store,
1628            planner_id.as_str(),
1629            "plan-out",
1630            json!({"plan": "x"}),
1631        )
1632        .await;
1633        run_store
1634            .create(run_record(
1635                &task_id,
1636                &run_id,
1637                vec![step_entry(&planner_id, "planner")],
1638            ))
1639            .await
1640            .expect("create run");
1641
1642        let state = test_state(data_store, run_store);
1643
1644        // Seed the `StepNaming` table the way `Compiler::compile` +
1645        // `EngineDispatcher::dispatch` would have — the same `Arc` stashed
1646        // under every dispatched step's own id, including the FETCHING
1647        // agent's (`consumer_id`), which `assemble_step_pointers` looks up
1648        // via `Engine::step_naming_for(&payload.task_id)`.
1649        let (naming, _warnings) =
1650            mlua_swarm::core::step_naming::StepNaming::from_blueprint(&declared_name_bp())
1651                .expect("no collision");
1652        let naming = Arc::new(naming);
1653        let consumer_id = StepId::new();
1654        state
1655            .engine
1656            .with_state("test.seed_step_naming", {
1657                let naming = naming.clone();
1658                let planner_id = planner_id.clone();
1659                let consumer_id = consumer_id.clone();
1660                move |s| {
1661                    s.step_namings.insert(planner_id, naming.clone());
1662                    s.step_namings.insert(consumer_id, naming);
1663                }
1664            })
1665            .await
1666            .expect("seed step naming");
1667        state
1668            .engine
1669            .with_state("test.seed_policy", {
1670                let consumer_id = consumer_id.clone();
1671                move |s| {
1672                    s.agent_ctx.insert(
1673                        (consumer_id, 1),
1674                        mlua_swarm::core::state::AgentCtxEntry {
1675                            policy: mlua_swarm_schema::ContextPolicy {
1676                                steps: Some(vec!["plan-out".to_string()]),
1677                                ..Default::default()
1678                            },
1679                            ..Default::default()
1680                        },
1681                    );
1682                }
1683            })
1684            .await
1685            .expect("seed policy");
1686
1687        let mut payload = consumer_payload(&consumer_id, &run_id);
1688        assemble_step_pointers(&state, &mut payload).await;
1689
1690        let steps = &payload.context.expect("context").steps;
1691        assert_eq!(steps.len(), 1, "steps: {steps:?}");
1692        assert_eq!(
1693            steps[0].name, "plan-out",
1694            "StepPointer.name must be the canonical name"
1695        );
1696    }
1697
1698    // ──────────────────────────────────────────────────────────────────────
1699    // GH #31 — `/v1/worker/prompt/system` + `/v1/agents/:name/render-size`
1700    // ──────────────────────────────────────────────────────────────────────
1701
1702    /// Seeds a task + baked system prompt + a short worker handle bound to
1703    /// it, mirroring the shape `Engine::dispatch_attempt` would have
1704    /// produced (minus the parts these two routes don't touch: no real
1705    /// HMAC-signed `CapToken`, since `task_id_from_handle`'s handle → fp →
1706    /// task_id chain is what's under test, not signature verification).
1707    async fn seed_task_with_handle(
1708        state: &AppState,
1709        task_id: &StepId,
1710        agent: &str,
1711        attempt: u32,
1712        system: Option<String>,
1713    ) -> String {
1714        let handle = format!("wh-{}", mlua_swarm::types::secure_hex(4));
1715        let task_id = task_id.clone();
1716        let agent = agent.to_string();
1717        let handle_clone = handle.clone();
1718        state
1719            .engine
1720            .with_state("test.seed_task_with_handle", move |s| {
1721                let mut task = mlua_swarm::core::state::TaskState::new(
1722                    task_id.clone(),
1723                    mlua_swarm::core::state::TaskSpec {
1724                        agent: agent.clone(),
1725                        initial_directive: json!("x"),
1726                        step_ctx: None,
1727                        check_policy: None,
1728                    },
1729                );
1730                task.attempt = attempt;
1731                s.tasks.insert(task_id.clone(), task);
1732                s.systems.insert((task_id.clone(), attempt), system);
1733                let token = CapToken {
1734                    agent_id: agent,
1735                    role: mlua_swarm::Role::Worker,
1736                    scopes: vec!["*".to_string()],
1737                    issued_at: 0,
1738                    expire_at: u64::MAX,
1739                    max_uses: None,
1740                    nonce: format!("test-nonce-{task_id}"),
1741                    sig_hex: String::new(),
1742                };
1743                let fp = token.fingerprint();
1744                s.tokens.insert(
1745                    fp.clone(),
1746                    mlua_swarm::core::state::CapTokenRecord {
1747                        token,
1748                        uses_left: None,
1749                        revoked: false,
1750                        task_id: Some(task_id),
1751                    },
1752                );
1753                s.worker_handles.insert(handle_clone, fp);
1754            })
1755            .await
1756            .expect("seed_task_with_handle");
1757        handle
1758    }
1759
1760    fn bearer_headers(handle: &str) -> HeaderMap {
1761        let mut headers = HeaderMap::new();
1762        headers.insert(
1763            AUTHORIZATION,
1764            format!("Bearer {handle}").parse().expect("header value"),
1765        );
1766        headers
1767    }
1768
1769    /// `GET /v1/worker/prompt/system` returns the exact raw baked bytes
1770    /// (not JSON-wrapped) with `Content-Type: text/plain`, for the
1771    /// `(task_id, attempt)` the handle is bound to.
1772    #[tokio::test]
1773    async fn worker_prompt_system_returns_raw_bytes_for_baked_system() {
1774        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1775        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1776        let state = test_state(data_store, run_store);
1777        let task_id = StepId::new();
1778        let rendered = "# Hello\n\nThis is the baked system prompt.".to_string();
1779        let handle =
1780            seed_task_with_handle(&state, &task_id, "planner", 1, Some(rendered.clone())).await;
1781
1782        let resp = worker_prompt_system(
1783            State(state.clone()),
1784            bearer_headers(&handle),
1785            Query(PromptSystemQuery {
1786                task_id: task_id.clone(),
1787                attempt: 1,
1788            }),
1789        )
1790        .await
1791        .expect("worker_prompt_system")
1792        .into_response();
1793
1794        assert_eq!(resp.status(), StatusCode::OK);
1795        let content_type = resp
1796            .headers()
1797            .get(header::CONTENT_TYPE)
1798            .expect("content-type header")
1799            .to_str()
1800            .expect("ascii");
1801        assert_eq!(content_type, "text/plain; charset=utf-8");
1802        let body_bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1803            .await
1804            .expect("body bytes");
1805        assert_eq!(body_bytes.as_ref(), rendered.as_bytes());
1806    }
1807
1808    /// No baked system for the given `(task_id, attempt)` → 404, not a
1809    /// panic or a 200-with-empty-body.
1810    #[tokio::test]
1811    async fn worker_prompt_system_404s_when_no_baked_system() {
1812        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1813        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1814        let state = test_state(data_store, run_store);
1815        let task_id = StepId::new();
1816        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
1817
1818        let result = worker_prompt_system(
1819            State(state.clone()),
1820            bearer_headers(&handle),
1821            Query(PromptSystemQuery {
1822                task_id: task_id.clone(),
1823                attempt: 1,
1824            }),
1825        )
1826        .await;
1827        let err = match result {
1828            Ok(_) => panic!("expected 404 ApiError, got Ok"),
1829            Err(e) => e,
1830        };
1831        assert_eq!(err.into_response().status(), StatusCode::NOT_FOUND);
1832    }
1833
1834    /// A handle bound to a different task than the one requested must be
1835    /// rejected (400) — this is the same cross-check `worker_prompt` does.
1836    #[tokio::test]
1837    async fn worker_prompt_system_rejects_handle_task_mismatch() {
1838        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1839        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1840        let state = test_state(data_store, run_store);
1841        let task_id = StepId::new();
1842        let other_task_id = StepId::new();
1843        let handle =
1844            seed_task_with_handle(&state, &task_id, "planner", 1, Some("x".to_string())).await;
1845
1846        let result = worker_prompt_system(
1847            State(state.clone()),
1848            bearer_headers(&handle),
1849            Query(PromptSystemQuery {
1850                task_id: other_task_id,
1851                attempt: 1,
1852            }),
1853        )
1854        .await;
1855        let err = match result {
1856            Ok(_) => panic!("expected 400 ApiError for task mismatch, got Ok"),
1857            Err(e) => e,
1858        };
1859        assert_eq!(err.into_response().status(), StatusCode::BAD_REQUEST);
1860    }
1861
1862    /// `GET /v1/agents/:name/render-size` requires no auth, and reports
1863    /// `last_rendered_bytes: null` for an agent that has never had a
1864    /// `system_prompt` baked — a normal 200, not a 404.
1865    #[tokio::test]
1866    async fn agent_render_size_returns_null_for_unknown_agent() {
1867        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1868        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1869        let state = test_state(data_store, run_store);
1870
1871        let Json(body) = agent_render_size(
1872            State(state.clone()),
1873            axum::extract::Path("never-dispatched".to_string()),
1874        )
1875        .await;
1876        assert_eq!(body.agent, "never-dispatched");
1877        assert_eq!(body.last_rendered_bytes, None);
1878    }
1879
1880    /// Once `bake_worker_system_prompt` has recorded a render size for an
1881    /// agent, the route reports the most-recently-observed value.
1882    #[tokio::test]
1883    async fn agent_render_size_reports_last_rendered_bytes() {
1884        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1885        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1886        let state = test_state(data_store, run_store);
1887        let task_id = StepId::new();
1888        state
1889            .engine
1890            .with_state("test.seed_agent_ctx_for_bake", {
1891                let task_id = task_id.clone();
1892                move |s| {
1893                    s.tasks.insert(
1894                        task_id.clone(),
1895                        mlua_swarm::core::state::TaskState::new(
1896                            task_id,
1897                            mlua_swarm::core::state::TaskSpec {
1898                                agent: "coder".to_string(),
1899                                initial_directive: json!("x"),
1900                                step_ctx: None,
1901                                check_policy: None,
1902                            },
1903                        ),
1904                    );
1905                }
1906            })
1907            .await
1908            .expect("seed task");
1909        state
1910            .engine
1911            .bake_worker_system_prompt(&task_id, 1, Some("z".repeat(42)))
1912            .await
1913            .expect("bake_worker_system_prompt");
1914
1915        let Json(body) = agent_render_size(
1916            State(state.clone()),
1917            axum::extract::Path("coder".to_string()),
1918        )
1919        .await;
1920        assert_eq!(body.agent, "coder");
1921        assert_eq!(body.last_rendered_bytes, Some(42));
1922    }
1923
1924    // ──────────────────────────────────────────────────────────────────────
1925    // GH #36 ST1 — `POST /v1/worker/artifact`
1926    // ──────────────────────────────────────────────────────────────────────
1927
1928    /// A valid `?name=` + short-handle Bearer stages the raw body (trailing
1929    /// whitespace trimmed, same as `worker_submit`) as an `Artifact` on the
1930    /// task's current-attempt tail, and returns `204 No Content`.
1931    #[tokio::test]
1932    async fn worker_artifact_stages_and_204s_for_valid_request() {
1933        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1934        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1935        let state = test_state(data_store, run_store);
1936        let task_id = StepId::new();
1937        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
1938
1939        let status = worker_artifact(
1940            State(state.clone()),
1941            bearer_headers(&handle),
1942            Query(ArtifactQuery {
1943                name: "summary".to_string(),
1944            }),
1945            axum::body::Bytes::from_static(b"hello artifact\n"),
1946        )
1947        .await
1948        .expect("worker_artifact");
1949        assert_eq!(status, StatusCode::NO_CONTENT);
1950
1951        let tail = state.engine.output_tail(&task_id, 1).await;
1952        assert_eq!(tail.len(), 1, "tail: {tail:?}");
1953        match &tail[0] {
1954            OutputEvent::Artifact { name, content } => {
1955                assert_eq!(name, "summary");
1956                match content {
1957                    ContentRef::Inline { value } => {
1958                        assert_eq!(value, &json!("hello artifact"));
1959                    }
1960                    other => panic!("expected Inline content, got {other:?}"),
1961                }
1962            }
1963            other => panic!("expected Artifact event, got {other:?}"),
1964        }
1965    }
1966
1967    /// `?name=` missing entirely → axum's `Query` extractor rejection
1968    /// (400), not a panic. `Query<ArtifactQuery>` is constructed directly
1969    /// in this test (mirroring the other handlers' unit style, which call
1970    /// the handler fn with an already-extracted `Query`) — an empty `name`
1971    /// is exercised separately below since that case is NOT caught by the
1972    /// extractor and must be checked in the handler body.
1973    #[tokio::test]
1974    async fn worker_artifact_rejects_blank_name() {
1975        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1976        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1977        let state = test_state(data_store, run_store);
1978        let task_id = StepId::new();
1979        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
1980
1981        let result = worker_artifact(
1982            State(state.clone()),
1983            bearer_headers(&handle),
1984            Query(ArtifactQuery {
1985                name: "   ".to_string(),
1986            }),
1987            axum::body::Bytes::from_static(b"x"),
1988        )
1989        .await;
1990        let err = match result {
1991            Ok(_) => panic!("expected 400 ApiError for blank name, got Ok"),
1992            Err(e) => e,
1993        };
1994        assert_eq!(err.into_response().status(), StatusCode::BAD_REQUEST);
1995
1996        // Nothing was staged.
1997        assert!(state.engine.output_tail(&task_id, 1).await.is_empty());
1998    }
1999
2000    /// Staging the same `name` twice within one attempt is last-write-wins
2001    /// on the folded value (`fold_final_and_parts` in `mlua_swarm::core::
2002    /// engine`) — this test only asserts the raw tail carries both events
2003    /// in order (the fold itself is covered by that crate's own unit
2004    /// tests); `Engine::stage_worker_artifact_trusted`'s doc.
2005    #[tokio::test]
2006    async fn worker_artifact_staging_same_name_twice_appends_both_events_in_order() {
2007        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2008        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2009        let state = test_state(data_store, run_store);
2010        let task_id = StepId::new();
2011        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2012
2013        for body in [b"first".as_slice(), b"second".as_slice()] {
2014            worker_artifact(
2015                State(state.clone()),
2016                bearer_headers(&handle),
2017                Query(ArtifactQuery {
2018                    name: "a".to_string(),
2019                }),
2020                axum::body::Bytes::copy_from_slice(body),
2021            )
2022            .await
2023            .expect("worker_artifact");
2024        }
2025
2026        let tail = state.engine.output_tail(&task_id, 1).await;
2027        assert_eq!(tail.len(), 2, "tail: {tail:?}");
2028        let values: Vec<&str> = tail
2029            .iter()
2030            .map(|ev| match ev {
2031                OutputEvent::Artifact {
2032                    content: ContentRef::Inline { value },
2033                    ..
2034                } => value.as_str().expect("string value"),
2035                other => panic!("expected Artifact/Inline event, got {other:?}"),
2036            })
2037            .collect();
2038        assert_eq!(values, vec!["first", "second"]);
2039    }
2040
2041    // ──────────────────────────────────────────────────────────────────
2042    // GH #37 — terminal-run guard (`reject_if_run_terminal`)
2043    // ──────────────────────────────────────────────────────────────────
2044
2045    /// Links a seeded dispatch task to a Run the same way
2046    /// `AgentContextMiddleware` does at spawn time: an `agent_ctx` entry
2047    /// whose view carries the `run_id`.
2048    async fn link_task_to_run(state: &AppState, task_id: &StepId, attempt: u32, run_id: &RunId) {
2049        let tid = task_id.clone();
2050        let rid_str = run_id.to_string();
2051        state
2052            .engine
2053            .with_state("test.link_task_to_run", move |s| {
2054                let mut entry = mlua_swarm::core::state::AgentCtxEntry::default();
2055                entry.view.run_id = Some(rid_str);
2056                s.agent_ctx.insert((tid, attempt), entry);
2057            })
2058            .await
2059            .expect("link_task_to_run");
2060    }
2061
2062    /// GH #37: a submit / artifact addressed at a Run that already
2063    /// reached a terminal status must be rejected with `410 Gone` — the
2064    /// flow-eval driver for that Run is gone, so a silent `204` here
2065    /// would orphan the worker's output.
2066    #[tokio::test]
2067    async fn submit_and_artifact_against_terminal_run_return_410() {
2068        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2069        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2070        let state = test_state(data_store, run_store.clone());
2071        let task_id = StepId::new();
2072        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2073
2074        let owner_task = TaskId::new();
2075        let run_id = RunId::new();
2076        let mut rec = run_record(&owner_task, &run_id, vec![]);
2077        rec.status = RunStatus::Failed;
2078        run_store.create(rec).await.expect("run create");
2079        link_task_to_run(&state, &task_id, 1, &run_id).await;
2080
2081        let err = worker_submit(
2082            State(state.clone()),
2083            bearer_headers(&handle),
2084            Query(SubmitQuery { ok: None }),
2085            axum::body::Bytes::from_static(b"LATE OUTPUT"),
2086        )
2087        .await
2088        .expect_err("a submit against a Failed run must be rejected");
2089        assert_eq!(err.status, StatusCode::GONE);
2090        assert!(
2091            err.message.contains(&run_id.to_string()),
2092            "the 410 must name the terminal run: {}",
2093            err.message
2094        );
2095
2096        let err = worker_artifact(
2097            State(state.clone()),
2098            bearer_headers(&handle),
2099            Query(ArtifactQuery {
2100                name: "part.md".to_string(),
2101            }),
2102            axum::body::Bytes::from_static(b"LATE PART"),
2103        )
2104        .await
2105        .expect_err("an artifact staged against a Failed run must be rejected");
2106        assert_eq!(err.status, StatusCode::GONE);
2107
2108        // The rejected values must not have reached the output tail.
2109        let tail = state.engine.output_tail(&task_id, 1).await;
2110        assert!(tail.is_empty(), "rejected submits must not land: {tail:?}");
2111    }
2112
2113    /// GH #37 fail-open contract: the guard must never turn a
2114    /// would-have-succeeded submit into a failure — no run linkage at
2115    /// all, an unknown Run, and a live (`Running`) Run all pass.
2116    #[tokio::test]
2117    async fn terminal_run_guard_is_fail_open() {
2118        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2119        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2120        let state = test_state(data_store, run_store.clone());
2121        let task_id = StepId::new();
2122        seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2123
2124        // (a) No agent-ctx linkage at all (pre-run-tracking dispatch).
2125        reject_if_run_terminal(&state, &task_id, 1)
2126            .await
2127            .expect("no linkage must fail open");
2128
2129        // (b) Linked to a Run the store does not know.
2130        let unknown_run = RunId::new();
2131        link_task_to_run(&state, &task_id, 1, &unknown_run).await;
2132        reject_if_run_terminal(&state, &task_id, 1)
2133            .await
2134            .expect("unknown run must fail open");
2135
2136        // (c) Linked to a live Run.
2137        let owner_task = TaskId::new();
2138        let live_run = RunId::new();
2139        run_store
2140            .create(run_record(&owner_task, &live_run, vec![]))
2141            .await
2142            .expect("run create");
2143        link_task_to_run(&state, &task_id, 1, &live_run).await;
2144        reject_if_run_terminal(&state, &task_id, 1)
2145            .await
2146            .expect("a Running run must pass the guard");
2147    }
2148
2149    // ──────────────────────────────────────────────────────────────────
2150    // GH #32 — `POST /v1/worker/degradation`
2151    // ──────────────────────────────────────────────────────────────────
2152
2153    fn degradation_body(tool: &str, note: Option<&str>) -> DegradationBody {
2154        DegradationBody {
2155            tool: tool.to_string(),
2156            error: "boom".to_string(),
2157            fallback: "used cached value".to_string(),
2158            note: note.map(str::to_string),
2159        }
2160    }
2161
2162    /// [`link_task_to_run`] plus the `view.agent` name — production's
2163    /// `AgentContextMiddleware` sets both fields on the same `agent_ctx`
2164    /// entry; the shared GH #37 helper only needed `run_id`, so this
2165    /// sibling fills in `agent` too for tests that assert on the
2166    /// server-injected `step_ref`.
2167    async fn link_task_to_run_with_agent(
2168        state: &AppState,
2169        task_id: &StepId,
2170        attempt: u32,
2171        run_id: &RunId,
2172        agent: &str,
2173    ) {
2174        let tid = task_id.clone();
2175        let rid_str = run_id.to_string();
2176        let agent = agent.to_string();
2177        state
2178            .engine
2179            .with_state("test.link_task_to_run_with_agent", move |s| {
2180                let mut entry = mlua_swarm::core::state::AgentCtxEntry::default();
2181                entry.view.run_id = Some(rid_str);
2182                entry.view.agent = agent;
2183                s.agent_ctx.insert((tid, attempt), entry);
2184            })
2185            .await
2186            .expect("link_task_to_run_with_agent");
2187    }
2188
2189    /// A worker-reported degradation is persisted to the linked Run's
2190    /// `degradations` with the server-injected `step_ref` / `attempt` /
2191    /// `at` fields filled in — the client body never supplies any of the
2192    /// three.
2193    #[tokio::test]
2194    async fn worker_degradation_persists_entry_when_run_tracked() {
2195        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2196        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2197        let state = test_state(data_store, run_store.clone());
2198        let task_id = StepId::new();
2199        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2200
2201        let owner_task = TaskId::new();
2202        let run_id = RunId::new();
2203        run_store
2204            .create(run_record(&owner_task, &run_id, vec![]))
2205            .await
2206            .expect("run create");
2207        link_task_to_run_with_agent(&state, &task_id, 1, &run_id, "planner").await;
2208
2209        let status = worker_degradation(
2210            State(state.clone()),
2211            bearer_headers(&handle),
2212            Json(degradation_body("web_search", Some("rate limited"))),
2213        )
2214        .await
2215        .expect("worker_degradation");
2216        assert_eq!(status, StatusCode::NO_CONTENT);
2217
2218        let rec = run_store.get(&run_id).await.expect("run get");
2219        assert_eq!(
2220            rec.degradations.len(),
2221            1,
2222            "degradations: {:?}",
2223            rec.degradations
2224        );
2225        let entry = &rec.degradations[0];
2226        assert_eq!(entry.tool, "web_search");
2227        assert_eq!(entry.error, "boom");
2228        assert_eq!(entry.fallback, "used cached value");
2229        assert_eq!(entry.note.as_deref(), Some("rate limited"));
2230        assert_eq!(entry.step_ref.as_deref(), Some("planner"));
2231        assert_eq!(entry.attempt, Some(1));
2232        assert!(entry.at > 0, "at must be a real timestamp: {}", entry.at);
2233    }
2234
2235    /// Two entries POSTed in sequence are appended in order.
2236    #[tokio::test]
2237    async fn worker_degradation_appends_in_order() {
2238        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2239        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2240        let state = test_state(data_store, run_store.clone());
2241        let task_id = StepId::new();
2242        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2243
2244        let owner_task = TaskId::new();
2245        let run_id = RunId::new();
2246        run_store
2247            .create(run_record(&owner_task, &run_id, vec![]))
2248            .await
2249            .expect("run create");
2250        link_task_to_run(&state, &task_id, 1, &run_id).await;
2251
2252        for tool in ["first_tool", "second_tool"] {
2253            worker_degradation(
2254                State(state.clone()),
2255                bearer_headers(&handle),
2256                Json(degradation_body(tool, None)),
2257            )
2258            .await
2259            .expect("worker_degradation");
2260        }
2261
2262        let rec = run_store.get(&run_id).await.expect("run get");
2263        let tools: Vec<&str> = rec.degradations.iter().map(|e| e.tool.as_str()).collect();
2264        assert_eq!(tools, vec!["first_tool", "second_tool"]);
2265    }
2266
2267    /// A task whose `agent_ctx` carries no Run linkage (pre-run-tracking
2268    /// dispatch) silently 204s — nothing to append to, and this must not
2269    /// surface as a client error.
2270    #[tokio::test]
2271    async fn worker_degradation_silent_ok_when_no_run_tracked() {
2272        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2273        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2274        let state = test_state(data_store, run_store);
2275        let task_id = StepId::new();
2276        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2277
2278        let status = worker_degradation(
2279            State(state.clone()),
2280            bearer_headers(&handle),
2281            Json(degradation_body("some_tool", None)),
2282        )
2283        .await
2284        .expect("worker_degradation must not error on missing run linkage");
2285        assert_eq!(status, StatusCode::NO_CONTENT);
2286    }
2287
2288    /// GH #37 terminal-run guard applies to the degradation channel too — a
2289    /// dead Run must not accumulate signals.
2290    #[tokio::test]
2291    async fn worker_degradation_rejects_terminal_run() {
2292        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2293        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2294        let state = test_state(data_store, run_store.clone());
2295        let task_id = StepId::new();
2296        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2297
2298        let owner_task = TaskId::new();
2299        let run_id = RunId::new();
2300        let mut rec = run_record(&owner_task, &run_id, vec![]);
2301        rec.status = RunStatus::Done;
2302        run_store.create(rec).await.expect("run create");
2303        link_task_to_run(&state, &task_id, 1, &run_id).await;
2304
2305        let err = worker_degradation(
2306            State(state.clone()),
2307            bearer_headers(&handle),
2308            Json(degradation_body("some_tool", None)),
2309        )
2310        .await
2311        .expect_err("a degradation against a Done run must be rejected");
2312        assert_eq!(err.status, StatusCode::GONE);
2313
2314        let rec = run_store.get(&run_id).await.expect("run get");
2315        assert!(
2316            rec.degradations.is_empty(),
2317            "rejected degradation must not land: {:?}",
2318            rec.degradations
2319        );
2320    }
2321
2322    // ──────────────────────────────────────────────────────────────────
2323    // GH #42 — `@file:<abs-path>` sentinel resolution in `worker_submit`
2324    // / `worker_artifact`. Guards each verified independently: sentinel
2325    // resolves to the file's trimmed contents; path outside `work_dir`,
2326    // missing file, oversized file, and non-sentinel bodies each get the
2327    // documented behavior.
2328    // ──────────────────────────────────────────────────────────────────
2329
2330    /// Seeds an `agent_ctx` entry whose view carries `work_dir` and, when
2331    /// `allow_file_submit` is `Some`, that value under the GH #43
2332    /// [`FILE_SENTINEL_ALLOW_KEY`] in `view.extra` — matching the shape
2333    /// `AgentContextMiddleware` writes at spawn time. Sentinel resolution
2334    /// requires both the `work_dir` and the strict `Bool(true)` opt-in.
2335    async fn seed_work_dir(
2336        state: &AppState,
2337        task_id: &StepId,
2338        attempt: u32,
2339        work_dir: &str,
2340        allow_file_submit: Option<Value>,
2341    ) {
2342        let tid = task_id.clone();
2343        let work_dir = work_dir.to_string();
2344        state
2345            .engine
2346            .with_state("test.seed_work_dir", move |s| {
2347                let mut entry = mlua_swarm::core::state::AgentCtxEntry::default();
2348                entry.view.work_dir = Some(work_dir);
2349                if let Some(v) = allow_file_submit {
2350                    entry
2351                        .view
2352                        .extra
2353                        .insert(FILE_SENTINEL_ALLOW_KEY.to_string(), v);
2354                }
2355                s.agent_ctx.insert((tid, attempt), entry);
2356            })
2357            .await
2358            .expect("seed_work_dir");
2359    }
2360
2361    /// Sentinel body `@file:<abs-path>` resolves to the file's trimmed
2362    /// contents and reaches the `OutputStore` via the normal Final-append
2363    /// path — same 204 the inline path returns.
2364    #[tokio::test]
2365    async fn worker_submit_resolves_file_sentinel_under_work_dir() {
2366        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2367        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2368        let state = test_state(data_store.clone(), run_store);
2369        let task_id = StepId::new();
2370        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2371
2372        let tmp = tempfile::tempdir().expect("tempdir");
2373        let work_dir = tmp.path().to_path_buf();
2374        seed_work_dir(
2375            &state,
2376            &task_id,
2377            1,
2378            work_dir.to_str().expect("work_dir utf-8"),
2379            Some(Value::Bool(true)),
2380        )
2381        .await;
2382
2383        let payload_path = work_dir.join("scout.md");
2384        let payload = "## Context Package (broad)\n\nlarge body content\n";
2385        tokio::fs::write(&payload_path, payload)
2386            .await
2387            .expect("write payload");
2388        let body = format!(
2389            "@file:{}",
2390            payload_path.to_str().expect("payload path utf-8")
2391        );
2392
2393        let status = worker_submit(
2394            State(state.clone()),
2395            bearer_headers(&handle),
2396            Query(SubmitQuery { ok: None }),
2397            axum::body::Bytes::from(body),
2398        )
2399        .await
2400        .expect("worker_submit sentinel");
2401        assert_eq!(status, StatusCode::NO_CONTENT);
2402
2403        // Final event lands with the file's trimmed contents on
2404        // `EngineState.output_store` (the in-memory tail
2405        // `submit_worker_result_trusted` writes to).
2406        let tid = task_id.clone();
2407        let value = state
2408            .engine
2409            .with_state("test.inspect_output_store", move |s| {
2410                s.output_store.get(&(tid.clone(), 1)).and_then(|evs| {
2411                    evs.iter().find_map(|ev| match ev {
2412                        OutputEvent::Final {
2413                            content: ContentRef::Inline { value },
2414                            ..
2415                        } => Some(value.clone()),
2416                        _ => None,
2417                    })
2418                })
2419            })
2420            .await
2421            .expect("with_state")
2422            .expect("Final event present");
2423        assert_eq!(value, Value::String(payload.trim_end().to_string()));
2424    }
2425
2426    /// A non-sentinel body is passed through byte-for-byte (pre-#42
2427    /// callers see zero behavior change).
2428    #[tokio::test]
2429    async fn worker_submit_passes_non_sentinel_body_unchanged() {
2430        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2431        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2432        let state = test_state(data_store.clone(), run_store);
2433        let task_id = StepId::new();
2434        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2435        // No agent_ctx / work_dir seeded — the inline path must not
2436        // require one.
2437
2438        let status = worker_submit(
2439            State(state.clone()),
2440            bearer_headers(&handle),
2441            Query(SubmitQuery { ok: None }),
2442            axum::body::Bytes::from_static(b"DONE yes=1 maybe=0 no=0"),
2443        )
2444        .await
2445        .expect("worker_submit inline");
2446        assert_eq!(status, StatusCode::NO_CONTENT);
2447
2448        let tid = task_id.clone();
2449        let value = state
2450            .engine
2451            .with_state("test.inspect_output_store", move |s| {
2452                s.output_store.get(&(tid.clone(), 1)).and_then(|evs| {
2453                    evs.iter().find_map(|ev| match ev {
2454                        OutputEvent::Final {
2455                            content: ContentRef::Inline { value },
2456                            ..
2457                        } => Some(value.clone()),
2458                        _ => None,
2459                    })
2460                })
2461            })
2462            .await
2463            .expect("with_state")
2464            .expect("Final event present");
2465        assert_eq!(value, Value::String("DONE yes=1 maybe=0 no=0".to_string()));
2466    }
2467
2468    /// Sentinel with a path outside the task's `work_dir` (`..`-escape
2469    /// via a sibling tempdir) → `400`. `canonicalize` collapses the
2470    /// `..`, so a symlink pointing outside the allowlist would be caught
2471    /// by the same check.
2472    #[tokio::test]
2473    async fn worker_submit_rejects_sentinel_path_outside_work_dir() {
2474        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2475        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2476        let state = test_state(data_store, run_store);
2477        let task_id = StepId::new();
2478        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2479
2480        let allowed = tempfile::tempdir().expect("allowed tempdir");
2481        let outside = tempfile::tempdir().expect("outside tempdir");
2482        seed_work_dir(
2483            &state,
2484            &task_id,
2485            1,
2486            allowed.path().to_str().expect("utf-8"),
2487            Some(Value::Bool(true)),
2488        )
2489        .await;
2490
2491        let outside_file = outside.path().join("leak.md");
2492        tokio::fs::write(&outside_file, b"outside content")
2493            .await
2494            .expect("write outside");
2495        let body = format!(
2496            "@file:{}",
2497            outside_file.to_str().expect("outside path utf-8")
2498        );
2499
2500        let err = worker_submit(
2501            State(state.clone()),
2502            bearer_headers(&handle),
2503            Query(SubmitQuery { ok: None }),
2504            axum::body::Bytes::from(body),
2505        )
2506        .await
2507        .expect_err("outside-work_dir sentinel must be rejected");
2508        assert_eq!(err.status, StatusCode::BAD_REQUEST);
2509    }
2510
2511    /// Sentinel pointing at a non-existent file → `404`.
2512    #[tokio::test]
2513    async fn worker_submit_rejects_sentinel_missing_file() {
2514        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2515        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2516        let state = test_state(data_store, run_store);
2517        let task_id = StepId::new();
2518        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2519
2520        let tmp = tempfile::tempdir().expect("tempdir");
2521        seed_work_dir(
2522            &state,
2523            &task_id,
2524            1,
2525            tmp.path().to_str().expect("utf-8"),
2526            Some(Value::Bool(true)),
2527        )
2528        .await;
2529        let missing = tmp.path().join("does-not-exist.md");
2530        let body = format!("@file:{}", missing.to_str().expect("utf-8"));
2531
2532        let err = worker_submit(
2533            State(state.clone()),
2534            bearer_headers(&handle),
2535            Query(SubmitQuery { ok: None }),
2536            axum::body::Bytes::from(body),
2537        )
2538        .await
2539        .expect_err("missing-file sentinel must be rejected");
2540        assert_eq!(err.status, StatusCode::NOT_FOUND);
2541    }
2542
2543    /// Sentinel body with a relative path → `400` before any FS lookup.
2544    #[tokio::test]
2545    async fn worker_submit_rejects_sentinel_relative_path() {
2546        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2547        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2548        let state = test_state(data_store, run_store);
2549        let task_id = StepId::new();
2550        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2551
2552        let err = worker_submit(
2553            State(state.clone()),
2554            bearer_headers(&handle),
2555            Query(SubmitQuery { ok: None }),
2556            axum::body::Bytes::from_static(b"@file:relative/path.md"),
2557        )
2558        .await
2559        .expect_err("relative-path sentinel must be rejected");
2560        assert_eq!(err.status, StatusCode::BAD_REQUEST);
2561    }
2562
2563    /// Sentinel body when the task has no `AgentContextView` (spawn
2564    /// didn't run through `AgentContextMiddleware`) → `400`. This is the
2565    /// documented pre-condition for sentinel use.
2566    #[tokio::test]
2567    async fn worker_submit_rejects_sentinel_without_agent_context_view() {
2568        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2569        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2570        let state = test_state(data_store, run_store);
2571        let task_id = StepId::new();
2572        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2573        // No seed_work_dir — the agent_ctx map has no entry for this task.
2574
2575        let err = worker_submit(
2576            State(state.clone()),
2577            bearer_headers(&handle),
2578            Query(SubmitQuery { ok: None }),
2579            axum::body::Bytes::from_static(b"@file:/tmp/anywhere.md"),
2580        )
2581        .await
2582        .expect_err("missing AgentContextView must reject sentinel");
2583        assert_eq!(err.status, StatusCode::BAD_REQUEST);
2584    }
2585
2586    /// The same sentinel form works on `POST /v1/worker/artifact` — the
2587    /// artifact endpoint shares the resolver with `worker_submit`, so the
2588    /// resolved file contents land under the artifact's `name` key.
2589    #[tokio::test]
2590    async fn worker_artifact_resolves_file_sentinel_under_work_dir() {
2591        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2592        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2593        let state = test_state(data_store, run_store);
2594        let task_id = StepId::new();
2595        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2596
2597        let tmp = tempfile::tempdir().expect("tempdir");
2598        seed_work_dir(
2599            &state,
2600            &task_id,
2601            1,
2602            tmp.path().to_str().expect("utf-8"),
2603            Some(Value::Bool(true)),
2604        )
2605        .await;
2606
2607        let payload_path = tmp.path().join("part.md");
2608        let payload = "artifact part body\n";
2609        tokio::fs::write(&payload_path, payload)
2610            .await
2611            .expect("write payload");
2612        let body = format!("@file:{}", payload_path.to_str().expect("utf-8"));
2613
2614        let status = worker_artifact(
2615            State(state.clone()),
2616            bearer_headers(&handle),
2617            Query(ArtifactQuery {
2618                name: "scout".to_string(),
2619            }),
2620            axum::body::Bytes::from(body),
2621        )
2622        .await
2623        .expect("worker_artifact sentinel");
2624        assert_eq!(status, StatusCode::NO_CONTENT);
2625    }
2626
2627    /// GH #43 — sentinel with `work_dir` seeded but no
2628    /// `allow_file_submit` opt-in → `400` (default-deny). The file exists
2629    /// and sits under `work_dir`, so the rejection is attributable to the
2630    /// missing opt-in alone.
2631    #[tokio::test]
2632    async fn worker_submit_rejects_sentinel_without_allow_flag() {
2633        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2634        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2635        let state = test_state(data_store, run_store);
2636        let task_id = StepId::new();
2637        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2638
2639        let tmp = tempfile::tempdir().expect("tempdir");
2640        seed_work_dir(
2641            &state,
2642            &task_id,
2643            1,
2644            tmp.path().to_str().expect("utf-8"),
2645            None,
2646        )
2647        .await;
2648
2649        let payload_path = tmp.path().join("out.md");
2650        tokio::fs::write(&payload_path, b"resolvable body")
2651            .await
2652            .expect("write payload");
2653        let body = format!("@file:{}", payload_path.to_str().expect("utf-8"));
2654
2655        let err = worker_submit(
2656            State(state.clone()),
2657            bearer_headers(&handle),
2658            Query(SubmitQuery { ok: None }),
2659            axum::body::Bytes::from(body),
2660        )
2661        .await
2662        .expect_err("missing opt-in must reject sentinel");
2663        assert_eq!(err.status, StatusCode::BAD_REQUEST);
2664        assert!(
2665            err.message.contains("not allowed"),
2666            "rejection must name the opt-in guard, got: {}",
2667            err.message
2668        );
2669    }
2670
2671    /// GH #43 — the opt-in is the strict boolean `true`: `Bool(false)`
2672    /// and the string `"true"` are both rejected with `400`.
2673    #[tokio::test]
2674    async fn worker_submit_rejects_sentinel_with_non_true_allow_values() {
2675        for allow in [Value::Bool(false), Value::String("true".to_string())] {
2676            let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2677            let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2678            let state = test_state(data_store, run_store);
2679            let task_id = StepId::new();
2680            let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
2681
2682            let tmp = tempfile::tempdir().expect("tempdir");
2683            seed_work_dir(
2684                &state,
2685                &task_id,
2686                1,
2687                tmp.path().to_str().expect("utf-8"),
2688                Some(allow.clone()),
2689            )
2690            .await;
2691
2692            let payload_path = tmp.path().join("out.md");
2693            tokio::fs::write(&payload_path, b"resolvable body")
2694                .await
2695                .expect("write payload");
2696            let body = format!("@file:{}", payload_path.to_str().expect("utf-8"));
2697
2698            let err = worker_submit(
2699                State(state.clone()),
2700                bearer_headers(&handle),
2701                Query(SubmitQuery { ok: None }),
2702                axum::body::Bytes::from(body),
2703            )
2704            .await
2705            .expect_err("non-true opt-in value must reject sentinel");
2706            assert_eq!(err.status, StatusCode::BAD_REQUEST, "value: {allow:?}");
2707        }
2708    }
2709
2710    // ──────────────────────────────────────────────────────────────────
2711    // GH #50 (Subtask 2) — submit-time verdict contract gate, handler-
2712    // level unit coverage. The full process-boundary HTTP round trip
2713    // (Acceptance Criterion #7) lives in
2714    // `crates/mlua-swarm-server/tests/verdict_contract.rs`; these are the
2715    // fast in-process counterpart exercising `worker_submit` /
2716    // `worker_artifact` directly, same convention as the sentinel tests
2717    // above.
2718    // ──────────────────────────────────────────────────────────────────
2719
2720    fn body_verdict_contract(values: &[&str]) -> mlua_swarm_schema::VerdictContract {
2721        mlua_swarm_schema::VerdictContract {
2722            channel: VerdictChannel::Body,
2723            values: values.iter().map(|v| v.to_string()).collect(),
2724        }
2725    }
2726
2727    fn part_verdict_contract(values: &[&str]) -> mlua_swarm_schema::VerdictContract {
2728        mlua_swarm_schema::VerdictContract {
2729            channel: VerdictChannel::Part,
2730            values: values.iter().map(|v| v.to_string()).collect(),
2731        }
2732    }
2733
2734    /// A `channel: "body"` contract rejects a `worker_submit` body outside
2735    /// its declared `values` with `422`, echoing the expected token set.
2736    #[tokio::test]
2737    async fn worker_submit_rejects_body_outside_contract_values_with_422() {
2738        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2739        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2740        let state = test_state(data_store, run_store);
2741        let task_id = StepId::new();
2742        let handle = seed_task_with_handle(&state, &task_id, "gate", 1, None).await;
2743        state.engine.register_verdict_contracts(HashMap::from([(
2744            "gate".to_string(),
2745            body_verdict_contract(&["PASS", "BLOCKED"]),
2746        )]));
2747
2748        let err = worker_submit(
2749            State(state.clone()),
2750            bearer_headers(&handle),
2751            Query(SubmitQuery { ok: None }),
2752            axum::body::Bytes::from("UNKNOWN"),
2753        )
2754        .await
2755        .expect_err("value outside declared values must reject");
2756        assert_eq!(err.status, StatusCode::UNPROCESSABLE_ENTITY);
2757        assert!(
2758            err.message.contains("PASS") && err.message.contains("BLOCKED"),
2759            "rejection must echo the declared values, got: {}",
2760            err.message
2761        );
2762    }
2763
2764    /// The same contract accepts a body that IS a member of `values` —
2765    /// `204`, unaffected submit.
2766    #[tokio::test]
2767    async fn worker_submit_accepts_body_inside_contract_values() {
2768        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2769        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2770        let state = test_state(data_store, run_store);
2771        let task_id = StepId::new();
2772        let handle = seed_task_with_handle(&state, &task_id, "gate", 1, None).await;
2773        state.engine.register_verdict_contracts(HashMap::from([(
2774            "gate".to_string(),
2775            body_verdict_contract(&["PASS", "BLOCKED"]),
2776        )]));
2777
2778        let status = worker_submit(
2779            State(state.clone()),
2780            bearer_headers(&handle),
2781            Query(SubmitQuery { ok: None }),
2782            axum::body::Bytes::from("PASS"),
2783        )
2784        .await
2785        .expect("value inside declared values must succeed");
2786        assert_eq!(status, StatusCode::NO_CONTENT);
2787    }
2788
2789    /// Opt-in regression guard: an agent with NO declared verdict contract
2790    /// is entirely unaffected — `worker_submit` still returns `204` for an
2791    /// arbitrary body, exactly the pre-GH-#50 behavior.
2792    #[tokio::test]
2793    async fn worker_submit_without_a_declared_contract_is_unaffected() {
2794        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2795        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2796        let state = test_state(data_store, run_store);
2797        let task_id = StepId::new();
2798        // No `register_verdict_contracts` call — the agent declared no contract.
2799        let handle = seed_task_with_handle(&state, &task_id, "undeclared-agent", 1, None).await;
2800
2801        let status = worker_submit(
2802            State(state.clone()),
2803            bearer_headers(&handle),
2804            Query(SubmitQuery { ok: None }),
2805            axum::body::Bytes::from("anything at all, no contract to violate"),
2806        )
2807        .await
2808        .expect("no contract declared must never reject");
2809        assert_eq!(status, StatusCode::NO_CONTENT);
2810    }
2811
2812    /// A `channel: "part"` contract rejects a `worker_artifact?name=verdict`
2813    /// value outside `values` with `422`.
2814    #[tokio::test]
2815    async fn worker_artifact_verdict_part_rejects_value_outside_contract_with_422() {
2816        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2817        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2818        let state = test_state(data_store, run_store);
2819        let task_id = StepId::new();
2820        let handle = seed_task_with_handle(&state, &task_id, "gate", 1, None).await;
2821        state.engine.register_verdict_contracts(HashMap::from([(
2822            "gate".to_string(),
2823            part_verdict_contract(&["PASS", "BLOCKED"]),
2824        )]));
2825
2826        let err = worker_artifact(
2827            State(state.clone()),
2828            bearer_headers(&handle),
2829            Query(ArtifactQuery {
2830                name: "verdict".to_string(),
2831            }),
2832            axum::body::Bytes::from("UNKNOWN"),
2833        )
2834        .await
2835        .expect_err("value outside declared values must reject");
2836        assert_eq!(err.status, StatusCode::UNPROCESSABLE_ENTITY);
2837    }
2838
2839    /// A part named anything OTHER than `"verdict"` skips the gate
2840    /// entirely, even with a `channel: "part"` contract declared — `204`,
2841    /// existing pre-GH-#50 behavior unchanged.
2842    #[tokio::test]
2843    async fn worker_artifact_non_verdict_part_skips_the_gate() {
2844        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
2845        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
2846        let state = test_state(data_store, run_store);
2847        let task_id = StepId::new();
2848        let handle = seed_task_with_handle(&state, &task_id, "gate", 1, None).await;
2849        state.engine.register_verdict_contracts(HashMap::from([(
2850            "gate".to_string(),
2851            part_verdict_contract(&["PASS", "BLOCKED"]),
2852        )]));
2853
2854        let status = worker_artifact(
2855            State(state.clone()),
2856            bearer_headers(&handle),
2857            Query(ArtifactQuery {
2858                name: "notes".to_string(),
2859            }),
2860            axum::body::Bytes::from("anything at all"),
2861        )
2862        .await
2863        .expect("non-verdict part name must never be gated");
2864        assert_eq!(status, StatusCode::NO_CONTENT);
2865    }
2866}