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