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>, ...}}`.
35//! - `GET /v1/worker/prompt/system?task_id=<tid>&attempt=<n>` (GH #31) —
36//!   raw baked `system` bytes for `(task_id, attempt)`, the `Http`-mode
37//!   fetch target for `system_ref.uri`. Same Bearer flow as
38//!   `/v1/worker/prompt`; body is `text/plain`, not JSON.
39//! - `GET /v1/agents/:name/render-size` (GH #31) — no Bearer required, same
40//!   trust tier as `GET /v1/blueprints/:id/head`. Live per-agent most-recently
41//!   observed render size, backing `bp_doctor`'s post-render check.
42//! - `POST /v1/worker/degradation` (GH #32) — structured JSON `{tool, error,
43//!   fallback, note?}`, same Bearer flow as [`worker_submit`]. An
44//!   **independent channel**: entries are appended to `RunRecord.degradations`
45//!   via `RunStore::append_degradation` directly and never touch
46//!   `OutputStore` / the fold path (Crux invariant 2 — a degradation must
47//!   never surface as step OUTPUT). `step_ref` / `attempt` / `at` are
48//!   server-injected, never trusted from the client. Silent `204` (no
49//!   append) when the dispatch task carries no Run linkage — same
50//!   fail-open contract as [`reject_if_run_terminal`]'s own resolution
51//!   steps, since a pre-run-tracking dispatch has nowhere to record a
52//!   degradation and that must not become a client-visible error.
53//!
54//! ## Bearer authentication
55//!
56//! The Bearer value is the string produced by `CapToken::encode()` (= URL-safe
57//! base64 of serde_json). The server decodes it with `CapToken::decode` and then,
58//! inside the engine, verifies HMAC sig + role × verb gate + TTL via
59//! `verify_token_for_task` (= self-contained capability token; no server-side
60//! store lookup required).
61//!
62//! Tokens are minted during the "2) mint outside the lock" phase of
63//! `engine.dispatch_attempt` (`Role::Worker`, 600s TTL, `scopes=["*"]`).
64//! The verb gate covers `FetchPrompt` / `EmitOutput` / `PostResult` — the worker
65//! leaf capability set (`crate::types::WORKER_LEAF_VERBS`).
66
67use axum::{
68    extract::{Query, State},
69    http::{header, header::AUTHORIZATION, HeaderMap, StatusCode},
70    Json,
71};
72use mlua_swarm::core::agent_context::StepPointer;
73use mlua_swarm::core::step_naming::StepNaming;
74use mlua_swarm::store::run::{DegradationEntry, RunStatus, RunStoreError};
75use mlua_swarm::{CapToken, ContentRef, OutputEvent, RunId, StepId, WorkerPayload};
76use mlua_swarm_schema::ContextPolicy;
77use serde::Deserialize;
78use serde_json::Value;
79
80use crate::projection::McpQueryAdapter;
81use crate::{ApiError, AppState};
82
83/// Query params for `GET /v1/worker/prompt`.
84#[derive(Debug, Deserialize)]
85pub struct PromptQuery {
86    /// Task the fetched prompt belongs to; cross-checked against the Bearer
87    /// handle/token. Typed [`StepId`] since issue #14 — the wire shape stays
88    /// a plain string; a bad prefix is rejected at deserialize.
89    pub task_id: StepId,
90}
91
92/// `GET /v1/worker/prompt?task_id=<tid>`. Bearer = encoded `CapToken` or short `wh-` handle.
93/// Thin HTTP wrapper over `engine.fetch_worker_payload` / `fetch_worker_payload_trusted`.
94/// Short-handle path (recommended for SubAgents): handle → task_id
95/// cross-check → trusted fetch.
96/// Full-`CapToken` path: token decode → verify → fetch.
97pub async fn worker_prompt(
98    State(state): State<AppState>,
99    headers: HeaderMap,
100    Query(q): Query<PromptQuery>,
101) -> Result<Json<WorkerPayload>, ApiError> {
102    let task_id = q.task_id;
103    let bearer = extract_bearer_raw(&headers)?;
104    let mut payload = if let Some(handle) = parse_worker_handle(&bearer) {
105        // Short-handle path: verify handle → task_id (security: confirm the handle is bound to this task).
106        let resolved = state
107            .engine
108            .task_id_from_handle(handle)
109            .await
110            .map_err(|e| ApiError::engine(format!("task_id_from_handle: {e}")))?;
111        if resolved != task_id {
112            return Err(ApiError::bad_request(format!(
113                "handle {handle} is bound to task {resolved}, not {task_id}"
114            )));
115        }
116        state
117            .engine
118            .fetch_worker_payload_trusted(&task_id)
119            .await
120            .map_err(|e| ApiError::engine(format!("fetch_worker_payload_trusted: {e}")))?
121    } else {
122        // Full CapToken path (the alternate Bearer form).
123        let token = CapToken::decode(bearer.trim())
124            .map_err(|e| ApiError::bad_request(format!("invalid token: {e}")))?;
125        state
126            .engine
127            .fetch_worker_payload(&token, &task_id)
128            .await
129            .map_err(|e| ApiError::engine(format!("fetch_worker_payload: {e}")))?
130    };
131    assemble_step_pointers(&state, &mut payload).await;
132    Ok(Json(payload))
133}
134
135/// Assembles `payload.context.steps` — the `ContextPolicy.steps`-filtered
136/// pointer list to preceding steps' OUTPUT (`projection-adapter` ST5's
137/// Worker axis; see `mlua_swarm::core::agent_context`'s module doc).
138/// Resolved fresh on every fetch (not baked at spawn time), so a step
139/// submitted after this agent spawned — but before it fetches its prompt
140/// — is still visible.
141///
142/// GH #23 subtask-3: `resolved_steps` (from
143/// `McpQueryAdapter::list_steps_by_run_id`) always reports the CANONICAL
144/// name (see `crate::projection`'s module doc), so both the self-exclusion
145/// check and the `ContextPolicy` match are done against canonical names —
146/// `payload.agent` (the raw `Step.ref` this fetching agent was dispatched
147/// under) is canonicalized via `Engine::step_naming_for(&payload.task_id)`
148/// (the FETCHING agent's own dispatch id — the same `StepNaming` `Arc`
149/// every step of this Blueprint launch shares, see [`StepNaming`]'s module
150/// doc), and `policy.allows_step` itself is left untouched (schema crate
151/// stays name-agnostic) — [`allows_step_canonical`] is the caller-side seam
152/// that resolves each policy-declared name through the table before
153/// comparing.
154///
155/// No-op (`context.steps` stays empty) when: the payload carries no
156/// `context` at all; the context has no `run_id` (a spawn that never
157/// threaded one through — pre-run-tracking callers, or a spawner stack
158/// without the Run-tracking layer); or the addressed Run cannot be
159/// resolved. All three are fail-open, matching this crate's other
160/// best-effort projection hooks (a missing pointer list must never turn a
161/// would-have-succeeded fetch into a failure).
162async fn assemble_step_pointers(state: &AppState, payload: &mut WorkerPayload) {
163    let Some(context) = payload.context.as_mut() else {
164        return;
165    };
166    let Some(run_id_str) = context.run_id.clone() else {
167        return;
168    };
169    let Ok(run_id) = RunId::parse(run_id_str) else {
170        return;
171    };
172
173    let adapter = McpQueryAdapter::new(
174        state.data_store.clone(),
175        state.run_store.clone(),
176        state.engine.clone(),
177    );
178    let Ok((run, resolved_steps)) = adapter.list_steps_by_run_id(&run_id).await else {
179        return;
180    };
181
182    let naming = state.engine.step_naming_for(&payload.task_id).await;
183    let policy = state
184        .engine
185        .context_policy_for(&payload.task_id, payload.attempt)
186        .await;
187    let self_canonical = naming
188        .as_deref()
189        .and_then(|n| n.canonical_of_producer(&payload.agent))
190        .map(str::to_string)
191        .unwrap_or_else(|| payload.agent.clone());
192
193    let mut pointers = Vec::new();
194    for step in &resolved_steps {
195        if step.name == self_canonical
196            || !allows_step_canonical(&policy, naming.as_deref(), &step.name)
197        {
198            continue;
199        }
200        if let Some((size_bytes, file_path, content_url, sha256)) =
201            crate::projection::resolve_step_pointer_fields(state, &run, step).await
202        {
203            pointers.push(StepPointer {
204                name: step.name.clone(),
205                size_bytes,
206                file_path,
207                content_url,
208                sha256,
209            });
210        }
211    }
212    context.steps = pointers;
213}
214
215/// GH #23 subtask-3: caller-side canonical/alias expansion for
216/// `ContextPolicy.allows_step` — same precedence as
217/// `ContextPolicy::allows_step` itself (`steps_exclude` wins; `steps:
218/// None` = pass-all, `Some(list)` = named-only), but each
219/// policy-declared name is resolved through the Blueprint's `StepNaming`
220/// table before comparison, so a Blueprint author's `steps: [...]` entry
221/// naming either the canonical projection name OR any alias (`Step.ref` /
222/// the `out` ctx-path's top-level segment) matches the same step.
223/// `ContextPolicy::allows_step` (schema crate) is untouched — this is the
224/// GH #23 seam, kept out of the name-agnostic schema type. `naming: None`
225/// degrades to a literal string comparison, byte-identical to
226/// `ContextPolicy::allows_step` itself (defensive-only fallback, matching
227/// `crate::projection::McpQueryAdapter::step_naming_for_run`'s own
228/// contract).
229fn allows_step_canonical(
230    policy: &ContextPolicy,
231    naming: Option<&StepNaming>,
232    canonical_name: &str,
233) -> bool {
234    let resolves_to = |raw: &str| -> bool {
235        match naming {
236            Some(n) => n
237                .resolve(raw)
238                .map(|c| c == canonical_name)
239                .unwrap_or(raw == canonical_name),
240            None => raw == canonical_name,
241        }
242    };
243    if policy
244        .steps_exclude
245        .iter()
246        .any(|excluded| resolves_to(excluded))
247    {
248        return false;
249    }
250    match &policy.steps {
251        None => true,
252        Some(list) => list.iter().any(|included| resolves_to(included)),
253    }
254}
255
256/// Body for `POST /v1/worker/result`.
257#[derive(Debug, Deserialize)]
258pub struct WorkerResultReq {
259    /// Task this result belongs to (looked up together with the Bearer
260    /// token). Typed [`StepId`] since issue #14 (see [`PromptQuery`]).
261    pub task_id: StepId,
262    /// `WorkerResult.value` (= the value returned by the Operator: LLM inference result or tool execution result).
263    pub value: Value,
264    /// `WorkerResult.ok`. `false` makes the dispatch path decide Blocked
265    /// (= same semantics as `OutputEvent::Final { ok: false, .. }` from a
266    /// `SpawnerAdapter`). Defaults to `true`.
267    #[serde(default = "default_ok_true")]
268    pub ok: bool,
269    /// Optional explicit attempt. Normally omitted (= the server looks up `task.attempt`).
270    /// A carry for race-condition tests that need to write to a fixed attempt.
271    #[serde(default)]
272    pub attempt: Option<u32>,
273}
274
275fn default_ok_true() -> bool {
276    true
277}
278
279/// `POST /v1/worker/result`. Bearer = encoded `CapToken`.
280/// Fires `engine.submit_output(Final)` + `engine.post_result`.
281pub async fn worker_result(
282    State(state): State<AppState>,
283    headers: HeaderMap,
284    Json(req): Json<WorkerResultReq>,
285) -> Result<StatusCode, ApiError> {
286    let token = decode_worker_bearer(&headers)?;
287    let task_id = req.task_id.clone();
288
289    // Use body-explicit attempt if provided; otherwise the current task.attempt.
290    let attempt = match req.attempt {
291        Some(n) => n,
292        None => state
293            .engine
294            .task_attempt(&task_id)
295            .await
296            .map_err(|e| ApiError::engine(format!("task_attempt: {e}")))?,
297    };
298
299    let event = OutputEvent::Final {
300        content: ContentRef::Inline {
301            value: req.value.clone(),
302        },
303        ok: req.ok,
304    };
305    state
306        .engine
307        .submit_output(&token, &task_id, attempt, event)
308        .await
309        .map_err(|e| ApiError::engine(format!("submit_output: {e}")))?;
310    state
311        .engine
312        .post_result(&token, &task_id, req.value)
313        .await
314        .map_err(|e| ApiError::engine(format!("post_result: {e}")))?;
315    Ok(StatusCode::NO_CONTENT)
316}
317
318/// `POST /v1/worker/submit`. Bearer = encoded `CapToken`. Body = raw text/octet.
319///
320/// Simplification-axis endpoint for SubAgents. Removes the JSON construction,
321/// duplicated `task_id`, and JSON-escape burden of `/v1/worker/result` — the
322/// worker completes a POST with just token + raw body. Origin: the recent clean-up
323/// of the SubAgent contract drift (fewer IDs to pass around, multi-line escape
324/// accidents eliminated).
325///
326/// Behavior:
327/// - `task_id` is auto-looked-up server-side from the token (already bound to the `CapToken`).
328/// - Body raw bytes go as-is into `Value::String` for `submit_output` + `post_result`.
329/// - `ok=true` fixed (= the submit endpoint is success-path only). For the error
330///   path, use `/v1/worker/result` with an explicit `ok=false`.
331#[derive(Debug, Deserialize, Default)]
332pub struct SubmitQuery {
333    /// Optional. `ok=false` signals failure (= `DispatchOutcome::Blocked`, caught
334    /// by the flow.ir Try path). Unspecified (`None`) is treated as `ok=true`
335    /// (= normal success).
336    #[serde(default)]
337    pub ok: Option<bool>,
338}
339
340/// `POST /v1/worker/submit`. Simplified counterpart of [`worker_result`]:
341/// the caller sends only the raw result body, `task_id` is resolved
342/// server-side from the Bearer handle/token, and `ok` defaults to `true`
343/// unless overridden via [`SubmitQuery::ok`]. See the module doc for the
344/// short-handle vs full-`CapToken` Bearer forms.
345pub async fn worker_submit(
346    State(state): State<AppState>,
347    headers: HeaderMap,
348    Query(q): Query<SubmitQuery>,
349    body: axum::body::Bytes,
350) -> Result<StatusCode, ApiError> {
351    // Bearer accepts either (a) `wh-<8 hex>` short handle (recommended for
352    // SubAgents) or (b) base64-wrapped CapToken JSON (the full-token form).
353    let bearer = extract_bearer_raw(&headers)?;
354    let task_id = if let Some(handle) = parse_worker_handle(&bearer) {
355        state
356            .engine
357            .task_id_from_handle(handle)
358            .await
359            .map_err(|e| ApiError::engine(format!("task_id_from_handle: {e}")))?
360    } else {
361        let token = CapToken::decode(bearer.trim())
362            .map_err(|e| ApiError::bad_request(format!("invalid token: {e}")))?;
363        state
364            .engine
365            .task_id_from_token(&token)
366            .await
367            .map_err(|e| ApiError::engine(format!("task_id_from_token: {e}")))?
368    };
369    let attempt = state
370        .engine
371        .task_attempt(&task_id)
372        .await
373        .map_err(|e| ApiError::engine(format!("task_attempt: {e}")))?;
374    // GH #37: fail loud (410) instead of silently accepting a submit whose
375    // addressed Run is already terminal — see `reject_if_run_terminal`.
376    reject_if_run_terminal(&state, &task_id, attempt).await?;
377    // Strip trailing whitespace (newlines, etc.) so flow.ir `Eq` string matches
378    // don't drift on `"BLOCKED\n" == "BLOCKED"` false results. Origin: the recent clean-up
379    // verdict_loop smoke — sharp-edge removal. Internal `\n` inside the raw bytes
380    // is preserved (= only trailing).
381    let body_str = String::from_utf8_lossy(&body).trim_end().to_string();
382    let value = Value::String(body_str);
383
384    // The handle path = trusted internal API (= the server-minted handle is validated
385    // by the earlier lookup); the full-token path = existing verify-by-token API.
386    // Both are reflected identically into final + last_result.
387    // `?ok=false` in the query signals failure (= `DispatchOutcome::Blocked`,
388    // the flow.ir Try catch path).
389    let ok = q.ok.unwrap_or(true);
390    state
391        .engine
392        .submit_worker_result_trusted(&task_id, attempt, value, ok)
393        .await
394        .map_err(|e| ApiError::engine(format!("submit_worker_result_trusted: {e}")))?;
395    Ok(StatusCode::NO_CONTENT)
396}
397
398/// Query params for `POST /v1/worker/artifact`.
399#[derive(Debug, Deserialize)]
400pub struct ArtifactQuery {
401    /// Artifact name (GH #36 ST1: named multi-part worker output). Required
402    /// and non-empty (400 otherwise) — becomes the object key
403    /// `Engine::dispatch_attempt_with`'s Final-pull folds this part under
404    /// (`{"out": <final>, "parts": {<name>: <value>, ...}}`, see that
405    /// method's doc). No character restriction is enforced here (a BP
406    /// author references it via bracket notation, e.g. `$.out.parts["a.b"]`).
407    pub name: String,
408}
409
410/// `POST /v1/worker/artifact?name=<name>`. Bearer = same short-handle /
411/// full-`CapToken` forms as [`worker_submit`]. Body = raw text/octet.
412///
413/// Simplification-axis sibling of [`worker_submit`] (GH #36 ST1): lets a
414/// worker with more than one named result POST each part independently —
415/// same 1-part-per-POST simplicity as `/v1/worker/submit`, no Single Big
416/// JSON the worker has to construct/escape itself — then complete the
417/// attempt with an ordinary `/v1/worker/submit` (unchanged). Staging alone
418/// never completes the attempt; `dispatch_attempt_with` only pulls the
419/// tail's `Final` (whichever endpoint submits it) and folds every staged
420/// `Artifact` into `"parts"` at that point.
421///
422/// Behavior:
423/// - `task_id` is auto-looked-up server-side from the token/handle, same as
424///   [`worker_submit`].
425/// - `name` is required and non-empty; missing or blank → 400.
426/// - Body raw bytes go as-is into `Value::String` (same trailing-whitespace
427///   trim as `worker_submit`) and are staged via
428///   [`mlua_swarm::core::engine::Engine::stage_worker_artifact_trusted`].
429/// - Staging the same `name` twice within one attempt: last write wins (the
430///   Final-pull fold walks the tail in event order — see its doc).
431pub async fn worker_artifact(
432    State(state): State<AppState>,
433    headers: HeaderMap,
434    Query(q): Query<ArtifactQuery>,
435    body: axum::body::Bytes,
436) -> Result<StatusCode, ApiError> {
437    let name = q.name.trim();
438    if name.is_empty() {
439        return Err(ApiError::bad_request("name must not be empty".into()));
440    }
441    let name = name.to_string();
442
443    let bearer = extract_bearer_raw(&headers)?;
444    let task_id = if let Some(handle) = parse_worker_handle(&bearer) {
445        state
446            .engine
447            .task_id_from_handle(handle)
448            .await
449            .map_err(|e| ApiError::engine(format!("task_id_from_handle: {e}")))?
450    } else {
451        let token = CapToken::decode(bearer.trim())
452            .map_err(|e| ApiError::bad_request(format!("invalid token: {e}")))?;
453        state
454            .engine
455            .task_id_from_token(&token)
456            .await
457            .map_err(|e| ApiError::engine(format!("task_id_from_token: {e}")))?
458    };
459    let attempt = state
460        .engine
461        .task_attempt(&task_id)
462        .await
463        .map_err(|e| ApiError::engine(format!("task_attempt: {e}")))?;
464    // GH #37: fail loud (410) instead of silently staging a part whose
465    // addressed Run is already terminal — see `reject_if_run_terminal`.
466    reject_if_run_terminal(&state, &task_id, attempt).await?;
467    let body_str = String::from_utf8_lossy(&body).trim_end().to_string();
468    let value = Value::String(body_str);
469
470    state
471        .engine
472        .stage_worker_artifact_trusted(&task_id, attempt, name, value)
473        .await
474        .map_err(|e| ApiError::engine(format!("stage_worker_artifact_trusted: {e}")))?;
475    Ok(StatusCode::NO_CONTENT)
476}
477
478/// Body for `POST /v1/worker/degradation` (GH #32).
479#[derive(Debug, Deserialize)]
480pub struct DegradationBody {
481    /// The tool (or capability) the worker attempted to use.
482    pub tool: String,
483    /// The error that triggered the fallback, in the worker's own words.
484    pub error: String,
485    /// What the worker substituted instead of failing.
486    pub fallback: String,
487    /// Optional free-form context from the worker.
488    #[serde(default)]
489    pub note: Option<String>,
490}
491
492/// `POST /v1/worker/degradation` (GH #32). Bearer = same short-handle /
493/// full-`CapToken` forms as [`worker_submit`]. Body = JSON, not raw bytes —
494/// this endpoint carries structured data, unlike its raw-bytes siblings.
495///
496/// Independent channel: appends a [`DegradationEntry`] to
497/// `RunRecord.degradations` via `RunStore::append_degradation` directly.
498/// Never touches `OutputStore` / the fold path (Crux invariant 2 — a
499/// degradation must not surface as step OUTPUT / `$.step.parts`).
500///
501/// Behavior:
502/// - `task_id` is auto-looked-up server-side from the token/handle, same as
503///   [`worker_submit`] / [`worker_artifact`].
504/// - GH #37 terminal-run guard applies first — a degradation addressed at
505///   an already-terminal Run is rejected with `410 Gone`
506///   ([`reject_if_run_terminal`]), same as a submit/artifact would be.
507/// - `step_ref` / `attempt` / `at` are server-injected — `step_ref` is the
508///   fetching agent's resolved name (`AgentContextView.agent`, the best
509///   proxy for `Step.ref` available at this layer), `attempt` is the
510///   task's current attempt, `at` is now (Unix epoch seconds). The client
511///   body never supplies any of the three.
512/// - No Run linkage in `agent_ctx` (a pre-run-tracking dispatch), an
513///   unparseable `run_id`, or an `append_degradation` call against a Run
514///   the store doesn't actually hold (`RunStoreError::NotFound` — the same
515///   condition [`reject_if_run_terminal`] itself fails open on) all take
516///   the same silent `204 No Content` path, logged via `tracing::warn!` —
517///   this is a legitimate no-tracking codepath, not a client error. Any
518///   other `RunStore` failure propagates as `ApiError::engine`.
519pub async fn worker_degradation(
520    State(state): State<AppState>,
521    headers: HeaderMap,
522    Json(body): Json<DegradationBody>,
523) -> Result<StatusCode, ApiError> {
524    let bearer = extract_bearer_raw(&headers)?;
525    let task_id = if let Some(handle) = parse_worker_handle(&bearer) {
526        state
527            .engine
528            .task_id_from_handle(handle)
529            .await
530            .map_err(|e| ApiError::engine(format!("task_id_from_handle: {e}")))?
531    } else {
532        let token = CapToken::decode(bearer.trim())
533            .map_err(|e| ApiError::bad_request(format!("invalid token: {e}")))?;
534        state
535            .engine
536            .task_id_from_token(&token)
537            .await
538            .map_err(|e| ApiError::engine(format!("task_id_from_token: {e}")))?
539    };
540    let attempt = state
541        .engine
542        .task_attempt(&task_id)
543        .await
544        .map_err(|e| ApiError::engine(format!("task_attempt: {e}")))?;
545    // GH #37: the same terminal-run guard `worker_submit` / `worker_artifact`
546    // apply — a dead Run must not accumulate signals.
547    reject_if_run_terminal(&state, &task_id, attempt).await?;
548
549    // Same `with_state` resolution pattern as `reject_if_run_terminal`: an
550    // engine-level failure here is fail-open too (`_ => ...`), matching
551    // that guard's own "every resolution step is fail-open" contract —
552    // this lookup isn't a second, stricter gate on top of it.
553    let tid = task_id.clone();
554    let (run_id_str, agent) = match state
555        .engine
556        .with_state("worker_degradation_run_lookup", move |s| {
557            s.agent_ctx.get(&(tid, attempt)).and_then(|e| {
558                e.view
559                    .run_id
560                    .clone()
561                    .map(|run_id| (run_id, e.view.agent.clone()))
562            })
563        })
564        .await
565    {
566        Ok(Some(pair)) => pair,
567        _ => {
568            tracing::warn!(%task_id, "worker_degradation: no run linkage for this task; entry dropped");
569            return Ok(StatusCode::NO_CONTENT);
570        }
571    };
572    let Ok(run_id) = RunId::parse(run_id_str) else {
573        tracing::warn!(%task_id, "worker_degradation: run_id failed to parse; entry dropped");
574        return Ok(StatusCode::NO_CONTENT);
575    };
576
577    let entry = DegradationEntry {
578        tool: body.tool,
579        error: body.error,
580        fallback: body.fallback,
581        note: body.note,
582        step_ref: Some(agent),
583        attempt: Some(attempt),
584        at: crate::tasks::now_secs(),
585    };
586    match state.run_store.append_degradation(&run_id, entry).await {
587        Ok(()) => Ok(StatusCode::NO_CONTENT),
588        Err(RunStoreError::NotFound(_)) => {
589            tracing::warn!(%task_id, %run_id, "worker_degradation: run not found in run_store; entry dropped");
590            Ok(StatusCode::NO_CONTENT)
591        }
592        Err(e) => Err(ApiError::engine(format!("append_degradation: {e}"))),
593    }
594}
595
596/// GH #37: terminal-run guard shared by [`worker_submit`] / [`worker_artifact`].
597///
598/// Resolves the dispatch task's `AgentContextView.run_id` (threaded at
599/// spawn time when a `RunContext` accompanied the launch) and rejects the
600/// submit with `410 Gone` when the addressed Run has already reached a
601/// terminal status (`Done` / `Failed` / `Interrupted`) — the flow-eval
602/// driver for that Run is gone, so the staged/final value could never be
603/// folded into a flow context. Before this guard, such a submit was
604/// silently accepted with `204` and the worker's output orphaned — the
605/// exact failure shape observed when a long-running worker outlived the
606/// GH #33 sync launch ceiling.
607///
608/// Every resolution step is fail-open (missing agent-ctx entry / missing
609/// `run_id` / unparseable id / unknown Run → `Ok(())`), matching this
610/// crate's other best-effort projection hooks: a pre-run-tracking dispatch
611/// must keep working exactly as before.
612async fn reject_if_run_terminal(
613    state: &AppState,
614    task_id: &StepId,
615    attempt: u32,
616) -> Result<(), ApiError> {
617    let tid = task_id.clone();
618    let run_id_str = match state
619        .engine
620        .with_state("worker_terminal_run_guard", move |s| {
621            s.agent_ctx
622                .get(&(tid, attempt))
623                .and_then(|e| e.view.run_id.clone())
624        })
625        .await
626    {
627        Ok(Some(rid)) => rid,
628        _ => return Ok(()),
629    };
630    let Ok(run_id) = RunId::parse(run_id_str) else {
631        return Ok(());
632    };
633    let Ok(rec) = state.run_store.get(&run_id).await else {
634        return Ok(());
635    };
636    match rec.status {
637        RunStatus::Done | RunStatus::Failed | RunStatus::Interrupted => {
638            Err(ApiError::gone(format!(
639                "run {run_id} is already terminal ({:?}): this attempt's output cannot be \
640                 delivered to a flow context; re-kick the task (POST /v1/tasks/:id/runs) and \
641                 fetch a fresh prompt",
642                rec.status
643            )))
644        }
645        RunStatus::Pending | RunStatus::Running => Ok(()),
646    }
647}
648
649/// Query params for `GET /v1/worker/prompt/system`. Field names are fixed to
650/// `task_id` / `attempt` — this is the exact shape the engine bakes into
651/// `system_ref.uri`'s query string for `Http` mode (GH #31), so the names
652/// here must match verbatim.
653#[derive(Debug, Deserialize)]
654pub struct PromptSystemQuery {
655    /// Task the fetched raw system prompt belongs to; cross-checked
656    /// against the Bearer handle/token, same as [`PromptQuery::task_id`].
657    pub task_id: StepId,
658    /// Attempt number the baked system prompt was recorded under.
659    pub attempt: u32,
660}
661
662/// `GET /v1/worker/prompt/system?task_id=<tid>&attempt=<n>` (GH #31). The
663/// `Http`-mode fetch target for `system_ref.uri`: serves the exact baked
664/// `system` bytes for `(task_id, attempt)` as a raw `text/plain` body — not
665/// JSON-wrapped, since `mse_worker_fetch` needs the precise byte sequence to
666/// sha256-verify against `system_ref.sha256`.
667///
668/// Same Bearer auth flow as [`worker_prompt`] (short handle or full
669/// `CapToken`); 404 via [`ApiError::not_found`] if no baked system exists for
670/// that `(task_id, attempt)`.
671pub async fn worker_prompt_system(
672    State(state): State<AppState>,
673    headers: HeaderMap,
674    Query(q): Query<PromptSystemQuery>,
675) -> Result<impl axum::response::IntoResponse, ApiError> {
676    let task_id = q.task_id;
677    let attempt = q.attempt;
678    let bearer = extract_bearer_raw(&headers)?;
679    if let Some(handle) = parse_worker_handle(&bearer) {
680        let resolved = state
681            .engine
682            .task_id_from_handle(handle)
683            .await
684            .map_err(|e| ApiError::engine(format!("task_id_from_handle: {e}")))?;
685        if resolved != task_id {
686            return Err(ApiError::bad_request(format!(
687                "handle {handle} is bound to task {resolved}, not {task_id}"
688            )));
689        }
690    } else {
691        let token = CapToken::decode(bearer.trim())
692            .map_err(|e| ApiError::bad_request(format!("invalid token: {e}")))?;
693        state
694            .engine
695            .verify_token_for_task(&token, mlua_swarm::Verb::FetchPrompt, &task_id)
696            .await
697            .map_err(|e| ApiError::engine(format!("verify_token_for_task: {e}")))?;
698    }
699    let system = state
700        .engine
701        .raw_system_prompt(&task_id, attempt)
702        .await
703        .map_err(|e| ApiError::engine(format!("raw_system_prompt: {e}")))?
704        .ok_or_else(|| {
705            ApiError::not_found(format!(
706                "no baked system prompt for task {task_id} attempt {attempt}"
707            ))
708        })?;
709    Ok((
710        [(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
711        system,
712    ))
713}
714
715/// Response body for `GET /v1/agents/:name/render-size`.
716#[derive(Debug, serde::Serialize)]
717pub struct AgentRenderSizeResponse {
718    /// The agent name looked up (echoed back verbatim from the path param).
719    pub agent: String,
720    /// Most-recently-baked `system_prompt` render size in bytes for this
721    /// agent, or `None` if `bake_worker_system_prompt` has never recorded
722    /// one (a freshly-added agent that has never been dispatched).
723    pub last_rendered_bytes: Option<usize>,
724}
725
726/// `GET /v1/agents/:name/render-size` (GH #31). Live per-agent-name lookup
727/// of the most-recently-baked `system_prompt` render size, backing
728/// `bp_doctor`'s post-render size check. No Bearer required — same
729/// unauthenticated trust tier as `GET /v1/blueprints/:id/head`
730/// (`blueprints::get_head`), an operator-diagnostic route.
731///
732/// `last_rendered_bytes: null` is a normal, expected response (a
733/// freshly-added agent that has never been dispatched yet) — always
734/// `200 OK`, never a 404.
735pub async fn agent_render_size(
736    State(state): State<AppState>,
737    axum::extract::Path(name): axum::extract::Path<String>,
738) -> Json<AgentRenderSizeResponse> {
739    let last_rendered_bytes = state.engine.agent_last_rendered_size(&name).await;
740    Json(AgentRenderSizeResponse {
741        agent: name,
742        last_rendered_bytes,
743    })
744}
745
746/// Extracts the raw string from the `Authorization` header (= strips the `Bearer `
747/// prefix). To let `worker_submit` accept both short handles and full tokens, we
748/// fetch the raw value before any decode.
749fn extract_bearer_raw(headers: &HeaderMap) -> Result<String, ApiError> {
750    let v = headers
751        .get(AUTHORIZATION)
752        .ok_or_else(|| ApiError::bad_request("missing Authorization header".into()))?
753        .to_str()
754        .map_err(|_| ApiError::bad_request("invalid Authorization header encoding".into()))?;
755    let s = v
756        .strip_prefix("Bearer ")
757        .ok_or_else(|| ApiError::bad_request("Authorization must be 'Bearer <token>'".into()))?
758        .trim();
759    if s.is_empty() {
760        return Err(ApiError::bad_request("Bearer is empty".into()));
761    }
762    Ok(s.to_string())
763}
764
765/// Decides whether the Bearer is a short handle (`wh-XXXXXXXX`). Returns
766/// `Some(handle)` on a match, `None` otherwise (= caller proceeds to try decoding
767/// as full `CapToken` JSON).
768fn parse_worker_handle(s: &str) -> Option<&str> {
769    let s = s.trim();
770    if s.starts_with("wh-")
771        && s.len() >= 5
772        && s.len() <= 64
773        && s[3..].chars().all(|c| c.is_ascii_alphanumeric())
774    {
775        Some(s)
776    } else {
777        None
778    }
779}
780
781/// Decodes an encoded `CapToken` from `Authorization: Bearer <encoded CapToken>`.
782/// Kept separate from `extract_bearer` (sid-only) — kept as a distinct fn so
783/// that sid strings and encoded tokens are not confused, distinguishing them by type.
784fn decode_worker_bearer(headers: &HeaderMap) -> Result<CapToken, ApiError> {
785    let v = headers
786        .get(AUTHORIZATION)
787        .ok_or_else(|| ApiError::bad_request("missing Authorization header".into()))?
788        .to_str()
789        .map_err(|_| ApiError::bad_request("invalid Authorization header encoding".into()))?;
790    let encoded = v
791        .strip_prefix("Bearer ")
792        .ok_or_else(|| ApiError::bad_request("Authorization must be 'Bearer <token>'".into()))?
793        .trim();
794    if encoded.is_empty() {
795        return Err(ApiError::bad_request("Bearer token is empty".into()));
796    }
797    CapToken::decode(encoded).map_err(|e| ApiError::bad_request(format!("invalid token: {e}")))
798}
799
800// ──────────────────────────────────────────────────────────────────────────
801// UT — `assemble_step_pointers` (`projection-adapter` ST5 Worker axis)
802// ──────────────────────────────────────────────────────────────────────────
803
804#[cfg(test)]
805mod tests {
806    use super::*;
807    use axum::response::IntoResponse;
808    use mlua_swarm::core::agent_context::AgentContextView;
809    use mlua_swarm::core::config::EngineCfg;
810    use mlua_swarm::core::engine::Engine;
811    use mlua_swarm::store::output::{InMemoryOutputStore, OutputStore};
812    use mlua_swarm::store::run::{InMemoryRunStore, RunRecord, RunStatus, RunStore, StepEntry};
813    use mlua_swarm::store::task::InMemoryTaskStore;
814    use mlua_swarm::{RunId, StepId, TaskId};
815    use serde_json::json;
816    use std::collections::HashMap;
817    use std::sync::Arc;
818    use tokio::sync::Mutex;
819
820    /// Per-module test-helper convention (this crate's established
821    /// pattern — see e.g. `projection::tests::test_state`): a minimal
822    /// `AppState` wired with the caller-supplied `data_store` / `run_store`
823    /// so a test can seed both directly rather than driving a real
824    /// dispatch through them.
825    fn test_state(data_store: Arc<dyn OutputStore>, run_store: Arc<dyn RunStore>) -> AppState {
826        let engine = Engine::new(EngineCfg::default());
827        let compiler = mlua_swarm::Compiler::new(crate::default_registry());
828        let launch = Arc::new(mlua_swarm::TaskLaunchService::new(engine.clone(), compiler));
829        AppState {
830            engine,
831            sessions: Arc::new(Mutex::new(crate::SessionStore::default())),
832            task_app: Arc::new(mlua_swarm::TaskApplication::new_inline_only(launch)),
833            ws_operator_factory: None,
834            data_store,
835            operator_sessions: Arc::new(Mutex::new(HashMap::new())),
836            roles_to_sid: Arc::new(Mutex::new(HashMap::new())),
837            task_store: Arc::new(InMemoryTaskStore::new()),
838            run_store,
839            base_url: None,
840            sync_timeout_secs: 300,
841        }
842    }
843
844    async fn append_final(
845        data_store: &Arc<dyn OutputStore>,
846        task_id: &str,
847        producer: &str,
848        value: Value,
849    ) {
850        data_store
851            .append(
852                task_id,
853                1,
854                producer,
855                OutputEvent::Final {
856                    content: ContentRef::Inline { value },
857                    ok: true,
858                },
859                vec![],
860            )
861            .await
862            .expect("append final");
863    }
864
865    fn step_entry(step_id: &StepId, step_ref: &str) -> StepEntry {
866        StepEntry {
867            step_id: step_id.clone(),
868            step_ref: Some(step_ref.to_string()),
869            status: Some("passed".to_string()),
870            at: 0,
871        }
872    }
873
874    fn run_record(task_id: &TaskId, run_id: &RunId, step_entries: Vec<StepEntry>) -> RunRecord {
875        RunRecord {
876            id: run_id.clone(),
877            task_id: task_id.clone(),
878            status: RunStatus::Running,
879            step_entries,
880            degradations: Vec::new(),
881            operator_sid: None,
882            result_ref: None,
883            created_at: 0,
884            updated_at: 0,
885        }
886    }
887
888    fn consumer_payload(consumer_step_id: &StepId, run_id: &RunId) -> WorkerPayload {
889        WorkerPayload {
890            task_id: consumer_step_id.clone(),
891            attempt: 1,
892            agent: "consumer".to_string(),
893            system: None,
894            prompt: String::new(),
895            context: Some(AgentContextView {
896                task_id: consumer_step_id.to_string(),
897                agent: "consumer".to_string(),
898                attempt: 1,
899                run_id: Some(run_id.to_string()),
900                ..Default::default()
901            }),
902            system_ref: None,
903        }
904    }
905
906    /// Test 1: `ContextPolicy.steps` unspecified (no policy seeded at all
907    /// — `Engine::context_policy_for`'s "no entry" default is `None` /
908    /// pass-all) → the fetch payload carries every submitted step's
909    /// `StepPointer`.
910    #[tokio::test]
911    async fn context_policy_unspecified_yields_every_submitted_step() {
912        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
913        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
914        let task_id = TaskId::new();
915        let run_id = RunId::new();
916        let planner_id = StepId::new();
917        let coder_id = StepId::new();
918
919        append_final(
920            &data_store,
921            planner_id.as_str(),
922            "planner",
923            json!({"plan": "x"}),
924        )
925        .await;
926        append_final(
927            &data_store,
928            coder_id.as_str(),
929            "coder",
930            json!({"code": "y"}),
931        )
932        .await;
933        run_store
934            .create(run_record(
935                &task_id,
936                &run_id,
937                vec![
938                    step_entry(&planner_id, "planner"),
939                    step_entry(&coder_id, "coder"),
940                ],
941            ))
942            .await
943            .expect("create run");
944
945        let state = test_state(data_store, run_store);
946        let consumer_id = StepId::new();
947        let mut payload = consumer_payload(&consumer_id, &run_id);
948        assemble_step_pointers(&state, &mut payload).await;
949
950        let names: Vec<&str> = payload
951            .context
952            .as_ref()
953            .expect("context")
954            .steps
955            .iter()
956            .map(|p| p.name.as_str())
957            .collect();
958        assert!(names.contains(&"planner"), "names: {names:?}");
959        assert!(names.contains(&"coder"), "names: {names:?}");
960    }
961
962    /// Test 2: `steps: ["planner"]` → only `planner`'s pointer is present.
963    #[tokio::test]
964    async fn context_policy_steps_include_list_filters_to_named_steps() {
965        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
966        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
967        let task_id = TaskId::new();
968        let run_id = RunId::new();
969        let planner_id = StepId::new();
970        let coder_id = StepId::new();
971        append_final(&data_store, planner_id.as_str(), "planner", json!("x")).await;
972        append_final(&data_store, coder_id.as_str(), "coder", json!("y")).await;
973        run_store
974            .create(run_record(
975                &task_id,
976                &run_id,
977                vec![
978                    step_entry(&planner_id, "planner"),
979                    step_entry(&coder_id, "coder"),
980                ],
981            ))
982            .await
983            .expect("create run");
984
985        let state = test_state(data_store, run_store);
986        let consumer_id = StepId::new();
987        state
988            .engine
989            .with_state("test.seed_policy", {
990                let consumer_id = consumer_id.clone();
991                move |s| {
992                    s.agent_ctx.insert(
993                        (consumer_id, 1),
994                        mlua_swarm::core::state::AgentCtxEntry {
995                            policy: mlua_swarm_schema::ContextPolicy {
996                                steps: Some(vec!["planner".to_string()]),
997                                ..Default::default()
998                            },
999                            ..Default::default()
1000                        },
1001                    );
1002                }
1003            })
1004            .await
1005            .expect("seed policy");
1006
1007        let mut payload = consumer_payload(&consumer_id, &run_id);
1008        assemble_step_pointers(&state, &mut payload).await;
1009
1010        let names: Vec<&str> = payload
1011            .context
1012            .as_ref()
1013            .expect("context")
1014            .steps
1015            .iter()
1016            .map(|p| p.name.as_str())
1017            .collect();
1018        assert_eq!(names, vec!["planner"], "names: {names:?}");
1019    }
1020
1021    /// Test 3: `steps: []` → the pointer list is empty.
1022    #[tokio::test]
1023    async fn context_policy_steps_empty_list_yields_no_pointers() {
1024        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1025        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1026        let task_id = TaskId::new();
1027        let run_id = RunId::new();
1028        let planner_id = StepId::new();
1029        append_final(&data_store, planner_id.as_str(), "planner", json!("x")).await;
1030        run_store
1031            .create(run_record(
1032                &task_id,
1033                &run_id,
1034                vec![step_entry(&planner_id, "planner")],
1035            ))
1036            .await
1037            .expect("create run");
1038
1039        let state = test_state(data_store, run_store);
1040        let consumer_id = StepId::new();
1041        state
1042            .engine
1043            .with_state("test.seed_policy", {
1044                let consumer_id = consumer_id.clone();
1045                move |s| {
1046                    s.agent_ctx.insert(
1047                        (consumer_id, 1),
1048                        mlua_swarm::core::state::AgentCtxEntry {
1049                            policy: mlua_swarm_schema::ContextPolicy {
1050                                steps: Some(vec![]),
1051                                ..Default::default()
1052                            },
1053                            ..Default::default()
1054                        },
1055                    );
1056                }
1057            })
1058            .await
1059            .expect("seed policy");
1060
1061        let mut payload = consumer_payload(&consumer_id, &run_id);
1062        assemble_step_pointers(&state, &mut payload).await;
1063
1064        assert!(payload.context.expect("context").steps.is_empty());
1065    }
1066
1067    /// Test 4: `steps_exclude` wins over `steps` for a name in both.
1068    #[tokio::test]
1069    async fn context_policy_steps_exclude_wins_over_steps() {
1070        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1071        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1072        let task_id = TaskId::new();
1073        let run_id = RunId::new();
1074        let planner_id = StepId::new();
1075        let coder_id = StepId::new();
1076        append_final(&data_store, planner_id.as_str(), "planner", json!("x")).await;
1077        append_final(&data_store, coder_id.as_str(), "coder", json!("y")).await;
1078        run_store
1079            .create(run_record(
1080                &task_id,
1081                &run_id,
1082                vec![
1083                    step_entry(&planner_id, "planner"),
1084                    step_entry(&coder_id, "coder"),
1085                ],
1086            ))
1087            .await
1088            .expect("create run");
1089
1090        let state = test_state(data_store, run_store);
1091        let consumer_id = StepId::new();
1092        state
1093            .engine
1094            .with_state("test.seed_policy", {
1095                let consumer_id = consumer_id.clone();
1096                move |s| {
1097                    s.agent_ctx.insert(
1098                        (consumer_id, 1),
1099                        mlua_swarm::core::state::AgentCtxEntry {
1100                            policy: mlua_swarm_schema::ContextPolicy {
1101                                steps: Some(vec!["planner".to_string(), "coder".to_string()]),
1102                                steps_exclude: vec!["planner".to_string()],
1103                                ..Default::default()
1104                            },
1105                            ..Default::default()
1106                        },
1107                    );
1108                }
1109            })
1110            .await
1111            .expect("seed policy");
1112
1113        let mut payload = consumer_payload(&consumer_id, &run_id);
1114        assemble_step_pointers(&state, &mut payload).await;
1115
1116        let names: Vec<&str> = payload
1117            .context
1118            .as_ref()
1119            .expect("context")
1120            .steps
1121            .iter()
1122            .map(|p| p.name.as_str())
1123            .collect();
1124        assert_eq!(names, vec!["coder"], "names: {names:?}");
1125    }
1126
1127    /// Test 5 (in-flight window, subtask-4-style invariant): the Run has
1128    /// NOT finalized (`result_ref: None`, mirroring a Run still `Running`)
1129    /// yet the fetch payload still carries a `StepPointer` for a step
1130    /// already visible through the Data-plane store — the same mechanism
1131    /// `crates/mlua-swarm-server/src/projection.rs`'s
1132    /// `steps_list_returns_in_flight_step_output_before_run_completes`
1133    /// proves end-to-end through a real gated 2-step dispatch; this test
1134    /// isolates the same invariant at the `assemble_step_pointers` level.
1135    #[tokio::test]
1136    async fn in_flight_step_output_is_visible_before_run_finalizes() {
1137        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1138        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1139        let task_id = TaskId::new();
1140        let run_id = RunId::new();
1141        let step1_id = StepId::new();
1142        append_final(
1143            &data_store,
1144            step1_id.as_str(),
1145            "step1",
1146            json!({"step1_out": "hi"}),
1147        )
1148        .await;
1149        let mut run = run_record(&task_id, &run_id, vec![step_entry(&step1_id, "step1")]);
1150        run.status = RunStatus::Running;
1151        run.result_ref = None; // the in-flight window: not yet finalized.
1152        run_store.create(run).await.expect("create run");
1153
1154        let state = test_state(data_store, run_store);
1155        let consumer_id = StepId::new();
1156        let mut payload = consumer_payload(&consumer_id, &run_id);
1157        assemble_step_pointers(&state, &mut payload).await;
1158
1159        let steps = &payload.context.expect("context").steps;
1160        assert_eq!(steps.len(), 1);
1161        assert_eq!(steps[0].name, "step1");
1162    }
1163
1164    /// Test 6: the fetching agent's own name is always excluded, even if
1165    /// (e.g. a loop re-dispatching the same agent) it also appears in
1166    /// `run.step_entries` with a resolvable Data-plane record.
1167    #[tokio::test]
1168    async fn self_agent_name_is_always_excluded() {
1169        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1170        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1171        let task_id = TaskId::new();
1172        let run_id = RunId::new();
1173        let planner_id = StepId::new();
1174        let consumer_prior_id = StepId::new();
1175        append_final(&data_store, planner_id.as_str(), "planner", json!("x")).await;
1176        append_final(
1177            &data_store,
1178            consumer_prior_id.as_str(),
1179            "consumer",
1180            json!("self"),
1181        )
1182        .await;
1183        run_store
1184            .create(run_record(
1185                &task_id,
1186                &run_id,
1187                vec![
1188                    step_entry(&planner_id, "planner"),
1189                    step_entry(&consumer_prior_id, "consumer"),
1190                ],
1191            ))
1192            .await
1193            .expect("create run");
1194
1195        let state = test_state(data_store, run_store);
1196        let consumer_id = StepId::new();
1197        let mut payload = consumer_payload(&consumer_id, &run_id);
1198        assemble_step_pointers(&state, &mut payload).await;
1199
1200        let names: Vec<&str> = payload
1201            .context
1202            .as_ref()
1203            .expect("context")
1204            .steps
1205            .iter()
1206            .map(|p| p.name.as_str())
1207            .collect();
1208        assert!(!names.contains(&"consumer"), "names: {names:?}");
1209        assert!(names.contains(&"planner"), "names: {names:?}");
1210    }
1211
1212    /// Test 7 (pointer-only invariant): a `StepPointer`'s serialized JSON
1213    /// carries no preview / content-bytes field — only `name` /
1214    /// `size_bytes` / `file_path?` / `content_url` / `sha256`.
1215    #[tokio::test]
1216    async fn step_pointer_serializes_with_no_preview_or_content_bytes() {
1217        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1218        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1219        let task_id = TaskId::new();
1220        let run_id = RunId::new();
1221        let planner_id = StepId::new();
1222        append_final(
1223            &data_store,
1224            planner_id.as_str(),
1225            "planner",
1226            json!({"plan": "do the thing, at length".repeat(50)}),
1227        )
1228        .await;
1229        run_store
1230            .create(run_record(
1231                &task_id,
1232                &run_id,
1233                vec![step_entry(&planner_id, "planner")],
1234            ))
1235            .await
1236            .expect("create run");
1237
1238        let state = test_state(data_store, run_store);
1239        let consumer_id = StepId::new();
1240        let mut payload = consumer_payload(&consumer_id, &run_id);
1241        assemble_step_pointers(&state, &mut payload).await;
1242
1243        let steps = &payload.context.expect("context").steps;
1244        assert_eq!(steps.len(), 1);
1245        let json_value = serde_json::to_value(&steps[0]).expect("serialize StepPointer");
1246        let obj = json_value.as_object().expect("object");
1247        for forbidden in ["preview", "content", "value", "bytes"] {
1248            assert!(
1249                !obj.contains_key(forbidden),
1250                "StepPointer must not carry a {forbidden:?} field: {obj:?}"
1251            );
1252        }
1253        assert!(obj.contains_key("name"));
1254        assert!(obj.contains_key("size_bytes"));
1255        assert!(obj.contains_key("content_url"));
1256        assert!(obj.contains_key("sha256"));
1257    }
1258
1259    /// A single-step Blueprint whose `planner` agent declares
1260    /// `AgentMeta.projection_name = "plan-out"` — the `StepNaming` fixture
1261    /// for [`declared_projection_name_pointer_name_is_canonical_and_policy_matches_it`],
1262    /// mirroring `crate::projection::tests`' own
1263    /// `declared_projection_name_blueprint` helper (duplicated here rather
1264    /// than shared — this crate's established per-module test-helper
1265    /// convention).
1266    fn declared_name_bp() -> mlua_swarm::blueprint::Blueprint {
1267        use mlua_flow_ir::{Expr, Node};
1268        use mlua_swarm::blueprint::{
1269            current_schema_version, AgentDef, AgentKind, AgentMeta, Blueprint, BlueprintMetadata,
1270            CompilerHints, CompilerStrategy,
1271        };
1272        Blueprint {
1273            schema_version: current_schema_version(),
1274            id: "worker-test-declared-name-bp".into(),
1275            flow: Node::Step {
1276                ref_: "planner".to_string(),
1277                in_: Expr::Path {
1278                    at: "$.in".to_string(),
1279                },
1280                out: Expr::Path {
1281                    at: "$.plan".to_string(),
1282                },
1283            },
1284            agents: vec![AgentDef {
1285                name: "planner".to_string(),
1286                kind: AgentKind::RustFn,
1287                spec: json!({"fn_id": "planner"}),
1288                profile: None,
1289                meta: Some(AgentMeta {
1290                    projection_name: Some("plan-out".to_string()),
1291                    ..Default::default()
1292                }),
1293            }],
1294            operators: vec![],
1295            metas: vec![],
1296            hints: CompilerHints::default(),
1297            strategy: CompilerStrategy::default(),
1298            metadata: BlueprintMetadata::default(),
1299            spawner_hints: Default::default(),
1300            default_agent_kind: AgentKind::Operator,
1301            default_operator_kind: None,
1302            default_init_ctx: None,
1303            default_agent_ctx: None,
1304            default_context_policy: None,
1305            projection_placement: None,
1306            audits: vec![],
1307            degradation_policy: None,
1308        }
1309    }
1310
1311    /// Test 8 (GH #23 subtask-3, declared-name E2E — Worker axis half): a
1312    /// declared `projection_name` makes `StepPointer.name` the CANONICAL
1313    /// name (not the raw `Step.ref` the Data-plane / `step_entries` still
1314    /// index by), and `ContextPolicy.steps` naming the canonical name
1315    /// matches it.
1316    #[tokio::test]
1317    async fn declared_projection_name_pointer_name_is_canonical_and_policy_matches_it() {
1318        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1319        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1320        let task_id = TaskId::new();
1321        let run_id = RunId::new();
1322        let planner_id = StepId::new();
1323
1324        // The Data-plane store is keyed by the CANONICAL name — GH #23
1325        // subtask-2's sink already writes it that way.
1326        append_final(
1327            &data_store,
1328            planner_id.as_str(),
1329            "plan-out",
1330            json!({"plan": "x"}),
1331        )
1332        .await;
1333        run_store
1334            .create(run_record(
1335                &task_id,
1336                &run_id,
1337                vec![step_entry(&planner_id, "planner")],
1338            ))
1339            .await
1340            .expect("create run");
1341
1342        let state = test_state(data_store, run_store);
1343
1344        // Seed the `StepNaming` table the way `Compiler::compile` +
1345        // `EngineDispatcher::dispatch` would have — the same `Arc` stashed
1346        // under every dispatched step's own id, including the FETCHING
1347        // agent's (`consumer_id`), which `assemble_step_pointers` looks up
1348        // via `Engine::step_naming_for(&payload.task_id)`.
1349        let (naming, _warnings) =
1350            mlua_swarm::core::step_naming::StepNaming::from_blueprint(&declared_name_bp())
1351                .expect("no collision");
1352        let naming = Arc::new(naming);
1353        let consumer_id = StepId::new();
1354        state
1355            .engine
1356            .with_state("test.seed_step_naming", {
1357                let naming = naming.clone();
1358                let planner_id = planner_id.clone();
1359                let consumer_id = consumer_id.clone();
1360                move |s| {
1361                    s.step_namings.insert(planner_id, naming.clone());
1362                    s.step_namings.insert(consumer_id, naming);
1363                }
1364            })
1365            .await
1366            .expect("seed step naming");
1367        state
1368            .engine
1369            .with_state("test.seed_policy", {
1370                let consumer_id = consumer_id.clone();
1371                move |s| {
1372                    s.agent_ctx.insert(
1373                        (consumer_id, 1),
1374                        mlua_swarm::core::state::AgentCtxEntry {
1375                            policy: mlua_swarm_schema::ContextPolicy {
1376                                steps: Some(vec!["plan-out".to_string()]),
1377                                ..Default::default()
1378                            },
1379                            ..Default::default()
1380                        },
1381                    );
1382                }
1383            })
1384            .await
1385            .expect("seed policy");
1386
1387        let mut payload = consumer_payload(&consumer_id, &run_id);
1388        assemble_step_pointers(&state, &mut payload).await;
1389
1390        let steps = &payload.context.expect("context").steps;
1391        assert_eq!(steps.len(), 1, "steps: {steps:?}");
1392        assert_eq!(
1393            steps[0].name, "plan-out",
1394            "StepPointer.name must be the canonical name"
1395        );
1396    }
1397
1398    // ──────────────────────────────────────────────────────────────────────
1399    // GH #31 — `/v1/worker/prompt/system` + `/v1/agents/:name/render-size`
1400    // ──────────────────────────────────────────────────────────────────────
1401
1402    /// Seeds a task + baked system prompt + a short worker handle bound to
1403    /// it, mirroring the shape `Engine::dispatch_attempt` would have
1404    /// produced (minus the parts these two routes don't touch: no real
1405    /// HMAC-signed `CapToken`, since `task_id_from_handle`'s handle → fp →
1406    /// task_id chain is what's under test, not signature verification).
1407    async fn seed_task_with_handle(
1408        state: &AppState,
1409        task_id: &StepId,
1410        agent: &str,
1411        attempt: u32,
1412        system: Option<String>,
1413    ) -> String {
1414        let handle = format!("wh-{}", mlua_swarm::types::secure_hex(4));
1415        let task_id = task_id.clone();
1416        let agent = agent.to_string();
1417        let handle_clone = handle.clone();
1418        state
1419            .engine
1420            .with_state("test.seed_task_with_handle", move |s| {
1421                let mut task = mlua_swarm::core::state::TaskState::new(
1422                    task_id.clone(),
1423                    mlua_swarm::core::state::TaskSpec {
1424                        agent: agent.clone(),
1425                        initial_directive: json!("x"),
1426                        step_ctx: None,
1427                    },
1428                );
1429                task.attempt = attempt;
1430                s.tasks.insert(task_id.clone(), task);
1431                s.systems.insert((task_id.clone(), attempt), system);
1432                let token = CapToken {
1433                    agent_id: agent,
1434                    role: mlua_swarm::Role::Worker,
1435                    scopes: vec!["*".to_string()],
1436                    issued_at: 0,
1437                    expire_at: u64::MAX,
1438                    max_uses: None,
1439                    nonce: format!("test-nonce-{task_id}"),
1440                    sig_hex: String::new(),
1441                };
1442                let fp = token.fingerprint();
1443                s.tokens.insert(
1444                    fp.clone(),
1445                    mlua_swarm::core::state::CapTokenRecord {
1446                        token,
1447                        uses_left: None,
1448                        revoked: false,
1449                        task_id: Some(task_id),
1450                    },
1451                );
1452                s.worker_handles.insert(handle_clone, fp);
1453            })
1454            .await
1455            .expect("seed_task_with_handle");
1456        handle
1457    }
1458
1459    fn bearer_headers(handle: &str) -> HeaderMap {
1460        let mut headers = HeaderMap::new();
1461        headers.insert(
1462            AUTHORIZATION,
1463            format!("Bearer {handle}").parse().expect("header value"),
1464        );
1465        headers
1466    }
1467
1468    /// `GET /v1/worker/prompt/system` returns the exact raw baked bytes
1469    /// (not JSON-wrapped) with `Content-Type: text/plain`, for the
1470    /// `(task_id, attempt)` the handle is bound to.
1471    #[tokio::test]
1472    async fn worker_prompt_system_returns_raw_bytes_for_baked_system() {
1473        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1474        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1475        let state = test_state(data_store, run_store);
1476        let task_id = StepId::new();
1477        let rendered = "# Hello\n\nThis is the baked system prompt.".to_string();
1478        let handle =
1479            seed_task_with_handle(&state, &task_id, "planner", 1, Some(rendered.clone())).await;
1480
1481        let resp = worker_prompt_system(
1482            State(state.clone()),
1483            bearer_headers(&handle),
1484            Query(PromptSystemQuery {
1485                task_id: task_id.clone(),
1486                attempt: 1,
1487            }),
1488        )
1489        .await
1490        .expect("worker_prompt_system")
1491        .into_response();
1492
1493        assert_eq!(resp.status(), StatusCode::OK);
1494        let content_type = resp
1495            .headers()
1496            .get(header::CONTENT_TYPE)
1497            .expect("content-type header")
1498            .to_str()
1499            .expect("ascii");
1500        assert_eq!(content_type, "text/plain; charset=utf-8");
1501        let body_bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1502            .await
1503            .expect("body bytes");
1504        assert_eq!(body_bytes.as_ref(), rendered.as_bytes());
1505    }
1506
1507    /// No baked system for the given `(task_id, attempt)` → 404, not a
1508    /// panic or a 200-with-empty-body.
1509    #[tokio::test]
1510    async fn worker_prompt_system_404s_when_no_baked_system() {
1511        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1512        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1513        let state = test_state(data_store, run_store);
1514        let task_id = StepId::new();
1515        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
1516
1517        let result = worker_prompt_system(
1518            State(state.clone()),
1519            bearer_headers(&handle),
1520            Query(PromptSystemQuery {
1521                task_id: task_id.clone(),
1522                attempt: 1,
1523            }),
1524        )
1525        .await;
1526        let err = match result {
1527            Ok(_) => panic!("expected 404 ApiError, got Ok"),
1528            Err(e) => e,
1529        };
1530        assert_eq!(err.into_response().status(), StatusCode::NOT_FOUND);
1531    }
1532
1533    /// A handle bound to a different task than the one requested must be
1534    /// rejected (400) — this is the same cross-check `worker_prompt` does.
1535    #[tokio::test]
1536    async fn worker_prompt_system_rejects_handle_task_mismatch() {
1537        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1538        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1539        let state = test_state(data_store, run_store);
1540        let task_id = StepId::new();
1541        let other_task_id = StepId::new();
1542        let handle =
1543            seed_task_with_handle(&state, &task_id, "planner", 1, Some("x".to_string())).await;
1544
1545        let result = worker_prompt_system(
1546            State(state.clone()),
1547            bearer_headers(&handle),
1548            Query(PromptSystemQuery {
1549                task_id: other_task_id,
1550                attempt: 1,
1551            }),
1552        )
1553        .await;
1554        let err = match result {
1555            Ok(_) => panic!("expected 400 ApiError for task mismatch, got Ok"),
1556            Err(e) => e,
1557        };
1558        assert_eq!(err.into_response().status(), StatusCode::BAD_REQUEST);
1559    }
1560
1561    /// `GET /v1/agents/:name/render-size` requires no auth, and reports
1562    /// `last_rendered_bytes: null` for an agent that has never had a
1563    /// `system_prompt` baked — a normal 200, not a 404.
1564    #[tokio::test]
1565    async fn agent_render_size_returns_null_for_unknown_agent() {
1566        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1567        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1568        let state = test_state(data_store, run_store);
1569
1570        let Json(body) = agent_render_size(
1571            State(state.clone()),
1572            axum::extract::Path("never-dispatched".to_string()),
1573        )
1574        .await;
1575        assert_eq!(body.agent, "never-dispatched");
1576        assert_eq!(body.last_rendered_bytes, None);
1577    }
1578
1579    /// Once `bake_worker_system_prompt` has recorded a render size for an
1580    /// agent, the route reports the most-recently-observed value.
1581    #[tokio::test]
1582    async fn agent_render_size_reports_last_rendered_bytes() {
1583        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1584        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1585        let state = test_state(data_store, run_store);
1586        let task_id = StepId::new();
1587        state
1588            .engine
1589            .with_state("test.seed_agent_ctx_for_bake", {
1590                let task_id = task_id.clone();
1591                move |s| {
1592                    s.tasks.insert(
1593                        task_id.clone(),
1594                        mlua_swarm::core::state::TaskState::new(
1595                            task_id,
1596                            mlua_swarm::core::state::TaskSpec {
1597                                agent: "coder".to_string(),
1598                                initial_directive: json!("x"),
1599                                step_ctx: None,
1600                            },
1601                        ),
1602                    );
1603                }
1604            })
1605            .await
1606            .expect("seed task");
1607        state
1608            .engine
1609            .bake_worker_system_prompt(&task_id, 1, Some("z".repeat(42)))
1610            .await
1611            .expect("bake_worker_system_prompt");
1612
1613        let Json(body) = agent_render_size(
1614            State(state.clone()),
1615            axum::extract::Path("coder".to_string()),
1616        )
1617        .await;
1618        assert_eq!(body.agent, "coder");
1619        assert_eq!(body.last_rendered_bytes, Some(42));
1620    }
1621
1622    // ──────────────────────────────────────────────────────────────────────
1623    // GH #36 ST1 — `POST /v1/worker/artifact`
1624    // ──────────────────────────────────────────────────────────────────────
1625
1626    /// A valid `?name=` + short-handle Bearer stages the raw body (trailing
1627    /// whitespace trimmed, same as `worker_submit`) as an `Artifact` on the
1628    /// task's current-attempt tail, and returns `204 No Content`.
1629    #[tokio::test]
1630    async fn worker_artifact_stages_and_204s_for_valid_request() {
1631        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1632        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1633        let state = test_state(data_store, run_store);
1634        let task_id = StepId::new();
1635        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
1636
1637        let status = worker_artifact(
1638            State(state.clone()),
1639            bearer_headers(&handle),
1640            Query(ArtifactQuery {
1641                name: "summary".to_string(),
1642            }),
1643            axum::body::Bytes::from_static(b"hello artifact\n"),
1644        )
1645        .await
1646        .expect("worker_artifact");
1647        assert_eq!(status, StatusCode::NO_CONTENT);
1648
1649        let tail = state.engine.output_tail(&task_id, 1).await;
1650        assert_eq!(tail.len(), 1, "tail: {tail:?}");
1651        match &tail[0] {
1652            OutputEvent::Artifact { name, content } => {
1653                assert_eq!(name, "summary");
1654                match content {
1655                    ContentRef::Inline { value } => {
1656                        assert_eq!(value, &json!("hello artifact"));
1657                    }
1658                    other => panic!("expected Inline content, got {other:?}"),
1659                }
1660            }
1661            other => panic!("expected Artifact event, got {other:?}"),
1662        }
1663    }
1664
1665    /// `?name=` missing entirely → axum's `Query` extractor rejection
1666    /// (400), not a panic. `Query<ArtifactQuery>` is constructed directly
1667    /// in this test (mirroring the other handlers' unit style, which call
1668    /// the handler fn with an already-extracted `Query`) — an empty `name`
1669    /// is exercised separately below since that case is NOT caught by the
1670    /// extractor and must be checked in the handler body.
1671    #[tokio::test]
1672    async fn worker_artifact_rejects_blank_name() {
1673        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1674        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1675        let state = test_state(data_store, run_store);
1676        let task_id = StepId::new();
1677        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
1678
1679        let result = worker_artifact(
1680            State(state.clone()),
1681            bearer_headers(&handle),
1682            Query(ArtifactQuery {
1683                name: "   ".to_string(),
1684            }),
1685            axum::body::Bytes::from_static(b"x"),
1686        )
1687        .await;
1688        let err = match result {
1689            Ok(_) => panic!("expected 400 ApiError for blank name, got Ok"),
1690            Err(e) => e,
1691        };
1692        assert_eq!(err.into_response().status(), StatusCode::BAD_REQUEST);
1693
1694        // Nothing was staged.
1695        assert!(state.engine.output_tail(&task_id, 1).await.is_empty());
1696    }
1697
1698    /// Staging the same `name` twice within one attempt is last-write-wins
1699    /// on the folded value (`fold_final_and_parts` in `mlua_swarm::core::
1700    /// engine`) — this test only asserts the raw tail carries both events
1701    /// in order (the fold itself is covered by that crate's own unit
1702    /// tests); `Engine::stage_worker_artifact_trusted`'s doc.
1703    #[tokio::test]
1704    async fn worker_artifact_staging_same_name_twice_appends_both_events_in_order() {
1705        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1706        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1707        let state = test_state(data_store, run_store);
1708        let task_id = StepId::new();
1709        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
1710
1711        for body in [b"first".as_slice(), b"second".as_slice()] {
1712            worker_artifact(
1713                State(state.clone()),
1714                bearer_headers(&handle),
1715                Query(ArtifactQuery {
1716                    name: "a".to_string(),
1717                }),
1718                axum::body::Bytes::copy_from_slice(body),
1719            )
1720            .await
1721            .expect("worker_artifact");
1722        }
1723
1724        let tail = state.engine.output_tail(&task_id, 1).await;
1725        assert_eq!(tail.len(), 2, "tail: {tail:?}");
1726        let values: Vec<&str> = tail
1727            .iter()
1728            .map(|ev| match ev {
1729                OutputEvent::Artifact {
1730                    content: ContentRef::Inline { value },
1731                    ..
1732                } => value.as_str().expect("string value"),
1733                other => panic!("expected Artifact/Inline event, got {other:?}"),
1734            })
1735            .collect();
1736        assert_eq!(values, vec!["first", "second"]);
1737    }
1738
1739    // ──────────────────────────────────────────────────────────────────
1740    // GH #37 — terminal-run guard (`reject_if_run_terminal`)
1741    // ──────────────────────────────────────────────────────────────────
1742
1743    /// Links a seeded dispatch task to a Run the same way
1744    /// `AgentContextMiddleware` does at spawn time: an `agent_ctx` entry
1745    /// whose view carries the `run_id`.
1746    async fn link_task_to_run(state: &AppState, task_id: &StepId, attempt: u32, run_id: &RunId) {
1747        let tid = task_id.clone();
1748        let rid_str = run_id.to_string();
1749        state
1750            .engine
1751            .with_state("test.link_task_to_run", move |s| {
1752                let mut entry = mlua_swarm::core::state::AgentCtxEntry::default();
1753                entry.view.run_id = Some(rid_str);
1754                s.agent_ctx.insert((tid, attempt), entry);
1755            })
1756            .await
1757            .expect("link_task_to_run");
1758    }
1759
1760    /// GH #37: a submit / artifact addressed at a Run that already
1761    /// reached a terminal status must be rejected with `410 Gone` — the
1762    /// flow-eval driver for that Run is gone, so a silent `204` here
1763    /// would orphan the worker's output.
1764    #[tokio::test]
1765    async fn submit_and_artifact_against_terminal_run_return_410() {
1766        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1767        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1768        let state = test_state(data_store, run_store.clone());
1769        let task_id = StepId::new();
1770        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
1771
1772        let owner_task = TaskId::new();
1773        let run_id = RunId::new();
1774        let mut rec = run_record(&owner_task, &run_id, vec![]);
1775        rec.status = RunStatus::Failed;
1776        run_store.create(rec).await.expect("run create");
1777        link_task_to_run(&state, &task_id, 1, &run_id).await;
1778
1779        let err = worker_submit(
1780            State(state.clone()),
1781            bearer_headers(&handle),
1782            Query(SubmitQuery { ok: None }),
1783            axum::body::Bytes::from_static(b"LATE OUTPUT"),
1784        )
1785        .await
1786        .expect_err("a submit against a Failed run must be rejected");
1787        assert_eq!(err.status, StatusCode::GONE);
1788        assert!(
1789            err.message.contains(&run_id.to_string()),
1790            "the 410 must name the terminal run: {}",
1791            err.message
1792        );
1793
1794        let err = worker_artifact(
1795            State(state.clone()),
1796            bearer_headers(&handle),
1797            Query(ArtifactQuery {
1798                name: "part.md".to_string(),
1799            }),
1800            axum::body::Bytes::from_static(b"LATE PART"),
1801        )
1802        .await
1803        .expect_err("an artifact staged against a Failed run must be rejected");
1804        assert_eq!(err.status, StatusCode::GONE);
1805
1806        // The rejected values must not have reached the output tail.
1807        let tail = state.engine.output_tail(&task_id, 1).await;
1808        assert!(tail.is_empty(), "rejected submits must not land: {tail:?}");
1809    }
1810
1811    /// GH #37 fail-open contract: the guard must never turn a
1812    /// would-have-succeeded submit into a failure — no run linkage at
1813    /// all, an unknown Run, and a live (`Running`) Run all pass.
1814    #[tokio::test]
1815    async fn terminal_run_guard_is_fail_open() {
1816        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1817        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1818        let state = test_state(data_store, run_store.clone());
1819        let task_id = StepId::new();
1820        seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
1821
1822        // (a) No agent-ctx linkage at all (pre-run-tracking dispatch).
1823        reject_if_run_terminal(&state, &task_id, 1)
1824            .await
1825            .expect("no linkage must fail open");
1826
1827        // (b) Linked to a Run the store does not know.
1828        let unknown_run = RunId::new();
1829        link_task_to_run(&state, &task_id, 1, &unknown_run).await;
1830        reject_if_run_terminal(&state, &task_id, 1)
1831            .await
1832            .expect("unknown run must fail open");
1833
1834        // (c) Linked to a live Run.
1835        let owner_task = TaskId::new();
1836        let live_run = RunId::new();
1837        run_store
1838            .create(run_record(&owner_task, &live_run, vec![]))
1839            .await
1840            .expect("run create");
1841        link_task_to_run(&state, &task_id, 1, &live_run).await;
1842        reject_if_run_terminal(&state, &task_id, 1)
1843            .await
1844            .expect("a Running run must pass the guard");
1845    }
1846
1847    // ──────────────────────────────────────────────────────────────────
1848    // GH #32 — `POST /v1/worker/degradation`
1849    // ──────────────────────────────────────────────────────────────────
1850
1851    fn degradation_body(tool: &str, note: Option<&str>) -> DegradationBody {
1852        DegradationBody {
1853            tool: tool.to_string(),
1854            error: "boom".to_string(),
1855            fallback: "used cached value".to_string(),
1856            note: note.map(str::to_string),
1857        }
1858    }
1859
1860    /// [`link_task_to_run`] plus the `view.agent` name — production's
1861    /// `AgentContextMiddleware` sets both fields on the same `agent_ctx`
1862    /// entry; the shared GH #37 helper only needed `run_id`, so this
1863    /// sibling fills in `agent` too for tests that assert on the
1864    /// server-injected `step_ref`.
1865    async fn link_task_to_run_with_agent(
1866        state: &AppState,
1867        task_id: &StepId,
1868        attempt: u32,
1869        run_id: &RunId,
1870        agent: &str,
1871    ) {
1872        let tid = task_id.clone();
1873        let rid_str = run_id.to_string();
1874        let agent = agent.to_string();
1875        state
1876            .engine
1877            .with_state("test.link_task_to_run_with_agent", move |s| {
1878                let mut entry = mlua_swarm::core::state::AgentCtxEntry::default();
1879                entry.view.run_id = Some(rid_str);
1880                entry.view.agent = agent;
1881                s.agent_ctx.insert((tid, attempt), entry);
1882            })
1883            .await
1884            .expect("link_task_to_run_with_agent");
1885    }
1886
1887    /// A worker-reported degradation is persisted to the linked Run's
1888    /// `degradations` with the server-injected `step_ref` / `attempt` /
1889    /// `at` fields filled in — the client body never supplies any of the
1890    /// three.
1891    #[tokio::test]
1892    async fn worker_degradation_persists_entry_when_run_tracked() {
1893        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1894        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1895        let state = test_state(data_store, run_store.clone());
1896        let task_id = StepId::new();
1897        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
1898
1899        let owner_task = TaskId::new();
1900        let run_id = RunId::new();
1901        run_store
1902            .create(run_record(&owner_task, &run_id, vec![]))
1903            .await
1904            .expect("run create");
1905        link_task_to_run_with_agent(&state, &task_id, 1, &run_id, "planner").await;
1906
1907        let status = worker_degradation(
1908            State(state.clone()),
1909            bearer_headers(&handle),
1910            Json(degradation_body("web_search", Some("rate limited"))),
1911        )
1912        .await
1913        .expect("worker_degradation");
1914        assert_eq!(status, StatusCode::NO_CONTENT);
1915
1916        let rec = run_store.get(&run_id).await.expect("run get");
1917        assert_eq!(
1918            rec.degradations.len(),
1919            1,
1920            "degradations: {:?}",
1921            rec.degradations
1922        );
1923        let entry = &rec.degradations[0];
1924        assert_eq!(entry.tool, "web_search");
1925        assert_eq!(entry.error, "boom");
1926        assert_eq!(entry.fallback, "used cached value");
1927        assert_eq!(entry.note.as_deref(), Some("rate limited"));
1928        assert_eq!(entry.step_ref.as_deref(), Some("planner"));
1929        assert_eq!(entry.attempt, Some(1));
1930        assert!(entry.at > 0, "at must be a real timestamp: {}", entry.at);
1931    }
1932
1933    /// Two entries POSTed in sequence are appended in order.
1934    #[tokio::test]
1935    async fn worker_degradation_appends_in_order() {
1936        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1937        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1938        let state = test_state(data_store, run_store.clone());
1939        let task_id = StepId::new();
1940        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
1941
1942        let owner_task = TaskId::new();
1943        let run_id = RunId::new();
1944        run_store
1945            .create(run_record(&owner_task, &run_id, vec![]))
1946            .await
1947            .expect("run create");
1948        link_task_to_run(&state, &task_id, 1, &run_id).await;
1949
1950        for tool in ["first_tool", "second_tool"] {
1951            worker_degradation(
1952                State(state.clone()),
1953                bearer_headers(&handle),
1954                Json(degradation_body(tool, None)),
1955            )
1956            .await
1957            .expect("worker_degradation");
1958        }
1959
1960        let rec = run_store.get(&run_id).await.expect("run get");
1961        let tools: Vec<&str> = rec.degradations.iter().map(|e| e.tool.as_str()).collect();
1962        assert_eq!(tools, vec!["first_tool", "second_tool"]);
1963    }
1964
1965    /// A task whose `agent_ctx` carries no Run linkage (pre-run-tracking
1966    /// dispatch) silently 204s — nothing to append to, and this must not
1967    /// surface as a client error.
1968    #[tokio::test]
1969    async fn worker_degradation_silent_ok_when_no_run_tracked() {
1970        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1971        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1972        let state = test_state(data_store, run_store);
1973        let task_id = StepId::new();
1974        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
1975
1976        let status = worker_degradation(
1977            State(state.clone()),
1978            bearer_headers(&handle),
1979            Json(degradation_body("some_tool", None)),
1980        )
1981        .await
1982        .expect("worker_degradation must not error on missing run linkage");
1983        assert_eq!(status, StatusCode::NO_CONTENT);
1984    }
1985
1986    /// GH #37 terminal-run guard applies to the degradation channel too — a
1987    /// dead Run must not accumulate signals.
1988    #[tokio::test]
1989    async fn worker_degradation_rejects_terminal_run() {
1990        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
1991        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
1992        let state = test_state(data_store, run_store.clone());
1993        let task_id = StepId::new();
1994        let handle = seed_task_with_handle(&state, &task_id, "planner", 1, None).await;
1995
1996        let owner_task = TaskId::new();
1997        let run_id = RunId::new();
1998        let mut rec = run_record(&owner_task, &run_id, vec![]);
1999        rec.status = RunStatus::Done;
2000        run_store.create(rec).await.expect("run create");
2001        link_task_to_run(&state, &task_id, 1, &run_id).await;
2002
2003        let err = worker_degradation(
2004            State(state.clone()),
2005            bearer_headers(&handle),
2006            Json(degradation_body("some_tool", None)),
2007        )
2008        .await
2009        .expect_err("a degradation against a Done run must be rejected");
2010        assert_eq!(err.status, StatusCode::GONE);
2011
2012        let rec = run_store.get(&run_id).await.expect("run get");
2013        assert!(
2014            rec.degradations.is_empty(),
2015            "rejected degradation must not land: {:?}",
2016            rec.degradations
2017        );
2018    }
2019}