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