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