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//!
31//! ## Bearer authentication
32//!
33//! The Bearer value is the string produced by `CapToken::encode()` (= URL-safe
34//! base64 of serde_json). The server decodes it with `CapToken::decode` and then,
35//! inside the engine, verifies HMAC sig + role × verb gate + TTL via
36//! `verify_token_for_task` (= self-contained capability token; no server-side
37//! store lookup required).
38//!
39//! Tokens are minted during the "2) mint outside the lock" phase of
40//! `engine.dispatch_attempt` (`Role::Worker`, 600s TTL, `scopes=["*"]`).
41//! The verb gate covers `FetchPrompt` / `EmitOutput` / `PostResult` — the worker
42//! leaf capability set (`crate::types::WORKER_LEAF_VERBS`).
43
44use axum::{
45    extract::{Query, State},
46    http::{header::AUTHORIZATION, HeaderMap, StatusCode},
47    Json,
48};
49use mlua_swarm::core::agent_context::StepPointer;
50use mlua_swarm::core::step_naming::StepNaming;
51use mlua_swarm::{CapToken, ContentRef, OutputEvent, RunId, StepId, WorkerPayload};
52use mlua_swarm_schema::ContextPolicy;
53use serde::Deserialize;
54use serde_json::Value;
55
56use crate::projection::McpQueryAdapter;
57use crate::{ApiError, AppState};
58
59/// Query params for `GET /v1/worker/prompt`.
60#[derive(Debug, Deserialize)]
61pub struct PromptQuery {
62    /// Task the fetched prompt belongs to; cross-checked against the Bearer
63    /// handle/token. Typed [`StepId`] since issue #14 — the wire shape stays
64    /// a plain string; a bad prefix is rejected at deserialize.
65    pub task_id: StepId,
66}
67
68/// `GET /v1/worker/prompt?task_id=<tid>`. Bearer = encoded `CapToken` or short `wh-` handle.
69/// Thin HTTP wrapper over `engine.fetch_worker_payload` / `fetch_worker_payload_trusted`.
70/// Short-handle path (recommended for SubAgents): handle → task_id
71/// cross-check → trusted fetch.
72/// Full-`CapToken` path: token decode → verify → fetch.
73pub async fn worker_prompt(
74    State(state): State<AppState>,
75    headers: HeaderMap,
76    Query(q): Query<PromptQuery>,
77) -> Result<Json<WorkerPayload>, ApiError> {
78    let task_id = q.task_id;
79    let bearer = extract_bearer_raw(&headers)?;
80    let mut payload = if let Some(handle) = parse_worker_handle(&bearer) {
81        // Short-handle path: verify handle → task_id (security: confirm the handle is bound to this task).
82        let resolved = state
83            .engine
84            .task_id_from_handle(handle)
85            .await
86            .map_err(|e| ApiError::engine(format!("task_id_from_handle: {e}")))?;
87        if resolved != task_id {
88            return Err(ApiError::bad_request(format!(
89                "handle {handle} is bound to task {resolved}, not {task_id}"
90            )));
91        }
92        state
93            .engine
94            .fetch_worker_payload_trusted(&task_id)
95            .await
96            .map_err(|e| ApiError::engine(format!("fetch_worker_payload_trusted: {e}")))?
97    } else {
98        // Full CapToken path (the alternate Bearer form).
99        let token = CapToken::decode(bearer.trim())
100            .map_err(|e| ApiError::bad_request(format!("invalid token: {e}")))?;
101        state
102            .engine
103            .fetch_worker_payload(&token, &task_id)
104            .await
105            .map_err(|e| ApiError::engine(format!("fetch_worker_payload: {e}")))?
106    };
107    assemble_step_pointers(&state, &mut payload).await;
108    Ok(Json(payload))
109}
110
111/// Assembles `payload.context.steps` — the `ContextPolicy.steps`-filtered
112/// pointer list to preceding steps' OUTPUT (`projection-adapter` ST5's
113/// Worker axis; see `mlua_swarm::core::agent_context`'s module doc).
114/// Resolved fresh on every fetch (not baked at spawn time), so a step
115/// submitted after this agent spawned — but before it fetches its prompt
116/// — is still visible.
117///
118/// GH #23 subtask-3: `resolved_steps` (from
119/// `McpQueryAdapter::list_steps_by_run_id`) always reports the CANONICAL
120/// name (see `crate::projection`'s module doc), so both the self-exclusion
121/// check and the `ContextPolicy` match are done against canonical names —
122/// `payload.agent` (the raw `Step.ref` this fetching agent was dispatched
123/// under) is canonicalized via `Engine::step_naming_for(&payload.task_id)`
124/// (the FETCHING agent's own dispatch id — the same `StepNaming` `Arc`
125/// every step of this Blueprint launch shares, see [`StepNaming`]'s module
126/// doc), and `policy.allows_step` itself is left untouched (schema crate
127/// stays name-agnostic) — [`allows_step_canonical`] is the caller-side seam
128/// that resolves each policy-declared name through the table before
129/// comparing.
130///
131/// No-op (`context.steps` stays empty) when: the payload carries no
132/// `context` at all; the context has no `run_id` (a spawn that never
133/// threaded one through — pre-run-tracking callers, or a spawner stack
134/// without the Run-tracking layer); or the addressed Run cannot be
135/// resolved. All three are fail-open, matching this crate's other
136/// best-effort projection hooks (a missing pointer list must never turn a
137/// would-have-succeeded fetch into a failure).
138async fn assemble_step_pointers(state: &AppState, payload: &mut WorkerPayload) {
139    let Some(context) = payload.context.as_mut() else {
140        return;
141    };
142    let Some(run_id_str) = context.run_id.clone() else {
143        return;
144    };
145    let Ok(run_id) = RunId::parse(run_id_str) else {
146        return;
147    };
148
149    let adapter = McpQueryAdapter::new(
150        state.data_store.clone(),
151        state.run_store.clone(),
152        state.engine.clone(),
153    );
154    let Ok((run, resolved_steps)) = adapter.list_steps_by_run_id(&run_id).await else {
155        return;
156    };
157
158    let naming = state.engine.step_naming_for(&payload.task_id).await;
159    let policy = state
160        .engine
161        .context_policy_for(&payload.task_id, payload.attempt)
162        .await;
163    let self_canonical = naming
164        .as_deref()
165        .and_then(|n| n.canonical_of_producer(&payload.agent))
166        .map(str::to_string)
167        .unwrap_or_else(|| payload.agent.clone());
168
169    let mut pointers = Vec::new();
170    for step in &resolved_steps {
171        if step.name == self_canonical
172            || !allows_step_canonical(&policy, naming.as_deref(), &step.name)
173        {
174            continue;
175        }
176        if let Some((size_bytes, file_path, content_url, sha256)) =
177            crate::projection::resolve_step_pointer_fields(state, &run, step).await
178        {
179            pointers.push(StepPointer {
180                name: step.name.clone(),
181                size_bytes,
182                file_path,
183                content_url,
184                sha256,
185            });
186        }
187    }
188    context.steps = pointers;
189}
190
191/// GH #23 subtask-3: caller-side canonical/alias expansion for
192/// `ContextPolicy.allows_step` — same precedence as
193/// `ContextPolicy::allows_step` itself (`steps_exclude` wins; `steps:
194/// None` = pass-all, `Some(list)` = named-only), but each
195/// policy-declared name is resolved through the Blueprint's `StepNaming`
196/// table before comparison, so a Blueprint author's `steps: [...]` entry
197/// naming either the canonical projection name OR any alias (`Step.ref` /
198/// the `out` ctx-path's top-level segment) matches the same step.
199/// `ContextPolicy::allows_step` (schema crate) is untouched — this is the
200/// GH #23 seam, kept out of the name-agnostic schema type. `naming: None`
201/// degrades to a literal string comparison, byte-identical to
202/// `ContextPolicy::allows_step` itself (defensive-only fallback, matching
203/// `crate::projection::McpQueryAdapter::step_naming_for_run`'s own
204/// contract).
205fn allows_step_canonical(
206    policy: &ContextPolicy,
207    naming: Option<&StepNaming>,
208    canonical_name: &str,
209) -> bool {
210    let resolves_to = |raw: &str| -> bool {
211        match naming {
212            Some(n) => n
213                .resolve(raw)
214                .map(|c| c == canonical_name)
215                .unwrap_or(raw == canonical_name),
216            None => raw == canonical_name,
217        }
218    };
219    if policy
220        .steps_exclude
221        .iter()
222        .any(|excluded| resolves_to(excluded))
223    {
224        return false;
225    }
226    match &policy.steps {
227        None => true,
228        Some(list) => list.iter().any(|included| resolves_to(included)),
229    }
230}
231
232/// Body for `POST /v1/worker/result`.
233#[derive(Debug, Deserialize)]
234pub struct WorkerResultReq {
235    /// Task this result belongs to (looked up together with the Bearer
236    /// token). Typed [`StepId`] since issue #14 (see [`PromptQuery`]).
237    pub task_id: StepId,
238    /// `WorkerResult.value` (= the value returned by the Operator: LLM inference result or tool execution result).
239    pub value: Value,
240    /// `WorkerResult.ok`. `false` makes the dispatch path decide Blocked
241    /// (= same semantics as `OutputEvent::Final { ok: false, .. }` from a
242    /// `SpawnerAdapter`). Defaults to `true`.
243    #[serde(default = "default_ok_true")]
244    pub ok: bool,
245    /// Optional explicit attempt. Normally omitted (= the server looks up `task.attempt`).
246    /// A carry for race-condition tests that need to write to a fixed attempt.
247    #[serde(default)]
248    pub attempt: Option<u32>,
249}
250
251fn default_ok_true() -> bool {
252    true
253}
254
255/// `POST /v1/worker/result`. Bearer = encoded `CapToken`.
256/// Fires `engine.submit_output(Final)` + `engine.post_result`.
257pub async fn worker_result(
258    State(state): State<AppState>,
259    headers: HeaderMap,
260    Json(req): Json<WorkerResultReq>,
261) -> Result<StatusCode, ApiError> {
262    let token = decode_worker_bearer(&headers)?;
263    let task_id = req.task_id.clone();
264
265    // Use body-explicit attempt if provided; otherwise the current task.attempt.
266    let attempt = match req.attempt {
267        Some(n) => n,
268        None => state
269            .engine
270            .task_attempt(&task_id)
271            .await
272            .map_err(|e| ApiError::engine(format!("task_attempt: {e}")))?,
273    };
274
275    let event = OutputEvent::Final {
276        content: ContentRef::Inline {
277            value: req.value.clone(),
278        },
279        ok: req.ok,
280    };
281    state
282        .engine
283        .submit_output(&token, &task_id, attempt, event)
284        .await
285        .map_err(|e| ApiError::engine(format!("submit_output: {e}")))?;
286    state
287        .engine
288        .post_result(&token, &task_id, req.value)
289        .await
290        .map_err(|e| ApiError::engine(format!("post_result: {e}")))?;
291    Ok(StatusCode::NO_CONTENT)
292}
293
294/// `POST /v1/worker/submit`. Bearer = encoded `CapToken`. Body = raw text/octet.
295///
296/// Simplification-axis endpoint for SubAgents. Removes the JSON construction,
297/// duplicated `task_id`, and JSON-escape burden of `/v1/worker/result` — the
298/// worker completes a POST with just token + raw body. Origin: the recent clean-up
299/// of the SubAgent contract drift (fewer IDs to pass around, multi-line escape
300/// accidents eliminated).
301///
302/// Behavior:
303/// - `task_id` is auto-looked-up server-side from the token (already bound to the `CapToken`).
304/// - Body raw bytes go as-is into `Value::String` for `submit_output` + `post_result`.
305/// - `ok=true` fixed (= the submit endpoint is success-path only). For the error
306///   path, use `/v1/worker/result` with an explicit `ok=false`.
307#[derive(Debug, Deserialize, Default)]
308pub struct SubmitQuery {
309    /// Optional. `ok=false` signals failure (= `DispatchOutcome::Blocked`, caught
310    /// by the flow.ir Try path). Unspecified (`None`) is treated as `ok=true`
311    /// (= normal success).
312    #[serde(default)]
313    pub ok: Option<bool>,
314}
315
316/// `POST /v1/worker/submit`. Simplified counterpart of [`worker_result`]:
317/// the caller sends only the raw result body, `task_id` is resolved
318/// server-side from the Bearer handle/token, and `ok` defaults to `true`
319/// unless overridden via [`SubmitQuery::ok`]. See the module doc for the
320/// short-handle vs full-`CapToken` Bearer forms.
321pub async fn worker_submit(
322    State(state): State<AppState>,
323    headers: HeaderMap,
324    Query(q): Query<SubmitQuery>,
325    body: axum::body::Bytes,
326) -> Result<StatusCode, ApiError> {
327    // Bearer accepts either (a) `wh-<8 hex>` short handle (recommended for
328    // SubAgents) or (b) base64-wrapped CapToken JSON (the full-token form).
329    let bearer = extract_bearer_raw(&headers)?;
330    let task_id = if let Some(handle) = parse_worker_handle(&bearer) {
331        state
332            .engine
333            .task_id_from_handle(handle)
334            .await
335            .map_err(|e| ApiError::engine(format!("task_id_from_handle: {e}")))?
336    } else {
337        let token = CapToken::decode(bearer.trim())
338            .map_err(|e| ApiError::bad_request(format!("invalid token: {e}")))?;
339        state
340            .engine
341            .task_id_from_token(&token)
342            .await
343            .map_err(|e| ApiError::engine(format!("task_id_from_token: {e}")))?
344    };
345    let attempt = state
346        .engine
347        .task_attempt(&task_id)
348        .await
349        .map_err(|e| ApiError::engine(format!("task_attempt: {e}")))?;
350    // Strip trailing whitespace (newlines, etc.) so flow.ir `Eq` string matches
351    // don't drift on `"BLOCKED\n" == "BLOCKED"` false results. Origin: the recent clean-up
352    // verdict_loop smoke — sharp-edge removal. Internal `\n` inside the raw bytes
353    // is preserved (= only trailing).
354    let body_str = String::from_utf8_lossy(&body).trim_end().to_string();
355    let value = Value::String(body_str);
356
357    // The handle path = trusted internal API (= the server-minted handle is validated
358    // by the earlier lookup); the full-token path = existing verify-by-token API.
359    // Both are reflected identically into final + last_result.
360    // `?ok=false` in the query signals failure (= `DispatchOutcome::Blocked`,
361    // the flow.ir Try catch path).
362    let ok = q.ok.unwrap_or(true);
363    state
364        .engine
365        .submit_worker_result_trusted(&task_id, attempt, value, ok)
366        .await
367        .map_err(|e| ApiError::engine(format!("submit_worker_result_trusted: {e}")))?;
368    Ok(StatusCode::NO_CONTENT)
369}
370
371/// Extracts the raw string from the `Authorization` header (= strips the `Bearer `
372/// prefix). To let `worker_submit` accept both short handles and full tokens, we
373/// fetch the raw value before any decode.
374fn extract_bearer_raw(headers: &HeaderMap) -> Result<String, ApiError> {
375    let v = headers
376        .get(AUTHORIZATION)
377        .ok_or_else(|| ApiError::bad_request("missing Authorization header".into()))?
378        .to_str()
379        .map_err(|_| ApiError::bad_request("invalid Authorization header encoding".into()))?;
380    let s = v
381        .strip_prefix("Bearer ")
382        .ok_or_else(|| ApiError::bad_request("Authorization must be 'Bearer <token>'".into()))?
383        .trim();
384    if s.is_empty() {
385        return Err(ApiError::bad_request("Bearer is empty".into()));
386    }
387    Ok(s.to_string())
388}
389
390/// Decides whether the Bearer is a short handle (`wh-XXXXXXXX`). Returns
391/// `Some(handle)` on a match, `None` otherwise (= caller proceeds to try decoding
392/// as full `CapToken` JSON).
393fn parse_worker_handle(s: &str) -> Option<&str> {
394    let s = s.trim();
395    if s.starts_with("wh-")
396        && s.len() >= 5
397        && s.len() <= 64
398        && s[3..].chars().all(|c| c.is_ascii_alphanumeric())
399    {
400        Some(s)
401    } else {
402        None
403    }
404}
405
406/// Decodes an encoded `CapToken` from `Authorization: Bearer <encoded CapToken>`.
407/// Kept separate from `extract_bearer` (sid-only) — kept as a distinct fn so
408/// that sid strings and encoded tokens are not confused, distinguishing them by type.
409fn decode_worker_bearer(headers: &HeaderMap) -> Result<CapToken, ApiError> {
410    let v = headers
411        .get(AUTHORIZATION)
412        .ok_or_else(|| ApiError::bad_request("missing Authorization header".into()))?
413        .to_str()
414        .map_err(|_| ApiError::bad_request("invalid Authorization header encoding".into()))?;
415    let encoded = v
416        .strip_prefix("Bearer ")
417        .ok_or_else(|| ApiError::bad_request("Authorization must be 'Bearer <token>'".into()))?
418        .trim();
419    if encoded.is_empty() {
420        return Err(ApiError::bad_request("Bearer token is empty".into()));
421    }
422    CapToken::decode(encoded).map_err(|e| ApiError::bad_request(format!("invalid token: {e}")))
423}
424
425// ──────────────────────────────────────────────────────────────────────────
426// UT — `assemble_step_pointers` (`projection-adapter` ST5 Worker axis)
427// ──────────────────────────────────────────────────────────────────────────
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432    use mlua_swarm::core::agent_context::AgentContextView;
433    use mlua_swarm::core::config::EngineCfg;
434    use mlua_swarm::core::engine::Engine;
435    use mlua_swarm::store::output::{InMemoryOutputStore, OutputStore};
436    use mlua_swarm::store::run::{InMemoryRunStore, RunRecord, RunStatus, RunStore, StepEntry};
437    use mlua_swarm::store::task::InMemoryTaskStore;
438    use mlua_swarm::{RunId, StepId, TaskId};
439    use serde_json::json;
440    use std::collections::HashMap;
441    use std::sync::Arc;
442    use tokio::sync::Mutex;
443
444    /// Per-module test-helper convention (this crate's established
445    /// pattern — see e.g. `projection::tests::test_state`): a minimal
446    /// `AppState` wired with the caller-supplied `data_store` / `run_store`
447    /// so a test can seed both directly rather than driving a real
448    /// dispatch through them.
449    fn test_state(data_store: Arc<dyn OutputStore>, run_store: Arc<dyn RunStore>) -> AppState {
450        let engine = Engine::new(EngineCfg::default());
451        let compiler = mlua_swarm::Compiler::new(crate::default_registry());
452        let launch = Arc::new(mlua_swarm::TaskLaunchService::new(engine.clone(), compiler));
453        AppState {
454            engine,
455            sessions: Arc::new(Mutex::new(crate::SessionStore::default())),
456            task_app: Arc::new(mlua_swarm::TaskApplication::new_inline_only(launch)),
457            ws_operator_factory: None,
458            data_store,
459            operator_sessions: Arc::new(Mutex::new(HashMap::new())),
460            roles_to_sid: Arc::new(Mutex::new(HashMap::new())),
461            task_store: Arc::new(InMemoryTaskStore::new()),
462            run_store,
463            base_url: None,
464        }
465    }
466
467    async fn append_final(
468        data_store: &Arc<dyn OutputStore>,
469        task_id: &str,
470        producer: &str,
471        value: Value,
472    ) {
473        data_store
474            .append(
475                task_id,
476                1,
477                producer,
478                OutputEvent::Final {
479                    content: ContentRef::Inline { value },
480                    ok: true,
481                },
482                vec![],
483            )
484            .await
485            .expect("append final");
486    }
487
488    fn step_entry(step_id: &StepId, step_ref: &str) -> StepEntry {
489        StepEntry {
490            step_id: step_id.clone(),
491            step_ref: Some(step_ref.to_string()),
492            status: Some("passed".to_string()),
493            at: 0,
494        }
495    }
496
497    fn run_record(task_id: &TaskId, run_id: &RunId, step_entries: Vec<StepEntry>) -> RunRecord {
498        RunRecord {
499            id: run_id.clone(),
500            task_id: task_id.clone(),
501            status: RunStatus::Running,
502            step_entries,
503            operator_sid: None,
504            result_ref: None,
505            created_at: 0,
506            updated_at: 0,
507        }
508    }
509
510    fn consumer_payload(consumer_step_id: &StepId, run_id: &RunId) -> WorkerPayload {
511        WorkerPayload {
512            task_id: consumer_step_id.clone(),
513            attempt: 1,
514            agent: "consumer".to_string(),
515            system: None,
516            prompt: String::new(),
517            context: Some(AgentContextView {
518                task_id: consumer_step_id.to_string(),
519                agent: "consumer".to_string(),
520                attempt: 1,
521                run_id: Some(run_id.to_string()),
522                ..Default::default()
523            }),
524        }
525    }
526
527    /// Test 1: `ContextPolicy.steps` unspecified (no policy seeded at all
528    /// — `Engine::context_policy_for`'s "no entry" default is `None` /
529    /// pass-all) → the fetch payload carries every submitted step's
530    /// `StepPointer`.
531    #[tokio::test]
532    async fn context_policy_unspecified_yields_every_submitted_step() {
533        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
534        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
535        let task_id = TaskId::new();
536        let run_id = RunId::new();
537        let planner_id = StepId::new();
538        let coder_id = StepId::new();
539
540        append_final(
541            &data_store,
542            planner_id.as_str(),
543            "planner",
544            json!({"plan": "x"}),
545        )
546        .await;
547        append_final(
548            &data_store,
549            coder_id.as_str(),
550            "coder",
551            json!({"code": "y"}),
552        )
553        .await;
554        run_store
555            .create(run_record(
556                &task_id,
557                &run_id,
558                vec![
559                    step_entry(&planner_id, "planner"),
560                    step_entry(&coder_id, "coder"),
561                ],
562            ))
563            .await
564            .expect("create run");
565
566        let state = test_state(data_store, run_store);
567        let consumer_id = StepId::new();
568        let mut payload = consumer_payload(&consumer_id, &run_id);
569        assemble_step_pointers(&state, &mut payload).await;
570
571        let names: Vec<&str> = payload
572            .context
573            .as_ref()
574            .expect("context")
575            .steps
576            .iter()
577            .map(|p| p.name.as_str())
578            .collect();
579        assert!(names.contains(&"planner"), "names: {names:?}");
580        assert!(names.contains(&"coder"), "names: {names:?}");
581    }
582
583    /// Test 2: `steps: ["planner"]` → only `planner`'s pointer is present.
584    #[tokio::test]
585    async fn context_policy_steps_include_list_filters_to_named_steps() {
586        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
587        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
588        let task_id = TaskId::new();
589        let run_id = RunId::new();
590        let planner_id = StepId::new();
591        let coder_id = StepId::new();
592        append_final(&data_store, planner_id.as_str(), "planner", json!("x")).await;
593        append_final(&data_store, coder_id.as_str(), "coder", json!("y")).await;
594        run_store
595            .create(run_record(
596                &task_id,
597                &run_id,
598                vec![
599                    step_entry(&planner_id, "planner"),
600                    step_entry(&coder_id, "coder"),
601                ],
602            ))
603            .await
604            .expect("create run");
605
606        let state = test_state(data_store, run_store);
607        let consumer_id = StepId::new();
608        state
609            .engine
610            .with_state("test.seed_policy", {
611                let consumer_id = consumer_id.clone();
612                move |s| {
613                    s.agent_ctx.insert(
614                        (consumer_id, 1),
615                        mlua_swarm::core::state::AgentCtxEntry {
616                            policy: mlua_swarm_schema::ContextPolicy {
617                                steps: Some(vec!["planner".to_string()]),
618                                ..Default::default()
619                            },
620                            ..Default::default()
621                        },
622                    );
623                }
624            })
625            .await
626            .expect("seed policy");
627
628        let mut payload = consumer_payload(&consumer_id, &run_id);
629        assemble_step_pointers(&state, &mut payload).await;
630
631        let names: Vec<&str> = payload
632            .context
633            .as_ref()
634            .expect("context")
635            .steps
636            .iter()
637            .map(|p| p.name.as_str())
638            .collect();
639        assert_eq!(names, vec!["planner"], "names: {names:?}");
640    }
641
642    /// Test 3: `steps: []` → the pointer list is empty.
643    #[tokio::test]
644    async fn context_policy_steps_empty_list_yields_no_pointers() {
645        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
646        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
647        let task_id = TaskId::new();
648        let run_id = RunId::new();
649        let planner_id = StepId::new();
650        append_final(&data_store, planner_id.as_str(), "planner", json!("x")).await;
651        run_store
652            .create(run_record(
653                &task_id,
654                &run_id,
655                vec![step_entry(&planner_id, "planner")],
656            ))
657            .await
658            .expect("create run");
659
660        let state = test_state(data_store, run_store);
661        let consumer_id = StepId::new();
662        state
663            .engine
664            .with_state("test.seed_policy", {
665                let consumer_id = consumer_id.clone();
666                move |s| {
667                    s.agent_ctx.insert(
668                        (consumer_id, 1),
669                        mlua_swarm::core::state::AgentCtxEntry {
670                            policy: mlua_swarm_schema::ContextPolicy {
671                                steps: Some(vec![]),
672                                ..Default::default()
673                            },
674                            ..Default::default()
675                        },
676                    );
677                }
678            })
679            .await
680            .expect("seed policy");
681
682        let mut payload = consumer_payload(&consumer_id, &run_id);
683        assemble_step_pointers(&state, &mut payload).await;
684
685        assert!(payload.context.expect("context").steps.is_empty());
686    }
687
688    /// Test 4: `steps_exclude` wins over `steps` for a name in both.
689    #[tokio::test]
690    async fn context_policy_steps_exclude_wins_over_steps() {
691        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
692        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
693        let task_id = TaskId::new();
694        let run_id = RunId::new();
695        let planner_id = StepId::new();
696        let coder_id = StepId::new();
697        append_final(&data_store, planner_id.as_str(), "planner", json!("x")).await;
698        append_final(&data_store, coder_id.as_str(), "coder", json!("y")).await;
699        run_store
700            .create(run_record(
701                &task_id,
702                &run_id,
703                vec![
704                    step_entry(&planner_id, "planner"),
705                    step_entry(&coder_id, "coder"),
706                ],
707            ))
708            .await
709            .expect("create run");
710
711        let state = test_state(data_store, run_store);
712        let consumer_id = StepId::new();
713        state
714            .engine
715            .with_state("test.seed_policy", {
716                let consumer_id = consumer_id.clone();
717                move |s| {
718                    s.agent_ctx.insert(
719                        (consumer_id, 1),
720                        mlua_swarm::core::state::AgentCtxEntry {
721                            policy: mlua_swarm_schema::ContextPolicy {
722                                steps: Some(vec!["planner".to_string(), "coder".to_string()]),
723                                steps_exclude: vec!["planner".to_string()],
724                                ..Default::default()
725                            },
726                            ..Default::default()
727                        },
728                    );
729                }
730            })
731            .await
732            .expect("seed policy");
733
734        let mut payload = consumer_payload(&consumer_id, &run_id);
735        assemble_step_pointers(&state, &mut payload).await;
736
737        let names: Vec<&str> = payload
738            .context
739            .as_ref()
740            .expect("context")
741            .steps
742            .iter()
743            .map(|p| p.name.as_str())
744            .collect();
745        assert_eq!(names, vec!["coder"], "names: {names:?}");
746    }
747
748    /// Test 5 (in-flight window, subtask-4-style invariant): the Run has
749    /// NOT finalized (`result_ref: None`, mirroring a Run still `Running`)
750    /// yet the fetch payload still carries a `StepPointer` for a step
751    /// already visible through the Data-plane store — the same mechanism
752    /// `crates/mlua-swarm-server/src/projection.rs`'s
753    /// `steps_list_returns_in_flight_step_output_before_run_completes`
754    /// proves end-to-end through a real gated 2-step dispatch; this test
755    /// isolates the same invariant at the `assemble_step_pointers` level.
756    #[tokio::test]
757    async fn in_flight_step_output_is_visible_before_run_finalizes() {
758        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
759        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
760        let task_id = TaskId::new();
761        let run_id = RunId::new();
762        let step1_id = StepId::new();
763        append_final(
764            &data_store,
765            step1_id.as_str(),
766            "step1",
767            json!({"step1_out": "hi"}),
768        )
769        .await;
770        let mut run = run_record(&task_id, &run_id, vec![step_entry(&step1_id, "step1")]);
771        run.status = RunStatus::Running;
772        run.result_ref = None; // the in-flight window: not yet finalized.
773        run_store.create(run).await.expect("create run");
774
775        let state = test_state(data_store, run_store);
776        let consumer_id = StepId::new();
777        let mut payload = consumer_payload(&consumer_id, &run_id);
778        assemble_step_pointers(&state, &mut payload).await;
779
780        let steps = &payload.context.expect("context").steps;
781        assert_eq!(steps.len(), 1);
782        assert_eq!(steps[0].name, "step1");
783    }
784
785    /// Test 6: the fetching agent's own name is always excluded, even if
786    /// (e.g. a loop re-dispatching the same agent) it also appears in
787    /// `run.step_entries` with a resolvable Data-plane record.
788    #[tokio::test]
789    async fn self_agent_name_is_always_excluded() {
790        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
791        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
792        let task_id = TaskId::new();
793        let run_id = RunId::new();
794        let planner_id = StepId::new();
795        let consumer_prior_id = StepId::new();
796        append_final(&data_store, planner_id.as_str(), "planner", json!("x")).await;
797        append_final(
798            &data_store,
799            consumer_prior_id.as_str(),
800            "consumer",
801            json!("self"),
802        )
803        .await;
804        run_store
805            .create(run_record(
806                &task_id,
807                &run_id,
808                vec![
809                    step_entry(&planner_id, "planner"),
810                    step_entry(&consumer_prior_id, "consumer"),
811                ],
812            ))
813            .await
814            .expect("create run");
815
816        let state = test_state(data_store, run_store);
817        let consumer_id = StepId::new();
818        let mut payload = consumer_payload(&consumer_id, &run_id);
819        assemble_step_pointers(&state, &mut payload).await;
820
821        let names: Vec<&str> = payload
822            .context
823            .as_ref()
824            .expect("context")
825            .steps
826            .iter()
827            .map(|p| p.name.as_str())
828            .collect();
829        assert!(!names.contains(&"consumer"), "names: {names:?}");
830        assert!(names.contains(&"planner"), "names: {names:?}");
831    }
832
833    /// Test 7 (pointer-only invariant): a `StepPointer`'s serialized JSON
834    /// carries no preview / content-bytes field — only `name` /
835    /// `size_bytes` / `file_path?` / `content_url` / `sha256`.
836    #[tokio::test]
837    async fn step_pointer_serializes_with_no_preview_or_content_bytes() {
838        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
839        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
840        let task_id = TaskId::new();
841        let run_id = RunId::new();
842        let planner_id = StepId::new();
843        append_final(
844            &data_store,
845            planner_id.as_str(),
846            "planner",
847            json!({"plan": "do the thing, at length".repeat(50)}),
848        )
849        .await;
850        run_store
851            .create(run_record(
852                &task_id,
853                &run_id,
854                vec![step_entry(&planner_id, "planner")],
855            ))
856            .await
857            .expect("create run");
858
859        let state = test_state(data_store, run_store);
860        let consumer_id = StepId::new();
861        let mut payload = consumer_payload(&consumer_id, &run_id);
862        assemble_step_pointers(&state, &mut payload).await;
863
864        let steps = &payload.context.expect("context").steps;
865        assert_eq!(steps.len(), 1);
866        let json_value = serde_json::to_value(&steps[0]).expect("serialize StepPointer");
867        let obj = json_value.as_object().expect("object");
868        for forbidden in ["preview", "content", "value", "bytes"] {
869            assert!(
870                !obj.contains_key(forbidden),
871                "StepPointer must not carry a {forbidden:?} field: {obj:?}"
872            );
873        }
874        assert!(obj.contains_key("name"));
875        assert!(obj.contains_key("size_bytes"));
876        assert!(obj.contains_key("content_url"));
877        assert!(obj.contains_key("sha256"));
878    }
879
880    /// A single-step Blueprint whose `planner` agent declares
881    /// `AgentMeta.projection_name = "plan-out"` — the `StepNaming` fixture
882    /// for [`declared_projection_name_pointer_name_is_canonical_and_policy_matches_it`],
883    /// mirroring `crate::projection::tests`' own
884    /// `declared_projection_name_blueprint` helper (duplicated here rather
885    /// than shared — this crate's established per-module test-helper
886    /// convention).
887    fn declared_name_bp() -> mlua_swarm::blueprint::Blueprint {
888        use mlua_flow_ir::{Expr, Node};
889        use mlua_swarm::blueprint::{
890            current_schema_version, AgentDef, AgentKind, AgentMeta, Blueprint, BlueprintMetadata,
891            CompilerHints, CompilerStrategy,
892        };
893        Blueprint {
894            schema_version: current_schema_version(),
895            id: "worker-test-declared-name-bp".into(),
896            flow: Node::Step {
897                ref_: "planner".to_string(),
898                in_: Expr::Path {
899                    at: "$.in".to_string(),
900                },
901                out: Expr::Path {
902                    at: "$.plan".to_string(),
903                },
904            },
905            agents: vec![AgentDef {
906                name: "planner".to_string(),
907                kind: AgentKind::RustFn,
908                spec: json!({"fn_id": "planner"}),
909                profile: None,
910                meta: Some(AgentMeta {
911                    projection_name: Some("plan-out".to_string()),
912                    ..Default::default()
913                }),
914            }],
915            operators: vec![],
916            metas: vec![],
917            hints: CompilerHints::default(),
918            strategy: CompilerStrategy::default(),
919            metadata: BlueprintMetadata::default(),
920            spawner_hints: Default::default(),
921            default_agent_kind: AgentKind::Operator,
922            default_operator_kind: None,
923            default_init_ctx: None,
924            default_agent_ctx: None,
925            default_context_policy: None,
926            projection_placement: None,
927        }
928    }
929
930    /// Test 8 (GH #23 subtask-3, declared-name E2E — Worker axis half): a
931    /// declared `projection_name` makes `StepPointer.name` the CANONICAL
932    /// name (not the raw `Step.ref` the Data-plane / `step_entries` still
933    /// index by), and `ContextPolicy.steps` naming the canonical name
934    /// matches it.
935    #[tokio::test]
936    async fn declared_projection_name_pointer_name_is_canonical_and_policy_matches_it() {
937        let data_store: Arc<dyn OutputStore> = Arc::new(InMemoryOutputStore::new());
938        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
939        let task_id = TaskId::new();
940        let run_id = RunId::new();
941        let planner_id = StepId::new();
942
943        // The Data-plane store is keyed by the CANONICAL name — GH #23
944        // subtask-2's sink already writes it that way.
945        append_final(
946            &data_store,
947            planner_id.as_str(),
948            "plan-out",
949            json!({"plan": "x"}),
950        )
951        .await;
952        run_store
953            .create(run_record(
954                &task_id,
955                &run_id,
956                vec![step_entry(&planner_id, "planner")],
957            ))
958            .await
959            .expect("create run");
960
961        let state = test_state(data_store, run_store);
962
963        // Seed the `StepNaming` table the way `Compiler::compile` +
964        // `EngineDispatcher::dispatch` would have — the same `Arc` stashed
965        // under every dispatched step's own id, including the FETCHING
966        // agent's (`consumer_id`), which `assemble_step_pointers` looks up
967        // via `Engine::step_naming_for(&payload.task_id)`.
968        let (naming, _warnings) =
969            mlua_swarm::core::step_naming::StepNaming::from_blueprint(&declared_name_bp())
970                .expect("no collision");
971        let naming = Arc::new(naming);
972        let consumer_id = StepId::new();
973        state
974            .engine
975            .with_state("test.seed_step_naming", {
976                let naming = naming.clone();
977                let planner_id = planner_id.clone();
978                let consumer_id = consumer_id.clone();
979                move |s| {
980                    s.step_namings.insert(planner_id, naming.clone());
981                    s.step_namings.insert(consumer_id, naming);
982                }
983            })
984            .await
985            .expect("seed step naming");
986        state
987            .engine
988            .with_state("test.seed_policy", {
989                let consumer_id = consumer_id.clone();
990                move |s| {
991                    s.agent_ctx.insert(
992                        (consumer_id, 1),
993                        mlua_swarm::core::state::AgentCtxEntry {
994                            policy: mlua_swarm_schema::ContextPolicy {
995                                steps: Some(vec!["plan-out".to_string()]),
996                                ..Default::default()
997                            },
998                            ..Default::default()
999                        },
1000                    );
1001                }
1002            })
1003            .await
1004            .expect("seed policy");
1005
1006        let mut payload = consumer_payload(&consumer_id, &run_id);
1007        assemble_step_pointers(&state, &mut payload).await;
1008
1009        let steps = &payload.context.expect("context").steps;
1010        assert_eq!(steps.len(), 1, "steps: {steps:?}");
1011        assert_eq!(
1012            steps[0].name, "plan-out",
1013            "StepPointer.name must be the canonical name"
1014        );
1015    }
1016}