Skip to main content

mlua_swarm_server/
projection.rs

1//! `McpQueryAdapter` — server-side [`ProjectionAdapter`], and the REST
2//! hierarchy that serves a Run's step OUTPUT as metadata + content
3//! (`projection-adapter` ST5's HTTP debug plane — replaces the ST2/ST4
4//! `GET /v1/tasks/:id/ctx` single-value endpoint / `ProjectionResponse`).
5//!
6//! # Two consumers, two roles (ST5)
7//!
8//! - **Worker axis** (`crates/mlua-swarm-server/src/worker.rs`'s `GET
9//!   /v1/worker/prompt` handler) — the *primary* supply path. A worker's
10//!   fetch payload carries `context.steps: Vec<StepPointer>`, a
11//!   `ContextPolicy.steps`-filtered pointer list assembled automatically at
12//!   fetch time; no separate tool call needed.
13//! - **HTTP debug plane** (this module's `GET
14//!   /v1/tasks/:id/runs/:run/steps*` routes) — the content the above
15//!   pointers' `content_url` addresses, plus an unfiltered metadata/content
16//!   view for operators / humans debugging a run.
17//!
18//! Both consumers share [`McpQueryAdapter::list_steps`]'s enumeration:
19//! every distinct `step_ref` name in `RunRecord.step_entries`, resolved
20//! through the Data-plane `OutputStore` (in-flight-safe — see below),
21//! **union** `RunRecord.result_ref`'s top-level object keys (the
22//! finalized-Run fallback) — a name present in both wins on the Data-plane
23//! side (same rule [`McpQueryAdapter::resolve_run`]'s single-key sibling,
24//! [`McpQueryAdapter::resolve_async`], already applies). Name-namespace
25//! unification (Data-plane producer names vs. flow.ir ctx-path segments)
26//! is tracked separately (see the KNOWN LIMITATION note below); this module
27//! does not resolve it.
28//!
29//! # Architecture (subtask-4 rework, carried into ST5)
30//!
31//! [`McpQueryAdapter`] reads through **two** backings, tried in order:
32//!
33//! 1. **Data-plane, in-flight-safe** (subtask-4's whole reason for being):
34//!    when `key.step` is `Some(producer_agent)` and no explicit `run_id`
35//!    pins an older Run, [`McpQueryAdapter::resolve_async`] first tries
36//!    `OutputStore::get_latest_by_name(producer_agent)` — the same store
37//!    `Engine::submit_output`'s submit-time projection sink dual-writes
38//!    into (see `mlua_swarm::core::engine::Engine::submit_output`'s doc).
39//!    A hit here can be a **not-yet-finalized** Run's already-submitted
40//!    step — the in-flight case this rework exists for.
41//! 2. **Persisted `RunRecord.result_ref` fallback** (the pre-rework path,
42//!    unchanged): used whenever (1) is skipped (`key.step` is `None`, or
43//!    an explicit `run_id` was given) or comes back empty (no Data-plane
44//!    record under that producer name yet — e.g. a Run that predates the
45//!    engine having an `OutputStore` wired, or `key.step` names a flow.ir
46//!    ctx-path segment rather than an agent ref — see the KNOWN
47//!    LIMITATION note below).
48//!
49//! Unlike `crate::operator_ws::session`'s spawn-time
50//! [`mlua_swarm::core::projection::FileProjectionAdapter`] hook (which
51//! materializes the *spawning* agent's own `AgentContextView`), this
52//! adapter's Data-plane path serves **prior steps'** submitted OUTPUT —
53//! the pull-supply counterpart to `Engine`'s submit-time file sink.
54//!
55//! ## KNOWN LIMITATION
56//!
57//! `OutputStore::get_latest_by_name` is producer-name-scoped, not
58//! Run-scoped (see `mlua_swarm::store::output`'s module doc) — it returns
59//! the single newest `Final` submitted anywhere under that producer name,
60//! across every Run / Task. This adapter narrows the blast radius by only
61//! taking this path when an explicit `run_id` did NOT pin an older Run
62//! (an explicit pin always uses the Run-scoped `result_ref` fallback
63//! instead), but two *concurrent* Runs whose flow.ir happens to dispatch
64//! an agent of the identical name can still race each other on this path.
65//! This is an accepted, pre-existing characteristic of the Data-plane
66//! store (not a new race introduced here) — see
67//! `mlua_swarm::store::output::OutputStore::get_latest_by_name`'s doc.
68//!
69//! [`ProjectionAdapter::fetch`] is a synchronous trait method, but this
70//! adapter's backing stores are async. [`McpQueryAdapter::resolve_async`]
71//! is the real, native-async implementation; [`step_content`] (the
72//! content-plane HTTP handler) calls [`McpQueryAdapter::list_steps`]
73//! directly. [`ProjectionAdapter::fetch`] instead bridges to
74//! [`McpQueryAdapter::resolve_async`] via `tokio::task::block_in_place` +
75//! `Handle::block_on` purely for trait conformance (dependency inversion —
76//! this adapter implements the same `core::projection::ProjectionAdapter`
77//! trait [`mlua_swarm::core::projection::FileProjectionAdapter`] does, so a
78//! caller holding a `dyn ProjectionAdapter` can use either
79//! polymorphically); the hot HTTP path never takes that bridge.
80
81use axum::{
82    extract::{Path, Query, State},
83    http::{header, HeaderMap, HeaderValue, StatusCode},
84    response::IntoResponse,
85    Json,
86};
87use mlua_swarm::core::projection::{
88    ProjectionAdapter, ProjectionError, ProjectionKey, ProjectionRef,
89};
90use mlua_swarm::store::output::{ContentRef, OutputEvent, OutputStore, OutputStoreError};
91use mlua_swarm::store::run::{RunRecord, RunStore};
92use mlua_swarm::{RunId, StepId, TaskId};
93use serde::{Deserialize, Serialize};
94use serde_json::Value;
95use sha2::Digest as _;
96use std::sync::Arc;
97
98use crate::tasks::map_task_store_err;
99use crate::{ApiError, AppState};
100
101/// Server-side [`ProjectionAdapter`] backed by an [`OutputStore`]
102/// (in-flight-safe, subtask-4) with a [`RunStore`]-backed `result_ref`
103/// fallback (see the module doc for the full narrative).
104pub struct McpQueryAdapter {
105    data_store: Arc<dyn OutputStore>,
106    run_store: Arc<dyn RunStore>,
107}
108
109/// Which backing produced a [`StepSummary`] / a Worker-axis `StepPointer`
110/// — Data-plane wins a name collision (module doc's "Architecture"
111/// section).
112#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
113#[serde(rename_all = "snake_case")]
114pub enum ProjectionSource {
115    /// Resolved via the in-flight-safe `OutputStore::get_latest_by_name`
116    /// path.
117    DataPlane,
118    /// Resolved via the persisted `RunRecord.result_ref` fallback (the Run
119    /// has finalized, or the name only ever existed there).
120    ResultRef,
121}
122
123/// One step's resolved OUTPUT value plus its provenance — the shared
124/// enumeration result [`McpQueryAdapter::list_steps`] returns, consumed by
125/// both this module's HTTP handlers and
126/// `crates/mlua-swarm-server/src/worker.rs`'s Worker-axis pointer
127/// assembly.
128#[derive(Debug, Clone)]
129pub(crate) struct ResolvedStep {
130    /// The producing step's name (`RunRecord.step_entries[].step_ref`, or
131    /// a `RunRecord.result_ref` top-level key).
132    pub(crate) name: String,
133    /// The resolved OUTPUT value (not yet path-narrowed).
134    pub(crate) value: Value,
135    /// Which backing produced this entry.
136    pub(crate) source: ProjectionSource,
137}
138
139/// Extracts a JSON value out of an [`OutputEvent`]'s content, when the
140/// event is a `Final` (anything else — `Progress` / `Partial` / `Artifact`
141/// sharing the same producer name via the separate `POST /v1/data/emit`
142/// axis — is not a submission this adapter serves, so callers treat
143/// `None` the same as "no record").
144fn final_value(event: &OutputEvent) -> Option<Value> {
145    match event {
146        OutputEvent::Final { content, .. } => Some(content_to_value(content)),
147        _ => None,
148    }
149}
150
151/// Renders a [`ContentRef`] down to a plain [`Value`] — `Inline` passes
152/// its value through verbatim; `FileRef` (large / binary content) becomes
153/// a small locator object (this adapter's `v1` scope does not read the
154/// file back, matching subtask-4's spec: "locator 返却で可").
155fn content_to_value(content: &ContentRef) -> Value {
156    match content {
157        ContentRef::Inline { value } => value.clone(),
158        ContentRef::FileRef {
159            path,
160            mime,
161            size_hint,
162        } => serde_json::json!({
163            "file_ref": path.to_string_lossy(),
164            "mime": mime,
165            "size_hint": size_hint,
166        }),
167    }
168}
169
170impl McpQueryAdapter {
171    /// Builds an adapter reading through `data_store` (in-flight-safe,
172    /// tried first) with `run_store`-backed `result_ref` fallback.
173    pub fn new(data_store: Arc<dyn OutputStore>, run_store: Arc<dyn RunStore>) -> Self {
174        Self {
175            data_store,
176            run_store,
177        }
178    }
179
180    /// Selects the Run `task_id` + `run_id` address: `run_id` when
181    /// `Some`, otherwise the most recently created Run for `task_id`
182    /// ([`RunStore::list_by_task`] returns oldest-created-first, so its
183    /// last element is the latest). [`ProjectionError::NotFound`] covers
184    /// every "nothing here" case uniformly: an unparseable `run_id`, an
185    /// unknown Run, a `run_id` that names a Run belonging to a *different*
186    /// Task, or a Task with no Runs yet.
187    async fn resolve_run(
188        &self,
189        task_id: &TaskId,
190        run_id: Option<&str>,
191    ) -> Result<RunRecord, ProjectionError> {
192        match run_id {
193            Some(rid) => {
194                let run_id = RunId::parse(rid.to_string())
195                    .map_err(|e| ProjectionError::InvalidKey(format!("run_id: {e}")))?;
196                let run = self.run_store.get(&run_id).await.map_err(|_| {
197                    ProjectionError::NotFound(ProjectionKey {
198                        task_id: task_id.to_string(),
199                        run_id: Some(rid.to_string()),
200                        step: None,
201                        path: None,
202                    })
203                })?;
204                if &run.task_id != task_id {
205                    return Err(ProjectionError::NotFound(ProjectionKey {
206                        task_id: task_id.to_string(),
207                        run_id: Some(rid.to_string()),
208                        step: None,
209                        path: None,
210                    }));
211                }
212                Ok(run)
213            }
214            None => {
215                let mut runs = self.run_store.list_by_task(task_id).await.map_err(|_| {
216                    ProjectionError::NotFound(ProjectionKey {
217                        task_id: task_id.to_string(),
218                        run_id: None,
219                        step: None,
220                        path: None,
221                    })
222                })?;
223                runs.pop().ok_or_else(|| {
224                    ProjectionError::NotFound(ProjectionKey {
225                        task_id: task_id.to_string(),
226                        run_id: None,
227                        step: None,
228                        path: None,
229                    })
230                })
231            }
232        }
233    }
234
235    /// The real, native-async single-key resolve: selects the Run `key`
236    /// addresses via [`Self::resolve_run`], then resolves the value —
237    /// Data-plane first (in-flight-safe), falling back to the selected
238    /// Run's persisted `result_ref` — see the module doc's Architecture
239    /// section. Returns the selected [`RunRecord`] alongside the resolved
240    /// value so a caller can report which Run actually served the
241    /// projection, even when the caller only supplied `task_id`.
242    async fn resolve_async(
243        &self,
244        key: &ProjectionKey,
245    ) -> Result<(RunRecord, Value), ProjectionError> {
246        let task_id = TaskId::parse(key.task_id.clone())
247            .map_err(|e| ProjectionError::InvalidKey(format!("task_id: {e}")))?;
248        let run = self.resolve_run(&task_id, key.run_id.as_deref()).await?;
249
250        // Data-plane, in-flight-safe path: only when `step` names a
251        // producer agent AND no explicit `run_id` pinned an older Run (see
252        // the module doc's KNOWN LIMITATION).
253        if key.run_id.is_none() {
254            if let Some(step) = &key.step {
255                match self.data_store.get_latest_by_name(step).await {
256                    Ok(record) => {
257                        if let Some(value) = final_value(&record.event) {
258                            let narrowed = match &key.path {
259                                None => Some(value),
260                                Some(_) => {
261                                    // Reuse `ProjectionKey::resolve`'s path-walk
262                                    // only (the step lookup is already done —
263                                    // this value IS the step's own content, not
264                                    // a `{step: value}` map to look `step` up
265                                    // in again).
266                                    let path_only = ProjectionKey {
267                                        task_id: key.task_id.clone(),
268                                        run_id: key.run_id.clone(),
269                                        step: None,
270                                        path: key.path.clone(),
271                                    };
272                                    path_only.resolve(&value).cloned()
273                                }
274                            };
275                            if let Some(value) = narrowed {
276                                return Ok((run, value));
277                            }
278                        }
279                    }
280                    Err(OutputStoreError::NotFound(_)) => {
281                        // No Data-plane record under this producer name —
282                        // fall through to the result_ref fallback below.
283                    }
284                    Err(other) => {
285                        return Err(ProjectionError::Io(std::io::Error::other(format!(
286                            "OutputStore::get_latest_by_name: {other}"
287                        ))));
288                    }
289                }
290            }
291        }
292
293        // Fallback: the pre-rework, Run-scoped `result_ref` path.
294        let ctx_data = run.result_ref.clone().unwrap_or(Value::Null);
295        let value = key
296            .resolve(&ctx_data)
297            .cloned()
298            .ok_or_else(|| ProjectionError::NotFound(key.clone()))?;
299        Ok((run, value))
300    }
301
302    /// Enumerates every step visible for the Run addressed by `task_id` +
303    /// `run_id` (`None` = latest) — the shared enumeration both this
304    /// module's HTTP handlers and the Worker axis's pointer assembly
305    /// build from (module doc). Returns the selected [`RunRecord`]
306    /// alongside the resolved steps.
307    pub(crate) async fn list_steps(
308        &self,
309        task_id: &TaskId,
310        run_id: Option<&str>,
311    ) -> Result<(RunRecord, Vec<ResolvedStep>), ProjectionError> {
312        let run = self.resolve_run(task_id, run_id).await?;
313        let steps = self.enumerate_steps(&run).await;
314        Ok((run, steps))
315    }
316
317    /// Same enumeration as [`Self::list_steps`], addressed directly by an
318    /// already-known [`RunId`] (no `task_id` cross-check, no `"latest"`
319    /// ambiguity) — the Worker axis's entry point
320    /// (`crates/mlua-swarm-server/src/worker.rs`), which already has the
321    /// exact Run its own `AgentContextView.run_id` names, from
322    /// `Ctx.meta.runtime[RUN_ID_KEY]` (threaded through by
323    /// `Engine::dispatch_attempt_with`).
324    pub(crate) async fn list_steps_by_run_id(
325        &self,
326        run_id: &RunId,
327    ) -> Result<(RunRecord, Vec<ResolvedStep>), ProjectionError> {
328        let run = self.run_store.get(run_id).await.map_err(|_| {
329            ProjectionError::NotFound(ProjectionKey {
330                task_id: String::new(),
331                run_id: Some(run_id.to_string()),
332                step: None,
333                path: None,
334            })
335        })?;
336        let steps = self.enumerate_steps(&run).await;
337        Ok((run, steps))
338    }
339
340    /// Data-plane `run.step_entries`' distinct `step_ref` names, resolved
341    /// through `OutputStore::get_latest_by_name` — **union**
342    /// `run.result_ref`'s top-level object keys not already resolved on
343    /// the Data-plane side (Data-plane wins a name collision, matching
344    /// [`Self::resolve_async`]'s single-key rule).
345    async fn enumerate_steps(&self, run: &RunRecord) -> Vec<ResolvedStep> {
346        let mut out = Vec::new();
347        let mut attempted = std::collections::HashSet::new();
348        let mut resolved_names = std::collections::HashSet::new();
349
350        for entry in &run.step_entries {
351            let Some(name) = &entry.step_ref else {
352                continue;
353            };
354            if !attempted.insert(name.clone()) {
355                continue;
356            }
357            if let Ok(record) = self.data_store.get_latest_by_name(name).await {
358                if let Some(value) = final_value(&record.event) {
359                    out.push(ResolvedStep {
360                        name: name.clone(),
361                        value,
362                        source: ProjectionSource::DataPlane,
363                    });
364                    resolved_names.insert(name.clone());
365                }
366            }
367        }
368
369        if let Some(Value::Object(map)) = &run.result_ref {
370            for (name, value) in map {
371                if resolved_names.contains(name) {
372                    continue;
373                }
374                out.push(ResolvedStep {
375                    name: name.clone(),
376                    value: value.clone(),
377                    source: ProjectionSource::ResultRef,
378                });
379            }
380        }
381
382        out
383    }
384}
385
386impl ProjectionAdapter for McpQueryAdapter {
387    fn name(&self) -> &'static str {
388        "mcp-query"
389    }
390
391    /// `ctx_data` is used only to fail loud up front (mirrors
392    /// [`mlua_swarm::core::projection::FileProjectionAdapter::project`]'s
393    /// own not-found check) — the returned [`ProjectionRef::Query`]
394    /// locator carries `key` itself, not a resolved value; the real lookup
395    /// happens later, at [`Self::fetch`] time, against whatever the
396    /// addressed Run's backing is *then* (which may differ from
397    /// `ctx_data`, e.g. after a re-kick, or once a step submits through
398    /// the Data-plane store).
399    fn project(
400        &self,
401        key: &ProjectionKey,
402        ctx_data: &Value,
403    ) -> Result<ProjectionRef, ProjectionError> {
404        if key.task_id.is_empty() {
405            return Err(ProjectionError::InvalidKey(
406                "task_id must not be empty".to_string(),
407            ));
408        }
409        key.resolve(ctx_data)
410            .ok_or_else(|| ProjectionError::NotFound(key.clone()))?;
411        Ok(ProjectionRef::Query {
412            endpoint: format!(
413                "/v1/tasks/{}/runs/{}/steps/{}/content",
414                key.task_id,
415                key.run_id.as_deref().unwrap_or("latest"),
416                key.step.as_deref().unwrap_or("_ctx")
417            ),
418            key: key.clone(),
419        })
420    }
421
422    fn fetch(&self, key: &ProjectionKey) -> Result<Value, ProjectionError> {
423        // See the module doc: this bridge exists for `ProjectionAdapter`
424        // trait conformance only. `block_in_place` requires the Tokio
425        // multi-thread runtime flavor (the workspace's `tokio` dependency
426        // enables `features = ["full"]`, which includes it).
427        let handle = tokio::runtime::Handle::try_current().map_err(|e| {
428            ProjectionError::Io(std::io::Error::other(format!(
429                "McpQueryAdapter::fetch requires a Tokio runtime: {e}"
430            )))
431        })?;
432        let (_run, value) =
433            tokio::task::block_in_place(|| handle.block_on(self.resolve_async(key)))?;
434        Ok(value)
435    }
436
437    fn pointer_line(&self, r: &ProjectionRef) -> String {
438        match r {
439            ProjectionRef::Query { endpoint, key } => {
440                format!("projection(mcp-query): {endpoint} task_id={}", key.task_id)
441            }
442            ProjectionRef::File { path } => format!("projection(file): {path}"),
443        }
444    }
445}
446
447// ──────────────────────────────────────────────────────────────────────────
448// REST hierarchy: StepList / StepSummary / content plane
449// ──────────────────────────────────────────────────────────────────────────
450
451/// Response body for `GET /v1/tasks/:id/runs/:run/steps`.
452#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
453pub struct StepList {
454    /// The addressed Task.
455    pub task_id: String,
456    /// The Run this list resolved `:run` to (the concrete id, even when
457    /// the request path said `latest`).
458    pub run_id: String,
459    /// Every visible step, unfiltered (the HTTP debug plane serves the
460    /// full union — `ContextPolicy.steps` filtering only applies to the
461    /// Worker axis's `context.steps` pointer list; see the module doc).
462    pub steps: Vec<StepSummary>,
463}
464
465/// One step's metadata (operator / debug plane) — `GET
466/// /v1/tasks/:id/runs/:run/steps/:step`, and each entry of
467/// [`StepList::steps`].
468#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
469pub struct StepSummary {
470    /// The producing step's name.
471    pub name: String,
472    /// Byte length of the body [`Self::content_url`] serves (the exact
473    /// bytes a `GET` of that URL returns for this same `?path=`, if any).
474    pub size_bytes: u64,
475    /// MIME type [`Self::content_url`] serves this body as
476    /// (`text/markdown; charset=utf-8` when materialized-file-backed,
477    /// `application/json` otherwise — see the module doc's Content-Type
478    /// rule).
479    pub content_type: String,
480    /// SHA-256 hex digest of the body, matching the content endpoint's
481    /// `ETag` value (`sha256:<hex>`, minus the `sha256:` prefix).
482    pub sha256: String,
483    /// Which backing produced this entry.
484    pub source: ProjectionSource,
485    /// Absolute filesystem path to the materialized projection file
486    /// (`crate::core::projection::FileProjectionAdapter`'s
487    /// `<root>/workspace/tasks/<step_id>/ctx/<name>.md` target), when one
488    /// exists AND this entry addresses the whole step (no `?path=`
489    /// narrowing — a narrowed fragment is never file-backed). `None`
490    /// otherwise.
491    #[serde(default, skip_serializing_if = "Option::is_none")]
492    pub file_path: Option<String>,
493    /// Fetch URL for this step's content (`GET
494    /// /v1/tasks/:id/runs/:run/steps/:step/content`, `?path=` echoed when
495    /// this entry is narrowed) — absolute (`AppState.base_url`-prefixed)
496    /// when the server has a configured base URL, relative otherwise.
497    pub content_url: String,
498    /// First <= 512 bytes of the body, UTF-8-boundary-safe (never splits
499    /// a multi-byte character), with a trailing `…` when truncated.
500    pub preview: String,
501    /// `true` when [`Self::preview`]'s underlying byte count is shorter
502    /// than [`Self::size_bytes`] (the body was truncated to build the
503    /// preview).
504    pub truncated: bool,
505}
506
507/// Query params shared by the metadata and content routes: narrows a
508/// single step's value via `$.a.b` dot-path form (the leading `$.` is
509/// optional) — same syntax `mlua_swarm::core::projection::ProjectionKey`
510/// already establishes.
511#[derive(Debug, Deserialize, Default, schemars::JsonSchema)]
512pub struct StepPathQuery {
513    /// `$.a.b` narrowing within the step's value. `None` = the whole
514    /// step value.
515    #[serde(default)]
516    pub path: Option<String>,
517}
518
519/// Narrows `value` by `path` (reuses [`ProjectionKey::resolve`]'s
520/// path-walk half — the step lookup is already done, this value IS the
521/// step's own content).
522fn narrow_step_value(value: &Value, path: Option<&str>) -> Option<Value> {
523    match path {
524        None => Some(value.clone()),
525        Some(p) => {
526            let path_only = ProjectionKey {
527                task_id: String::new(),
528                run_id: None,
529                step: None,
530                path: Some(p.to_string()),
531            };
532            path_only.resolve(value).cloned()
533        }
534    }
535}
536
537/// The materialize target [`mlua_swarm::core::projection::FileProjectionAdapter`]
538/// writes to for a submission (`<root>/workspace/tasks/<step_id>/ctx/<name>.md`
539/// — same convention as that adapter's own `target_path`, reconstructed
540/// here because this module resolves `root` for a step *other than* the
541/// one materializing it, so it cannot construct the adapter itself
542/// key-first).
543fn materialized_file_path(root: &str, step_id: &StepId, name: &str) -> std::path::PathBuf {
544    std::path::Path::new(root)
545        .join("workspace")
546        .join("tasks")
547        .join(step_id.to_string())
548        .join("ctx")
549        .join(format!("{name}.md"))
550}
551
552/// Resolves the materialized file body for `name` in `run`, when one
553/// exists: finds `name`'s most recent [`mlua_swarm::store::run::StepEntry`]
554/// (giving its own dispatch `StepId`), resolves that step's own
555/// `AgentContextView` root (`work_dir`, falling back to `project_root` —
556/// the same fallback order `Engine::submit_output`'s materialize sink
557/// uses) via [`mlua_swarm::core::engine::Engine::agent_context_for`], and
558/// reads the file at the resulting path back.
559///
560/// Only tries `attempt = 1` (the common case — a single dispatch per
561/// flow.ir Step) — a step retried under the same `StepId` at a later
562/// attempt is a known, accepted limitation (matching this module's other
563/// KNOWN LIMITATION notes); the entry still resolves via its Data-plane /
564/// `result_ref` value, just without a `file_path`.
565async fn resolve_materialized_file(
566    state: &AppState,
567    run: &RunRecord,
568    name: &str,
569) -> Option<(std::path::PathBuf, Vec<u8>)> {
570    let step_id = run
571        .step_entries
572        .iter()
573        .rev()
574        .find(|e| e.step_ref.as_deref() == Some(name))
575        .map(|e| e.step_id.clone())?;
576    let view = state.engine.agent_context_for(&step_id, 1).await?;
577    let root = view.work_dir.clone().or(view.project_root.clone())?;
578    let path = materialized_file_path(&root, &step_id, name);
579    let bytes = std::fs::read(&path).ok()?;
580    Some((path, bytes))
581}
582
583/// Renders the body [`Self`]'s content endpoint serves for `step`,
584/// narrowed by `path` when `Some`: whole-step + materialized-file-backed
585/// → the raw file bytes (`text/markdown; charset=utf-8`); anything else →
586/// the (possibly narrowed) value as pretty JSON (`application/json`).
587/// Returns `None` when `path` is `Some` and does not resolve against
588/// `step.value` (the caller's 404 case).
589async fn render_step_body(
590    state: &AppState,
591    run: &RunRecord,
592    step: &ResolvedStep,
593    path: Option<&str>,
594) -> Option<(Vec<u8>, &'static str, Option<String>)> {
595    if path.is_none() {
596        if let Some((file_path, bytes)) = resolve_materialized_file(state, run, &step.name).await {
597            return Some((
598                bytes,
599                "text/markdown; charset=utf-8",
600                Some(file_path.to_string_lossy().into_owned()),
601            ));
602        }
603    }
604    let narrowed = narrow_step_value(&step.value, path)?;
605    let body = serde_json::to_vec_pretty(&narrowed).ok()?;
606    Some((body, "application/json", None))
607}
608
609/// First <= 512 bytes of `body`, UTF-8-boundary-safe (never splits a
610/// multi-byte character), with a trailing `…` when truncated. Returns
611/// `(preview, truncated)`. `body` is expected to be valid UTF-8 (JSON /
612/// materialized-markdown text, per [`render_step_body`]'s own two output
613/// shapes); a malformed byte sequence falls back to a lossy decode rather
614/// than panicking.
615fn build_preview(body: &[u8]) -> (String, bool) {
616    const MAX_PREVIEW_BYTES: usize = 512;
617    if body.len() <= MAX_PREVIEW_BYTES {
618        return (String::from_utf8_lossy(body).into_owned(), false);
619    }
620    let preview = match std::str::from_utf8(body) {
621        Ok(s) => {
622            let mut end = MAX_PREVIEW_BYTES;
623            while end > 0 && !s.is_char_boundary(end) {
624                end -= 1;
625            }
626            s[..end].to_string()
627        }
628        Err(_) => String::from_utf8_lossy(&body[..MAX_PREVIEW_BYTES]).into_owned(),
629    };
630    (format!("{preview}…"), true)
631}
632
633/// `GET /v1/tasks/:id/runs/:run/steps/:step/content`'s URL — absolute
634/// (`base_url`-prefixed) when the server has one configured, relative
635/// otherwise. `path` is echoed back as `?path=` verbatim (unencoded — the
636/// dot-path syntax this module accepts uses no characters reserved in a
637/// URL query component).
638fn build_content_url(
639    base_url: &Option<Arc<str>>,
640    task_id: &TaskId,
641    run_id: &RunId,
642    name: &str,
643    path: Option<&str>,
644) -> String {
645    let mut url = format!("/v1/tasks/{task_id}/runs/{run_id}/steps/{name}/content");
646    if let Some(p) = path {
647        url.push_str("?path=");
648        url.push_str(p);
649    }
650    match base_url {
651        Some(base) => format!("{}{}", base.trim_end_matches('/'), url),
652        None => url,
653    }
654}
655
656/// Builds the full [`StepSummary`] for `step`, narrowed by `path` when
657/// `Some`. `None` when `path` does not resolve (the caller's 404 case).
658async fn build_step_summary(
659    state: &AppState,
660    run: &RunRecord,
661    step: &ResolvedStep,
662    path: Option<&str>,
663) -> Option<StepSummary> {
664    let (body, content_type, file_path) = render_step_body(state, run, step, path).await?;
665    let sha256 = hex::encode(sha2::Sha256::digest(&body));
666    let size_bytes = body.len() as u64;
667    let (preview, truncated) = build_preview(&body);
668    let content_url = build_content_url(&state.base_url, &run.task_id, &run.id, &step.name, path);
669    Some(StepSummary {
670        name: step.name.clone(),
671        size_bytes,
672        content_type: content_type.to_string(),
673        sha256,
674        source: step.source,
675        file_path,
676        content_url,
677        preview,
678        truncated,
679    })
680}
681
682/// Fields a Worker-axis
683/// [`mlua_swarm::core::agent_context::StepPointer`] needs —
684/// `crates/mlua-swarm-server/src/worker.rs`'s `GET /v1/worker/prompt`
685/// handler builds one per visible, policy-allowed step from this.
686/// Reuses the same whole-step body [`render_step_body`] renders for the
687/// content endpoint (`path = None`), so `sha256` / `size_bytes` always
688/// matches what a `GET` of the returned `content_url` serves. `None`
689/// when the body cannot be rendered at all (mirrors this crate's other
690/// best-effort projection hooks — never turns a would-have-succeeded
691/// fetch into a failure; the caller just omits this step's pointer).
692pub(crate) async fn resolve_step_pointer_fields(
693    state: &AppState,
694    run: &RunRecord,
695    step: &ResolvedStep,
696) -> Option<(u64, Option<String>, String, String)> {
697    let (body, _content_type, file_path) = render_step_body(state, run, step, None).await?;
698    let sha256 = hex::encode(sha2::Sha256::digest(&body));
699    let size_bytes = body.len() as u64;
700    let content_url = build_content_url(&state.base_url, &run.task_id, &run.id, &step.name, None);
701    Some((size_bytes, file_path, content_url, sha256))
702}
703
704/// Shared resolve: `:id` → `TaskId` (existence-checked against
705/// `state.task_store` first, so an unknown Task returns its own 404
706/// distinct from an unknown Run) + `:run` (`"latest"` or an explicit
707/// `R-<hex>`) → the addressed [`RunRecord`] and its enumerated
708/// [`ResolvedStep`]s.
709async fn resolve_run_and_steps(
710    state: &AppState,
711    id: &str,
712    run: &str,
713) -> Result<(RunRecord, Vec<ResolvedStep>), ApiError> {
714    let task_id = TaskId::parse(id.to_string())
715        .map_err(|e| ApiError::bad_request(format!("invalid task id: {e}")))?;
716    state
717        .task_store
718        .get(&task_id)
719        .await
720        .map_err(map_task_store_err)?;
721    let adapter = McpQueryAdapter::new(state.data_store.clone(), state.run_store.clone());
722    let run_sel = if run == "latest" { None } else { Some(run) };
723    adapter
724        .list_steps(&task_id, run_sel)
725        .await
726        .map_err(map_projection_err)
727}
728
729/// `GET /v1/tasks/:id/runs/:run/steps` — every step visible for the
730/// addressed Run, unfiltered (see the module doc's role split).
731pub async fn steps_list(
732    State(state): State<AppState>,
733    Path((id, run)): Path<(String, String)>,
734) -> Result<Json<StepList>, ApiError> {
735    let (run_record, steps) = resolve_run_and_steps(&state, &id, &run).await?;
736    let mut summaries = Vec::with_capacity(steps.len());
737    for step in &steps {
738        if let Some(summary) = build_step_summary(&state, &run_record, step, None).await {
739            summaries.push(summary);
740        }
741    }
742    Ok(Json(StepList {
743        task_id: run_record.task_id.to_string(),
744        run_id: run_record.id.to_string(),
745        steps: summaries,
746    }))
747}
748
749/// `GET /v1/tasks/:id/runs/:run/steps/:step?path=$.a.b` — one step's
750/// metadata, optionally narrowed.
751pub async fn step_get(
752    State(state): State<AppState>,
753    Path((id, run, step)): Path<(String, String, String)>,
754    Query(q): Query<StepPathQuery>,
755) -> Result<Json<StepSummary>, ApiError> {
756    let (run_record, steps) = resolve_run_and_steps(&state, &id, &run).await?;
757    let resolved = steps
758        .into_iter()
759        .find(|s| s.name == step)
760        .ok_or_else(|| ApiError::not_found(format!("step not found: {step}")))?;
761    let summary = build_step_summary(&state, &run_record, &resolved, q.path.as_deref())
762        .await
763        .ok_or_else(|| ApiError::not_found(format!("path not found: {:?}", q.path)))?;
764    Ok(Json(summary))
765}
766
767/// `GET /v1/tasks/:id/runs/:run/steps/:step/content?path=$.a.b` — the raw
768/// body: full bytes, no envelope, no Range support. `Content-Type` and
769/// `ETag` follow [`StepSummary::content_type`] / [`StepSummary::sha256`]'s
770/// same rules (module doc).
771pub async fn step_content(
772    State(state): State<AppState>,
773    Path((id, run, step)): Path<(String, String, String)>,
774    Query(q): Query<StepPathQuery>,
775) -> Result<impl IntoResponse, ApiError> {
776    let (run_record, steps) = resolve_run_and_steps(&state, &id, &run).await?;
777    let resolved = steps
778        .into_iter()
779        .find(|s| s.name == step)
780        .ok_or_else(|| ApiError::not_found(format!("step not found: {step}")))?;
781    let (body, content_type, _file_path) =
782        render_step_body(&state, &run_record, &resolved, q.path.as_deref())
783            .await
784            .ok_or_else(|| ApiError::not_found(format!("path not found: {:?}", q.path)))?;
785    let sha256 = hex::encode(sha2::Sha256::digest(&body));
786    let mut headers = HeaderMap::new();
787    headers.insert(
788        header::CONTENT_TYPE,
789        HeaderValue::from_str(content_type).expect("content_type is a static ASCII literal"),
790    );
791    headers.insert(
792        header::ETAG,
793        HeaderValue::from_str(&format!("\"sha256:{sha256}\""))
794            .expect("hex digest is ASCII-safe for a header value"),
795    );
796    Ok((StatusCode::OK, headers, body))
797}
798
799fn map_projection_err(e: ProjectionError) -> ApiError {
800    match e {
801        ProjectionError::NotFound(key) => {
802            ApiError::not_found(format!("projection not found for key {key:?}"))
803        }
804        ProjectionError::InvalidKey(msg) => ApiError::bad_request(msg),
805        other => ApiError::engine(other),
806    }
807}
808
809// ──────────────────────────────────────────────────────────────────────────
810// UT
811// ──────────────────────────────────────────────────────────────────────────
812
813#[cfg(test)]
814mod tests {
815    use super::*;
816    use crate::TaskLaunchRequest;
817    use axum::http::StatusCode;
818    use mlua_swarm::application::BlueprintRef;
819    use mlua_swarm::blueprint::{
820        current_schema_version, AgentDef, AgentKind, Blueprint, BlueprintMetadata, CompilerHints,
821        CompilerStrategy,
822    };
823    use mlua_swarm::core::config::EngineCfg;
824    use mlua_swarm::core::engine::Engine;
825    use mlua_swarm::store::output::InMemoryOutputStore;
826    use mlua_swarm::store::run::InMemoryRunStore;
827    use mlua_swarm::store::task::InMemoryTaskStore;
828    use serde_json::json;
829    use std::collections::HashMap;
830    use tokio::sync::Mutex;
831
832    /// A single-step flow.ir Blueprint that echoes `$.greeting` into
833    /// `$.out` (AG_IDENTITY wraps its input as `{"echoed": input}`), so
834    /// `result_ref = {"out": {"echoed": <greeting>}}` — enough shape to
835    /// exercise `step` + `path` narrowing. Mirrors `tasks.rs`'s own test
836    /// helper (duplicated here rather than shared — this crate's
837    /// established per-module test-helper convention; see e.g.
838    /// `tasks::tests::test_state`).
839    fn greeting_blueprint() -> Blueprint {
840        Blueprint {
841            schema_version: current_schema_version(),
842            id: "projection-test-greeting-bp".into(),
843            flow: serde_json::from_value(json!({
844                "kind": "step",
845                "ref": mlua_swarm::worker::baseline::AG_IDENTITY,
846                "in": {"op": "path", "at": "$.greeting"},
847                "out": {"op": "path", "at": "$.out"},
848            }))
849            .expect("flow parse"),
850            agents: vec![AgentDef {
851                name: mlua_swarm::worker::baseline::AG_IDENTITY.into(),
852                kind: AgentKind::RustFn,
853                spec: json!({"fn_id": mlua_swarm::worker::baseline::AG_IDENTITY}),
854                profile: None,
855                meta: None,
856            }],
857            operators: vec![],
858            metas: vec![],
859            hints: CompilerHints::default(),
860            strategy: CompilerStrategy::default(),
861            metadata: BlueprintMetadata::default(),
862            spawner_hints: Default::default(),
863            default_agent_kind: AgentKind::Operator,
864            default_operator_kind: None,
865            default_init_ctx: None,
866            default_agent_ctx: None,
867            default_context_policy: None,
868        }
869    }
870
871    fn test_state() -> AppState {
872        let engine = Engine::new_with_layers(EngineCfg::default(), crate::default_layer_registry());
873        let compiler = mlua_swarm::Compiler::new(crate::default_registry());
874        let launch = Arc::new(mlua_swarm::TaskLaunchService::new(engine.clone(), compiler));
875        let data_store: Arc<dyn mlua_swarm::store::output::OutputStore> =
876            Arc::new(InMemoryOutputStore::new());
877        // subtask-4 / ST2 rework: wire the SAME `OutputStore` into the
878        // engine's submit-time projection sink (mirrors
879        // `crate::build_router_full`'s own wiring), so tests exercising the
880        // Data-plane / in-flight path see ordinary worker submissions land
881        // here too, not just explicit `POST /v1/data/emit` calls.
882        engine.set_output_store(data_store.clone());
883        AppState {
884            engine,
885            sessions: Arc::new(Mutex::new(crate::SessionStore::default())),
886            task_app: Arc::new(mlua_swarm::TaskApplication::new_inline_only(launch)),
887            ws_operator_factory: None,
888            data_store,
889            operator_sessions: Arc::new(Mutex::new(HashMap::new())),
890            roles_to_sid: Arc::new(Mutex::new(HashMap::new())),
891            task_store: Arc::new(InMemoryTaskStore::new()),
892            run_store: Arc::new(InMemoryRunStore::new()),
893            base_url: None,
894        }
895    }
896
897    fn greeting_task_req(greeting: &str) -> TaskLaunchRequest {
898        TaskLaunchRequest {
899            blueprint: BlueprintRef::Inline {
900                value: Box::new(greeting_blueprint()),
901            },
902            init_ctx: json!({ "greeting": greeting }),
903            project_root: None,
904            work_dir: None,
905            task_metadata: None,
906            ttl_secs: None,
907            operator: None,
908            operator_sid: None,
909            goal: Some("projection test goal".to_string()),
910        }
911    }
912
913    // ─── Test 8: steps collection, data-plane ∪ result_ref union ───────────
914
915    #[tokio::test]
916    async fn steps_list_returns_data_plane_and_result_ref_union() {
917        let state = test_state();
918        let posted = crate::tasks_start(State(state.clone()), Json(greeting_task_req("hello")))
919            .await
920            .expect("tasks_start")
921            .0;
922
923        let resp = steps_list(
924            State(state.clone()),
925            Path((posted.task_id.to_string(), "latest".to_string())),
926        )
927        .await
928        .expect("steps_list")
929        .0;
930
931        assert_eq!(resp.task_id, posted.task_id.to_string());
932        assert_eq!(resp.run_id, posted.run_id.to_string());
933        // AG_IDENTITY's own producer name resolves via the Data-plane
934        // dual-write; "out" (the flow.ir ctx-path segment) only exists in
935        // `result_ref` — both must appear, with the correct `source`.
936        let identity_name = mlua_swarm::worker::baseline::AG_IDENTITY;
937        let identity_entry = resp
938            .steps
939            .iter()
940            .find(|s| s.name == identity_name)
941            .unwrap_or_else(|| panic!("missing {identity_name} in {:?}", resp.steps));
942        assert_eq!(identity_entry.source, ProjectionSource::DataPlane);
943        let out_entry = resp
944            .steps
945            .iter()
946            .find(|s| s.name == "out")
947            .unwrap_or_else(|| panic!("missing \"out\" in {:?}", resp.steps));
948        assert_eq!(out_entry.source, ProjectionSource::ResultRef);
949    }
950
951    // ─── Test 9: `:run = latest` resolves to newest Run; explicit pin still works ───
952
953    #[tokio::test]
954    async fn steps_list_latest_resolves_newest_run_explicit_pin_still_works() {
955        let state = test_state();
956        let first = crate::tasks_start(State(state.clone()), Json(greeting_task_req("first")))
957            .await
958            .expect("tasks_start")
959            .0;
960        let (status, rekicked) = crate::tasks::task_rekick(
961            State(state.clone()),
962            Path(first.task_id.to_string()),
963            Some(Json(crate::tasks::RunKickRequest {
964                init_ctx_override: Some(json!({ "greeting": "second" })),
965                task_input_override: None,
966            })),
967        )
968        .await
969        .expect("task_rekick");
970        assert_eq!(status, StatusCode::CREATED);
971
972        let latest = steps_list(
973            State(state.clone()),
974            Path((first.task_id.to_string(), "latest".to_string())),
975        )
976        .await
977        .expect("steps_list latest")
978        .0;
979        assert_eq!(latest.run_id, rekicked.0.run_id.to_string());
980
981        let pinned = steps_list(
982            State(state.clone()),
983            Path((first.task_id.to_string(), first.run_id.to_string())),
984        )
985        .await
986        .expect("steps_list pinned")
987        .0;
988        assert_eq!(pinned.run_id, first.run_id.to_string());
989    }
990
991    // ─── Test 10: preview <= 512 bytes, UTF-8 boundary safe, truncated flag ───
992
993    #[tokio::test]
994    async fn step_get_preview_is_utf8_boundary_safe_and_truncated_flag_is_correct() {
995        let state = test_state();
996        // A multi-byte fixture: repeat a 3-byte UTF-8 character (U+3042
997        // "あ") past the 512-byte preview cap so the boundary-safety guard
998        // is actually exercised, then wrap it as the greeting value.
999        let long_value = "あ".repeat(300); // 900 bytes
1000        let posted = crate::tasks_start(State(state.clone()), Json(greeting_task_req(&long_value)))
1001            .await
1002            .expect("tasks_start")
1003            .0;
1004
1005        let identity_name = mlua_swarm::worker::baseline::AG_IDENTITY;
1006        let summary = step_get(
1007            State(state.clone()),
1008            Path((
1009                posted.task_id.to_string(),
1010                "latest".to_string(),
1011                identity_name.to_string(),
1012            )),
1013            Query(StepPathQuery::default()),
1014        )
1015        .await
1016        .expect("step_get")
1017        .0;
1018
1019        assert!(
1020            summary.preview.len() <= 512 + "…".len(),
1021            "preview must stay near the 512-byte cap: {} bytes",
1022            summary.preview.len()
1023        );
1024        assert!(
1025            summary.truncated,
1026            "a 900-byte body must be reported truncated"
1027        );
1028        assert!(
1029            summary.preview.ends_with('…'),
1030            "truncated preview must end with an ellipsis: {}",
1031            summary.preview
1032        );
1033        // The boundary-safety guard: a valid `String` never panics on
1034        // construction from a byte slice that split a multi-byte char —
1035        // reaching this assertion at all is the proof (an unsafe/naive
1036        // byte-slice truncation would have panicked above on `str`
1037        // reconstruction).
1038        assert!(summary.preview.chars().all(|c| c != '\u{FFFD}'));
1039    }
1040
1041    // ─── Test 11: content = full body + Content-Type branch + ETag ────────
1042
1043    #[tokio::test]
1044    async fn step_content_in_memory_fallback_is_json_with_matching_etag() {
1045        let state = test_state();
1046        let posted = crate::tasks_start(State(state.clone()), Json(greeting_task_req("hi")))
1047            .await
1048            .expect("tasks_start")
1049            .0;
1050
1051        let resp = step_content(
1052            State(state.clone()),
1053            Path((
1054                posted.task_id.to_string(),
1055                "latest".to_string(),
1056                "out".to_string(),
1057            )),
1058            Query(StepPathQuery::default()),
1059        )
1060        .await
1061        .expect("step_content")
1062        .into_response();
1063
1064        assert_eq!(resp.status(), StatusCode::OK);
1065        let content_type = resp
1066            .headers()
1067            .get(header::CONTENT_TYPE)
1068            .expect("content-type header")
1069            .to_str()
1070            .expect("ascii");
1071        assert_eq!(content_type, "application/json");
1072        let etag = resp
1073            .headers()
1074            .get(header::ETAG)
1075            .expect("etag header")
1076            .to_str()
1077            .expect("ascii")
1078            .to_string();
1079        let body_bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1080            .await
1081            .expect("body bytes");
1082        let expected_sha = hex::encode(sha2::Sha256::digest(&body_bytes));
1083        assert_eq!(etag, format!("\"sha256:{expected_sha}\""));
1084        let parsed: Value = serde_json::from_slice(&body_bytes).expect("valid json body");
1085        assert_eq!(parsed["echoed"], json!("hi"));
1086    }
1087
1088    /// Test 11 (materialized-file half): when the producing step's
1089    /// submission was materialized to disk (`work_dir` resolved),
1090    /// `step_content` serves the RAW file bytes as `text/markdown`, not
1091    /// the in-memory JSON fallback.
1092    #[tokio::test]
1093    async fn step_content_materialized_file_is_served_as_markdown() {
1094        let dir = tempfile::TempDir::new().unwrap();
1095        let state = test_state();
1096        let mut req = greeting_task_req("materialized");
1097        req.work_dir = Some(dir.path().to_string_lossy().into_owned());
1098        let posted = crate::tasks_start(State(state.clone()), Json(req))
1099            .await
1100            .expect("tasks_start")
1101            .0;
1102
1103        let identity_name = mlua_swarm::worker::baseline::AG_IDENTITY;
1104        let resp = step_content(
1105            State(state.clone()),
1106            Path((
1107                posted.task_id.to_string(),
1108                "latest".to_string(),
1109                identity_name.to_string(),
1110            )),
1111            Query(StepPathQuery::default()),
1112        )
1113        .await
1114        .expect("step_content")
1115        .into_response();
1116
1117        assert_eq!(resp.status(), StatusCode::OK);
1118        let content_type = resp
1119            .headers()
1120            .get(header::CONTENT_TYPE)
1121            .expect("content-type header")
1122            .to_str()
1123            .expect("ascii");
1124        assert_eq!(content_type, "text/markdown; charset=utf-8");
1125        let body_bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1126            .await
1127            .expect("body bytes");
1128        let body_str = String::from_utf8(body_bytes.to_vec()).expect("utf8 body");
1129        assert!(
1130            body_str.contains("```json"),
1131            "materialized file must carry the fenced json block: {body_str}"
1132        );
1133    }
1134
1135    // ─── Test 12: content `?path=` narrow → application/json fragment ─────
1136
1137    #[tokio::test]
1138    async fn step_content_path_narrow_returns_json_fragment() {
1139        let state = test_state();
1140        let posted = crate::tasks_start(State(state.clone()), Json(greeting_task_req("narrowed")))
1141            .await
1142            .expect("tasks_start")
1143            .0;
1144
1145        let resp = step_content(
1146            State(state.clone()),
1147            Path((
1148                posted.task_id.to_string(),
1149                "latest".to_string(),
1150                "out".to_string(),
1151            )),
1152            Query(StepPathQuery {
1153                path: Some("echoed".to_string()),
1154            }),
1155        )
1156        .await
1157        .expect("step_content narrowed")
1158        .into_response();
1159
1160        assert_eq!(resp.status(), StatusCode::OK);
1161        let content_type = resp
1162            .headers()
1163            .get(header::CONTENT_TYPE)
1164            .expect("content-type header")
1165            .to_str()
1166            .expect("ascii");
1167        assert_eq!(content_type, "application/json");
1168        let body_bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1169            .await
1170            .expect("body bytes");
1171        let parsed: Value = serde_json::from_slice(&body_bytes).expect("valid json body");
1172        assert_eq!(parsed, json!("narrowed"));
1173    }
1174
1175    // ─── Test 13: unknown task / run / step → 404 ───────────────────────────
1176
1177    #[tokio::test]
1178    async fn steps_list_unknown_task_returns_404() {
1179        let state = test_state();
1180        let err = steps_list(
1181            State(state),
1182            Path(("T-does-not-exist".to_string(), "latest".to_string())),
1183        )
1184        .await
1185        .expect_err("unknown task must 404");
1186        assert_eq!(err.status, StatusCode::NOT_FOUND);
1187    }
1188
1189    #[tokio::test]
1190    async fn steps_list_unknown_run_returns_404() {
1191        let state = test_state();
1192        let posted = crate::tasks_start(State(state.clone()), Json(greeting_task_req("hi")))
1193            .await
1194            .expect("tasks_start")
1195            .0;
1196        let err = steps_list(
1197            State(state),
1198            Path((posted.task_id.to_string(), "R-does-not-exist".to_string())),
1199        )
1200        .await
1201        .expect_err("unknown run must 404");
1202        assert_eq!(err.status, StatusCode::NOT_FOUND);
1203    }
1204
1205    #[tokio::test]
1206    async fn step_get_unknown_step_returns_404() {
1207        let state = test_state();
1208        let posted = crate::tasks_start(State(state.clone()), Json(greeting_task_req("hi")))
1209            .await
1210            .expect("tasks_start")
1211            .0;
1212        let err = step_get(
1213            State(state),
1214            Path((
1215                posted.task_id.to_string(),
1216                "latest".to_string(),
1217                "does-not-exist".to_string(),
1218            )),
1219            Query(StepPathQuery::default()),
1220        )
1221        .await
1222        .expect_err("unknown step must 404");
1223        assert_eq!(err.status, StatusCode::NOT_FOUND);
1224    }
1225
1226    // ─── Test 14: the old /ctx route is gone ────────────────────────────────
1227
1228    #[tokio::test]
1229    async fn old_ctx_route_returns_404_not_found_by_router() {
1230        let engine = Engine::new(EngineCfg::default());
1231        let router = mlua_swarm_server_router_for_test(engine);
1232        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
1233            .await
1234            .expect("bind ephemeral port");
1235        let addr = listener.local_addr().expect("local addr");
1236        tokio::spawn(async move {
1237            let _ = axum::serve(listener, router).await;
1238        });
1239        let client = reqwest::Client::new();
1240        let resp = client
1241            .get(format!("http://{addr}/v1/tasks/T-anything/ctx"))
1242            .send()
1243            .await
1244            .expect("request");
1245        assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND);
1246    }
1247
1248    /// Local alias so the test above reads as "the crate's router", without
1249    /// importing `crate::build_router` under a name that shadows this
1250    /// module's own items.
1251    fn mlua_swarm_server_router_for_test(engine: Engine) -> axum::Router {
1252        crate::build_router(engine)
1253    }
1254
1255    // ─── McpQueryAdapter: single-key resolve (still exercised standalone) ───
1256
1257    #[test]
1258    fn mcp_query_adapter_project_builds_query_ref() {
1259        let adapter = McpQueryAdapter::new(
1260            Arc::new(InMemoryOutputStore::new()),
1261            Arc::new(InMemoryRunStore::new()),
1262        );
1263        let key = ProjectionKey {
1264            task_id: "T-abc".to_string(),
1265            run_id: None,
1266            step: Some("planner".to_string()),
1267            path: None,
1268        };
1269        let ctx_data = json!({"planner": {"plan": "do it"}});
1270        let reference = adapter.project(&key, &ctx_data).expect("project");
1271        match &reference {
1272            ProjectionRef::Query { endpoint, key: k } => {
1273                assert!(endpoint.contains("/steps/planner/content"));
1274                assert_eq!(k, &key);
1275            }
1276            other => panic!("expected Query ref, got {other:?}"),
1277        }
1278        let line = adapter.pointer_line(&reference);
1279        assert!(line.contains("T-abc"));
1280    }
1281
1282    #[test]
1283    fn mcp_query_adapter_project_rejects_key_not_present_in_ctx_data() {
1284        let adapter = McpQueryAdapter::new(
1285            Arc::new(InMemoryOutputStore::new()),
1286            Arc::new(InMemoryRunStore::new()),
1287        );
1288        let key = ProjectionKey {
1289            task_id: "T-abc".to_string(),
1290            run_id: None,
1291            step: Some("missing".to_string()),
1292            path: None,
1293        };
1294        let err = adapter.project(&key, &json!({"planner": {}})).unwrap_err();
1295        assert!(matches!(err, ProjectionError::NotFound(_)));
1296    }
1297
1298    #[tokio::test(flavor = "multi_thread")]
1299    async fn mcp_query_adapter_fetch_bridges_to_resolve_async() {
1300        let state = test_state();
1301        let posted = crate::tasks_start(State(state.clone()), Json(greeting_task_req("bridged")))
1302            .await
1303            .expect("tasks_start")
1304            .0;
1305
1306        let adapter = McpQueryAdapter::new(state.data_store.clone(), state.run_store.clone());
1307        let key = ProjectionKey {
1308            task_id: posted.task_id.to_string(),
1309            run_id: None,
1310            step: Some("out".to_string()),
1311            path: Some("echoed".to_string()),
1312        };
1313        // `fetch` is a sync trait method that bridges to `resolve_async`
1314        // via `block_in_place` + `Handle::block_on` — calling it directly
1315        // (not via `spawn_blocking`, which runs on the *blocking* pool
1316        // rather than a runtime worker thread and is not a valid
1317        // `block_in_place` call site) from this multi-thread-flavor test
1318        // task is exactly the context the bridge is built for (module
1319        // doc).
1320        let value = adapter.fetch(&key).expect("fetch");
1321        assert_eq!(value, json!("bridged"));
1322    }
1323
1324    // ─── subtask-4 / ST2 rework: Data-plane-backed, in-flight-safe query ───
1325
1326    /// Subtask 4 Test #5: path narrowing works against the Data-plane
1327    /// `Final` content — `AG_IDENTITY`'s own name (the producer_agent
1328    /// `Engine::submit_output`'s dual-write submits under; distinct from
1329    /// `greeting_blueprint`'s flow.ir ctx-path segment `"out"`, which is
1330    /// what `mcp_query_adapter_fetch_bridges_to_resolve_async` and its
1331    /// siblings above still exercise via the `result_ref` fallback) is
1332    /// queryable directly against the Data-plane store, narrowed by
1333    /// `path`.
1334    #[tokio::test]
1335    async fn resolve_async_path_narrows_within_data_plane_final_content() {
1336        let state = test_state();
1337        let posted = crate::tasks_start(State(state.clone()), Json(greeting_task_req("hi")))
1338            .await
1339            .expect("tasks_start")
1340            .0;
1341
1342        let adapter = McpQueryAdapter::new(state.data_store.clone(), state.run_store.clone());
1343        let key = ProjectionKey {
1344            task_id: posted.task_id.to_string(),
1345            run_id: None,
1346            step: Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string()),
1347            path: Some("echoed".to_string()),
1348        };
1349        let (_run, value) = adapter.resolve_async(&key).await.expect("resolve_async");
1350        assert_eq!(value, json!("hi"));
1351    }
1352
1353    /// Subtask 4 Test #1 (the in-flight scenario this rework exists for):
1354    /// a 2-step `Seq` flow where `step2` blocks on a gate until the test
1355    /// releases it. By the time `step2` has started, `step1`'s
1356    /// `dispatch_attempt_with` — and therefore its `submit_output` (and
1357    /// this rework's dual-write into the Data-plane store), plus its
1358    /// `RunRecord.step_entries` append — has unconditionally already
1359    /// completed (flow.ir's `Seq` awaits each child before starting the
1360    /// next), while the overall Run is still `Running` (not yet
1361    /// finalized). `GET /v1/tasks/:id/runs/:run/steps/step1` must return
1362    /// `step1`'s OUTPUT during that window.
1363    #[tokio::test(flavor = "multi_thread")]
1364    async fn steps_list_returns_in_flight_step_output_before_run_completes() {
1365        use mlua_flow_ir::{Expr, Node as FlowNode};
1366        use mlua_swarm::worker::adapter::WorkerResult;
1367        use mlua_swarm::{RustFnInProcessSpawnerFactory, SpawnerRegistry};
1368
1369        let started = Arc::new(tokio::sync::Notify::new());
1370        let gate = Arc::new(tokio::sync::Notify::new());
1371        let started_bg = started.clone();
1372        let gate_bg = gate.clone();
1373
1374        let factory = RustFnInProcessSpawnerFactory::new()
1375            .register_fn("step1", |inv| async move {
1376                Ok(WorkerResult {
1377                    value: json!({ "step1_out": inv.prompt }),
1378                    ok: true,
1379                })
1380            })
1381            .register_fn("step2", move |_inv| {
1382                let started = started_bg.clone();
1383                let gate = gate_bg.clone();
1384                async move {
1385                    started.notify_one();
1386                    gate.notified().await;
1387                    Ok(WorkerResult {
1388                        value: json!("step2 done"),
1389                        ok: true,
1390                    })
1391                }
1392            });
1393        let mut reg = SpawnerRegistry::new();
1394        reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
1395
1396        let engine = Engine::new_with_layers(EngineCfg::default(), crate::default_layer_registry());
1397        let data_store: Arc<dyn mlua_swarm::store::output::OutputStore> =
1398            Arc::new(InMemoryOutputStore::new());
1399        engine.set_output_store(data_store.clone());
1400        let compiler = mlua_swarm::Compiler::new(reg);
1401        let launch = Arc::new(mlua_swarm::TaskLaunchService::new(engine.clone(), compiler));
1402        let state = AppState {
1403            engine,
1404            sessions: Arc::new(Mutex::new(crate::SessionStore::default())),
1405            task_app: Arc::new(mlua_swarm::TaskApplication::new_inline_only(launch)),
1406            ws_operator_factory: None,
1407            data_store,
1408            operator_sessions: Arc::new(Mutex::new(HashMap::new())),
1409            roles_to_sid: Arc::new(Mutex::new(HashMap::new())),
1410            task_store: Arc::new(InMemoryTaskStore::new()),
1411            run_store: Arc::new(InMemoryRunStore::new()),
1412            base_url: None,
1413        };
1414
1415        let flow = FlowNode::Seq {
1416            children: vec![
1417                FlowNode::Step {
1418                    ref_: "step1".to_string(),
1419                    in_: Expr::Path {
1420                        at: "$.greeting".to_string(),
1421                    },
1422                    out: Expr::Path {
1423                        at: "$.step1".to_string(),
1424                    },
1425                },
1426                FlowNode::Step {
1427                    ref_: "step2".to_string(),
1428                    in_: Expr::Path {
1429                        at: "$.step1".to_string(),
1430                    },
1431                    out: Expr::Path {
1432                        at: "$.step2".to_string(),
1433                    },
1434                },
1435            ],
1436        };
1437        let blueprint = Blueprint {
1438            schema_version: current_schema_version(),
1439            id: "projection-test-in-flight-bp".into(),
1440            flow,
1441            agents: vec![
1442                AgentDef {
1443                    name: "step1".into(),
1444                    kind: AgentKind::RustFn,
1445                    spec: json!({"fn_id": "step1"}),
1446                    profile: None,
1447                    meta: None,
1448                },
1449                AgentDef {
1450                    name: "step2".into(),
1451                    kind: AgentKind::RustFn,
1452                    spec: json!({"fn_id": "step2"}),
1453                    profile: None,
1454                    meta: None,
1455                },
1456            ],
1457            operators: vec![],
1458            metas: vec![],
1459            hints: CompilerHints::default(),
1460            strategy: CompilerStrategy::default(),
1461            metadata: BlueprintMetadata::default(),
1462            spawner_hints: Default::default(),
1463            default_agent_kind: AgentKind::Operator,
1464            default_operator_kind: None,
1465            default_init_ctx: None,
1466            default_agent_ctx: None,
1467            default_context_policy: None,
1468        };
1469
1470        let req = TaskLaunchRequest {
1471            blueprint: BlueprintRef::Inline {
1472                value: Box::new(blueprint),
1473            },
1474            init_ctx: json!({ "greeting": "hi" }),
1475            project_root: None,
1476            work_dir: None,
1477            task_metadata: None,
1478            ttl_secs: None,
1479            operator: None,
1480            operator_sid: None,
1481            goal: None,
1482        };
1483
1484        let state_bg = state.clone();
1485        let launch_handle =
1486            tokio::spawn(async move { crate::tasks_start(State(state_bg), Json(req)).await });
1487
1488        // step2 signals `started` only after step1's dispatch (and its
1489        // submit_output / Data-plane dual-write, and its step_entries
1490        // append) has fully returned — see the doc above.
1491        started.notified().await;
1492
1493        let in_flight_tasks = state.task_store.list().await.expect("task_store list");
1494        assert_eq!(in_flight_tasks.len(), 1, "exactly one Task minted");
1495        let task_id = in_flight_tasks[0].id.clone();
1496
1497        let resp = steps_list(
1498            State(state.clone()),
1499            Path((task_id.to_string(), "latest".to_string())),
1500        )
1501        .await
1502        .expect("steps_list while step2 is still in flight");
1503        let step1_entry = resp
1504            .steps
1505            .iter()
1506            .find(|s| s.name == "step1")
1507            .expect("step1 must already be visible");
1508        assert_eq!(step1_entry.source, ProjectionSource::DataPlane);
1509
1510        // Release step2 so the background `tasks_start` can complete and
1511        // the test can join it cleanly.
1512        gate.notify_one();
1513        let posted = launch_handle.await.expect("join").expect("tasks_start").0;
1514        assert_eq!(posted.final_ctx["step2"], json!("step2 done"));
1515    }
1516}