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//! # GH #23 subtask-3: table-driven addressing (replaces the runtime union
19//! rule)
20//!
21//! Both consumers share [`McpQueryAdapter::list_steps`]'s enumeration.
22//! Previously every distinct `step_ref` name in `RunRecord.step_entries`
23//! was resolved through the Data-plane `OutputStore`, **unioned** with
24//! `RunRecord.result_ref`'s top-level object keys (the finalized-Run
25//! fallback), Data-plane winning a name collision — the pre-GH-#23 runtime
26//! union rule. That rule is now statically replaced: every real
27//! `Compiler::compile` output carries a
28//! [`mlua_swarm::core::step_naming::StepNaming`] table (built once, at
29//! compile time — see that module's doc), and this module's enumeration /
30//! single-key resolution ([`Self::enumerate_steps`] /
31//! [`Self::resolve_async`]) look the table up via
32//! `Engine::step_naming_for` and report every step under its ONE canonical
33//! name, addressable by that name OR any alias (`Step.ref` / the `out`
34//! ctx-path's top-level segment). The runtime union / collision-priority
35//! logic itself no longer runs per-request; it is baked into the table
36//! once, at register time (`StepNaming::from_blueprint`).
37//!
38//! [`Self::enumerate_steps_legacy_union`] keeps the OLD runtime-union body
39//! verbatim as a **defensive-only** fallback for the rare case no table
40//! resolves (a spawn stack the dispatcher never wired
41//! `EngineDispatcher::with_step_naming` for — certain test harnesses that
42//! seed `OutputStore`/`RunStore` fixtures directly without driving a real
43//! dispatch); it is not a "declared Blueprints get the new path, undeclared
44//! ones keep the old one" branch — undeclared Blueprints get the SAME
45//! table-driven path (their canonical name is simply their own `Step.ref`,
46//! byte-identical to the pre-GH-#23 name).
47//!
48//! # Architecture (subtask-4 rework, carried into ST5, table-driven since
49//! subtask-3)
50//!
51//! [`McpQueryAdapter`] reads through **two** backings, tried in order, for
52//! every step's OWN dispatch (`RunRecord.step_entries` row → its own
53//! `StepId`):
54//!
55//! 1. **Data-plane, in-flight-safe AND Run-scoped** (subtask-4's original
56//!    reason for being; Run-scoped since subtask-3 — see the former KNOWN
57//!    LIMITATION below): [`McpQueryAdapter::resolve_async`] /
58//!    [`Self::enumerate_steps_via_table`] look up
59//!    `OutputStore::get_latest_by_name_in_run(step_entry.step_id, 1,
60//!    canonical_name)` — the same store `Engine::submit_output`'s
61//!    submit-time projection sink dual-writes into (see
62//!    `mlua_swarm::core::engine::Engine::submit_output`'s doc), keyed
63//!    Run-scoped by construction (a `StepId` is globally unique per
64//!    dispatch, so two concurrent Runs sharing a producer name never
65//!    cross-resolve — no narrowing-by-guard needed any more). A hit here
66//!    can be a **not-yet-finalized** Run's already-submitted step — the
67//!    in-flight case subtask-4 exists for.
68//! 2. **Persisted `RunRecord.result_ref` fallback** (unchanged in kind,
69//!    now tried under the canonical name AND every alias): used whenever
70//!    (1) comes back empty (no Data-plane record for that step's own
71//!    dispatch yet — e.g. a Run that predates the engine having an
72//!    `OutputStore` wired).
73//!
74//! Unlike `crate::operator_ws::session`'s spawn-time
75//! [`mlua_swarm::core::projection::FileProjectionAdapter`] hook (which
76//! materializes the *spawning* agent's own `AgentContextView`), this
77//! adapter's Data-plane path serves **prior steps'** submitted OUTPUT —
78//! the pull-supply counterpart to `Engine`'s submit-time file sink.
79//!
80//! ## Former KNOWN LIMITATION (closed by GH #23 subtask-3)
81//!
82//! `OutputStore::get_latest_by_name` is producer-name-scoped, not
83//! Run-scoped (see `mlua_swarm::store::output`'s module doc) — it returns
84//! the single newest `Final` submitted anywhere under that producer name,
85//! across every Run / Task, so two *concurrent* Runs whose flow.ir happens
86//! to dispatch an agent of the identical name could race each other. This
87//! module no longer calls that method: every lookup here goes through
88//! `OutputStore::get_latest_by_name_in_run`, scoped to the dispatching
89//! step's own (globally unique) `StepId`, closing the race by
90//! construction, independent of whether the Blueprint declared a
91//! `projection_name` (see
92//! `mlua_swarm::store::output::OutputStore::get_latest_by_name_in_run`'s
93//! doc). `get_latest_by_name` itself is untouched (still used by
94//! `Engine::submit_output`'s own fail-open cross-Run compatibility path)
95//! — only this module's consumption of it changed.
96//!
97//! [`ProjectionAdapter::fetch`] is a synchronous trait method, but this
98//! adapter's backing stores are async. [`McpQueryAdapter::resolve_async`]
99//! is the real, native-async implementation; [`step_content`] (the
100//! content-plane HTTP handler) calls [`McpQueryAdapter::list_steps`]
101//! directly. [`ProjectionAdapter::fetch`] instead bridges to
102//! [`McpQueryAdapter::resolve_async`] via `tokio::task::block_in_place` +
103//! `Handle::block_on` purely for trait conformance (dependency inversion —
104//! this adapter implements the same `core::projection::ProjectionAdapter`
105//! trait [`mlua_swarm::core::projection::FileProjectionAdapter`] does, so a
106//! caller holding a `dyn ProjectionAdapter` can use either
107//! polymorphically); the hot HTTP path never takes that bridge.
108
109use axum::{
110    extract::{Path, Query, State},
111    http::{header, HeaderMap, HeaderValue, StatusCode},
112    response::IntoResponse,
113    Json,
114};
115use mlua_swarm::core::engine::Engine;
116use mlua_swarm::core::projection::{
117    ProjectionAdapter, ProjectionError, ProjectionKey, ProjectionRef,
118};
119use mlua_swarm::core::projection_placement::ProjectionPlacement;
120use mlua_swarm::core::step_naming::StepNaming;
121use mlua_swarm::store::output::{ContentRef, OutputEvent, OutputStore, OutputStoreError};
122use mlua_swarm::store::run::{RunRecord, RunStore};
123use mlua_swarm::{RunId, StepId, TaskId};
124use serde::{Deserialize, Serialize};
125use serde_json::Value;
126use sha2::Digest as _;
127use std::sync::Arc;
128
129use crate::tasks::map_task_store_err;
130use crate::{ApiError, AppState};
131
132/// Server-side [`ProjectionAdapter`] backed by an [`OutputStore`]
133/// (in-flight-safe, subtask-4, Run-scoped since GH #23 subtask-3) with a
134/// [`RunStore`]-backed `result_ref` fallback (see the module doc for the
135/// full narrative). Holds an [`Engine`] handle (GH #23 subtask-3) so
136/// [`Self::step_naming_for_run`] can pull the Blueprint-wide
137/// [`StepNaming`] table `Engine::step_naming_for` snapshotted at dispatch
138/// time.
139pub struct McpQueryAdapter {
140    data_store: Arc<dyn OutputStore>,
141    run_store: Arc<dyn RunStore>,
142    engine: Engine,
143}
144
145/// Which backing produced a [`StepSummary`] / a Worker-axis `StepPointer`
146/// — Data-plane wins a name collision (module doc's "Architecture"
147/// section).
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
149#[serde(rename_all = "snake_case")]
150pub enum ProjectionSource {
151    /// Resolved via the in-flight-safe `OutputStore::get_latest_by_name`
152    /// path.
153    DataPlane,
154    /// Resolved via the persisted `RunRecord.result_ref` fallback (the Run
155    /// has finalized, or the name only ever existed there).
156    ResultRef,
157}
158
159/// One step's resolved OUTPUT value plus its provenance — the shared
160/// enumeration result [`McpQueryAdapter::list_steps`] returns, consumed by
161/// both this module's HTTP handlers and
162/// `crates/mlua-swarm-server/src/worker.rs`'s Worker-axis pointer
163/// assembly.
164#[derive(Debug, Clone)]
165pub(crate) struct ResolvedStep {
166    /// The producing step's name (`RunRecord.step_entries[].step_ref`, or
167    /// a `RunRecord.result_ref` top-level key).
168    pub(crate) name: String,
169    /// The resolved OUTPUT value (not yet path-narrowed).
170    pub(crate) value: Value,
171    /// Which backing produced this entry.
172    pub(crate) source: ProjectionSource,
173}
174
175/// Extracts a JSON value out of an [`OutputEvent`]'s content, when the
176/// event is a `Final` (anything else — `Progress` / `Partial` / `Artifact`
177/// sharing the same producer name via the separate `POST /v1/data/emit`
178/// axis — is not a submission this adapter serves, so callers treat
179/// `None` the same as "no record").
180fn final_value(event: &OutputEvent) -> Option<Value> {
181    match event {
182        OutputEvent::Final { content, .. } => Some(content_to_value(content)),
183        _ => None,
184    }
185}
186
187/// Renders a [`ContentRef`] down to a plain [`Value`] — `Inline` passes
188/// its value through verbatim; `FileRef` (large / binary content) becomes
189/// a small locator object (this adapter's `v1` scope does not read the
190/// file back, matching subtask-4's spec: "locator 返却で可").
191fn content_to_value(content: &ContentRef) -> Value {
192    match content {
193        ContentRef::Inline { value } => value.clone(),
194        ContentRef::FileRef {
195            path,
196            mime,
197            size_hint,
198        } => serde_json::json!({
199            "file_ref": path.to_string_lossy(),
200            "mime": mime,
201            "size_hint": size_hint,
202        }),
203    }
204}
205
206/// GH #23 subtask-3: finds the [`StepId`] of the `run.step_entries` row
207/// whose canonical name (via `naming.canonical_of_producer`, or the raw
208/// `step_ref` unchanged when `naming` is `None`) equals `canonical`. Tried
209/// most-recent-first (`.rev()`) so a step re-dispatched under the same ref
210/// (e.g. inside a Loop) resolves to its LATEST occurrence within this
211/// Run — matching [`resolve_materialized_file`]'s own "only tries `attempt
212/// = 1`" convention, this helper does not attempt to disambiguate
213/// multiple attempts of the SAME occurrence, only multiple occurrences.
214fn find_step_id_for_canonical(
215    run: &RunRecord,
216    naming: Option<&StepNaming>,
217    canonical: &str,
218) -> Option<StepId> {
219    run.step_entries
220        .iter()
221        .rev()
222        .find(|entry| {
223            let Some(step_ref) = entry.step_ref.as_deref() else {
224                return false;
225            };
226            match naming {
227                Some(n) => n.canonical_of_producer(step_ref) == Some(canonical),
228                None => step_ref == canonical,
229            }
230        })
231        .map(|entry| entry.step_id.clone())
232}
233
234/// GH #23 subtask-3: every name worth trying against `RunRecord.result_ref`
235/// for `canonical` — the canonical name itself, every alias `naming`
236/// records for it (when `naming` resolves an entry), and `raw_step` (the
237/// original, un-canonicalized query) as a final fail-open fallback for
238/// when `naming` is `None` entirely or does not carry an entry for
239/// `canonical` (defensive-only — see [`McpQueryAdapter::step_naming_for_run`]'s
240/// doc). Order matters only for determinism (canonical first); a
241/// `result_ref` object has at most one of these keys present.
242fn candidate_names<'a>(
243    naming: Option<&'a StepNaming>,
244    canonical: &'a str,
245    raw_step: &'a str,
246) -> Vec<&'a str> {
247    let mut names = vec![canonical];
248    if let Some(entry) = naming.and_then(|n| n.entries().find(|e| e.canonical == canonical)) {
249        for alias in &entry.aliases {
250            if alias != canonical {
251                names.push(alias.as_str());
252            }
253        }
254    }
255    if !names.contains(&raw_step) {
256        names.push(raw_step);
257    }
258    names
259}
260
261impl McpQueryAdapter {
262    /// Builds an adapter reading through `data_store` (in-flight-safe,
263    /// Run-scoped, tried first) with `run_store`-backed `result_ref`
264    /// fallback, and `engine` for the GH #23 subtask-3 `StepNaming` table
265    /// lookup.
266    pub fn new(
267        data_store: Arc<dyn OutputStore>,
268        run_store: Arc<dyn RunStore>,
269        engine: Engine,
270    ) -> Self {
271        Self {
272            data_store,
273            run_store,
274            engine,
275        }
276    }
277
278    /// GH #23 subtask-3: resolves the Blueprint-wide [`StepNaming`] table
279    /// for `run` by trying each of its `step_entries`' own `StepId` via
280    /// `Engine::step_naming_for` until one resolves — every dispatched
281    /// step of one Blueprint launch shares the SAME `Arc`, snapshotted
282    /// under every one of their own ids at dispatch time (see
283    /// [`StepNaming`]'s module doc), so any entry's id suffices. `None`
284    /// when the Run has no `step_entries` yet, or none of them resolve
285    /// (a spawn stack that never called
286    /// `EngineDispatcher::with_step_naming` — pre-GH-#23 callers / test
287    /// harnesses that seed `OutputStore`/`RunStore` fixtures directly) —
288    /// callers fall back to [`Self::enumerate_steps_legacy_union`] in
289    /// that case (defensive-only; see the module doc). Delegates to the
290    /// free-function sibling [`resolve_step_naming_for_run`] (shared with
291    /// [`resolve_materialized_file`], which has no `McpQueryAdapter`
292    /// handle).
293    async fn step_naming_for_run(&self, run: &RunRecord) -> Option<Arc<StepNaming>> {
294        resolve_step_naming_for_run(&self.engine, run).await
295    }
296
297    /// GH #23 subtask-3: canonicalizes `raw` (a REST `:step` path segment
298    /// — the canonical name itself, or any alias) against `run`'s
299    /// [`StepNaming`] table, so `step_get` / `step_content` can find the
300    /// matching [`ResolvedStep`] by its (always-canonical) `name` — see
301    /// [`Self::enumerate_steps`]. Returns `raw` unchanged when no table
302    /// resolves for this Run (fail-open, matching
303    /// [`Self::step_naming_for_run`]'s own defensive fallback).
304    pub(crate) async fn resolve_step_name(&self, run: &RunRecord, raw: &str) -> String {
305        match self.step_naming_for_run(run).await {
306            Some(naming) => naming.resolve(raw).unwrap_or(raw).to_string(),
307            None => raw.to_string(),
308        }
309    }
310
311    /// Selects the Run `task_id` + `run_id` address: `run_id` when
312    /// `Some`, otherwise the most recently created Run for `task_id`
313    /// ([`RunStore::list_by_task`] returns oldest-created-first, so its
314    /// last element is the latest). [`ProjectionError::NotFound`] covers
315    /// every "nothing here" case uniformly: an unparseable `run_id`, an
316    /// unknown Run, a `run_id` that names a Run belonging to a *different*
317    /// Task, or a Task with no Runs yet.
318    async fn resolve_run(
319        &self,
320        task_id: &TaskId,
321        run_id: Option<&str>,
322    ) -> Result<RunRecord, ProjectionError> {
323        match run_id {
324            Some(rid) => {
325                let run_id = RunId::parse(rid.to_string())
326                    .map_err(|e| ProjectionError::InvalidKey(format!("run_id: {e}")))?;
327                let run = self.run_store.get(&run_id).await.map_err(|_| {
328                    ProjectionError::NotFound(ProjectionKey {
329                        task_id: task_id.to_string(),
330                        run_id: Some(rid.to_string()),
331                        step: None,
332                        path: None,
333                    })
334                })?;
335                if &run.task_id != task_id {
336                    return Err(ProjectionError::NotFound(ProjectionKey {
337                        task_id: task_id.to_string(),
338                        run_id: Some(rid.to_string()),
339                        step: None,
340                        path: None,
341                    }));
342                }
343                Ok(run)
344            }
345            None => {
346                let mut runs = self.run_store.list_by_task(task_id).await.map_err(|_| {
347                    ProjectionError::NotFound(ProjectionKey {
348                        task_id: task_id.to_string(),
349                        run_id: None,
350                        step: None,
351                        path: None,
352                    })
353                })?;
354                runs.pop().ok_or_else(|| {
355                    ProjectionError::NotFound(ProjectionKey {
356                        task_id: task_id.to_string(),
357                        run_id: None,
358                        step: None,
359                        path: None,
360                    })
361                })
362            }
363        }
364    }
365
366    /// The real, native-async single-key resolve: selects the Run `key`
367    /// addresses via [`Self::resolve_run`], then resolves the value —
368    /// Data-plane first (in-flight-safe, Run-scoped since GH #23
369    /// subtask-3), falling back to the selected Run's persisted
370    /// `result_ref` — see the module doc's Architecture section. Returns
371    /// the selected [`RunRecord`] alongside the resolved value so a
372    /// caller can report which Run actually served the projection, even
373    /// when the caller only supplied `task_id`.
374    ///
375    /// GH #23 subtask-3: `key.step` (canonical name OR any alias) is
376    /// canonicalized against `run`'s [`StepNaming`] table BEFORE either
377    /// lookup — the pre-subtask-3 `key.run_id.is_none()` guard (which
378    /// narrowed the Data-plane path to only in-flight fetches, hedging
379    /// against the cross-Run race the former KNOWN LIMITATION described)
380    /// is gone: the Data-plane lookup is now `get_latest_by_name_in_run`,
381    /// scoped to the resolving step's own globally-unique `StepId`, so an
382    /// explicit `run_id` pin is exactly as race-free as the in-flight
383    /// case — narrowing which calls attempt it bought nothing once the
384    /// lookup itself is Run-scoped.
385    async fn resolve_async(
386        &self,
387        key: &ProjectionKey,
388    ) -> Result<(RunRecord, Value), ProjectionError> {
389        let task_id = TaskId::parse(key.task_id.clone())
390            .map_err(|e| ProjectionError::InvalidKey(format!("task_id: {e}")))?;
391        let run = self.resolve_run(&task_id, key.run_id.as_deref()).await?;
392
393        let Some(raw_step) = &key.step else {
394            // `key.step` is `None` — whole-ctx addressing, no step name to
395            // canonicalize.
396            let ctx_data = run.result_ref.clone().unwrap_or(Value::Null);
397            let value = key
398                .resolve(&ctx_data)
399                .cloned()
400                .ok_or_else(|| ProjectionError::NotFound(key.clone()))?;
401            return Ok((run, value));
402        };
403
404        let naming = self.step_naming_for_run(&run).await;
405        let canonical = naming
406            .as_deref()
407            .and_then(|n| n.resolve(raw_step))
408            .unwrap_or(raw_step.as_str())
409            .to_string();
410
411        // Data-plane, in-flight-safe, Run-scoped path.
412        if let Some(step_id) = find_step_id_for_canonical(&run, naming.as_deref(), &canonical) {
413            match self
414                .data_store
415                .get_latest_by_name_in_run(step_id.as_str(), 1, &canonical)
416                .await
417            {
418                Ok(record) => {
419                    if let Some(value) = final_value(&record.event) {
420                        let narrowed = match &key.path {
421                            None => Some(value),
422                            Some(_) => {
423                                // Reuse `ProjectionKey::resolve`'s path-walk
424                                // only (the step lookup is already done —
425                                // this value IS the step's own content, not
426                                // a `{step: value}` map to look `step` up
427                                // in again).
428                                let path_only = ProjectionKey {
429                                    task_id: key.task_id.clone(),
430                                    run_id: key.run_id.clone(),
431                                    step: None,
432                                    path: key.path.clone(),
433                                };
434                                path_only.resolve(&value).cloned()
435                            }
436                        };
437                        if let Some(value) = narrowed {
438                            return Ok((run, value));
439                        }
440                    }
441                }
442                Err(OutputStoreError::NotFound(_)) => {
443                    // No Data-plane record for this step's own dispatch —
444                    // fall through to the result_ref fallback below.
445                }
446                Err(other) => {
447                    return Err(ProjectionError::Io(std::io::Error::other(format!(
448                        "OutputStore::get_latest_by_name_in_run: {other}"
449                    ))));
450                }
451            }
452        }
453
454        // Fallback: the persisted `result_ref`, tried under the canonical
455        // name AND every alias (`result_ref` keys are still the raw
456        // flow.ir ctx-path segment — an alias, not necessarily the
457        // canonical name).
458        let ctx_data = run.result_ref.clone().unwrap_or(Value::Null);
459        for candidate in candidate_names(naming.as_deref(), &canonical, raw_step) {
460            let candidate_key = ProjectionKey {
461                task_id: key.task_id.clone(),
462                run_id: key.run_id.clone(),
463                step: Some(candidate.to_string()),
464                path: key.path.clone(),
465            };
466            if let Some(value) = candidate_key.resolve(&ctx_data) {
467                return Ok((run, value.clone()));
468            }
469        }
470        Err(ProjectionError::NotFound(key.clone()))
471    }
472
473    /// Enumerates every step visible for the Run addressed by `task_id` +
474    /// `run_id` (`None` = latest) — the shared enumeration both this
475    /// module's HTTP handlers and the Worker axis's pointer assembly
476    /// build from (module doc). Returns the selected [`RunRecord`]
477    /// alongside the resolved steps.
478    pub(crate) async fn list_steps(
479        &self,
480        task_id: &TaskId,
481        run_id: Option<&str>,
482    ) -> Result<(RunRecord, Vec<ResolvedStep>), ProjectionError> {
483        let run = self.resolve_run(task_id, run_id).await?;
484        let steps = self.enumerate_steps(&run).await;
485        Ok((run, steps))
486    }
487
488    /// Same enumeration as [`Self::list_steps`], addressed directly by an
489    /// already-known [`RunId`] (no `task_id` cross-check, no `"latest"`
490    /// ambiguity) — the Worker axis's entry point
491    /// (`crates/mlua-swarm-server/src/worker.rs`), which already has the
492    /// exact Run its own `AgentContextView.run_id` names, from
493    /// `Ctx.meta.runtime[RUN_ID_KEY]` (threaded through by
494    /// `Engine::dispatch_attempt_with`).
495    pub(crate) async fn list_steps_by_run_id(
496        &self,
497        run_id: &RunId,
498    ) -> Result<(RunRecord, Vec<ResolvedStep>), ProjectionError> {
499        let run = self.run_store.get(run_id).await.map_err(|_| {
500            ProjectionError::NotFound(ProjectionKey {
501                task_id: String::new(),
502                run_id: Some(run_id.to_string()),
503                step: None,
504                path: None,
505            })
506        })?;
507        let steps = self.enumerate_steps(&run).await;
508        Ok((run, steps))
509    }
510
511    /// GH #23 subtask-3: dispatches to the table-driven enumeration
512    /// ([`Self::enumerate_steps_via_table`]) when a [`StepNaming`] table
513    /// resolves for `run`, else the defensive-only
514    /// [`Self::enumerate_steps_legacy_union`] fallback — see the module
515    /// doc's "table-driven addressing" section and
516    /// [`Self::step_naming_for_run`]'s doc for when the fallback fires.
517    async fn enumerate_steps(&self, run: &RunRecord) -> Vec<ResolvedStep> {
518        match self.step_naming_for_run(run).await {
519            Some(naming) => self.enumerate_steps_via_table(run, &naming).await,
520            None => self.enumerate_steps_legacy_union(run).await,
521        }
522    }
523
524    /// GH #23 subtask-3: the table-driven replacement for the runtime
525    /// union rule. For every `run.step_entries` row, resolves that row's
526    /// own `Step.ref` to its canonical name via
527    /// `naming.canonical_of_producer` (falling back to the raw ref when
528    /// the table has no entry for it — defensive-only, mirrors this
529    /// module's other best-effort hooks), then queries the Run-scoped
530    /// `OutputStore::get_latest_by_name_in_run` keyed by THIS row's own
531    /// `StepId` — globally unique, so no cross-Run bleed by construction,
532    /// closing the former KNOWN LIMITATION race regardless of whether the
533    /// Blueprint declared a `projection_name`. A canonical name spanning
534    /// multiple `step_entries` rows (a step re-dispatched under the same
535    /// ref, e.g. inside a Loop) keeps the LATEST successfully-resolved
536    /// occurrence (later rows overwrite earlier ones in `resolved`; a row
537    /// whose own lookup comes up empty never blanks an earlier
538    /// occurrence's already-resolved value). Names still unresolved after
539    /// every occurrence is tried fall back to `run.result_ref`, matched
540    /// against every alias (or the canonical name itself) — `result_ref`
541    /// keys are still the raw flow.ir ctx-path segment.
542    async fn enumerate_steps_via_table(
543        &self,
544        run: &RunRecord,
545        naming: &StepNaming,
546    ) -> Vec<ResolvedStep> {
547        let mut resolved: std::collections::BTreeMap<String, ResolvedStep> =
548            std::collections::BTreeMap::new();
549
550        for entry in &run.step_entries {
551            let Some(step_ref) = entry.step_ref.as_deref() else {
552                continue;
553            };
554            let canonical = naming
555                .canonical_of_producer(step_ref)
556                .unwrap_or(step_ref)
557                .to_string();
558            if let Ok(record) = self
559                .data_store
560                .get_latest_by_name_in_run(entry.step_id.as_str(), 1, &canonical)
561                .await
562            {
563                if let Some(value) = final_value(&record.event) {
564                    resolved.insert(
565                        canonical.clone(),
566                        ResolvedStep {
567                            name: canonical,
568                            value,
569                            source: ProjectionSource::DataPlane,
570                        },
571                    );
572                }
573            }
574        }
575
576        if let Some(Value::Object(map)) = &run.result_ref {
577            for entry in naming.entries() {
578                if resolved.contains_key(&entry.canonical) {
579                    continue;
580                }
581                let hit = entry
582                    .aliases
583                    .iter()
584                    .find_map(|alias| map.get(alias))
585                    .or_else(|| map.get(&entry.canonical));
586                if let Some(value) = hit {
587                    resolved.insert(
588                        entry.canonical.clone(),
589                        ResolvedStep {
590                            name: entry.canonical.clone(),
591                            value: value.clone(),
592                            source: ProjectionSource::ResultRef,
593                        },
594                    );
595                }
596            }
597        }
598
599        resolved.into_values().collect()
600    }
601
602    /// Pre-GH-#23 defensive-only fallback: the ORIGINAL runtime union rule
603    /// (raw `Step.ref` name ∪ `result_ref` top-level keys, Data-plane wins
604    /// a collision), used ONLY when [`Self::step_naming_for_run`] resolves
605    /// no [`StepNaming`] table for this Run. NOT a "keep the old path
606    /// around for undeclared Blueprints" hedge — every real
607    /// `Compiler::compile` output always carries a table (see
608    /// [`StepNaming`]'s module doc), so this branch is reached by
609    /// defensive-only callers (test harnesses that seed
610    /// `OutputStore`/`RunStore` fixtures directly, bypassing a real
611    /// dispatch), not by any Blueprint-driven Run.
612    async fn enumerate_steps_legacy_union(&self, run: &RunRecord) -> Vec<ResolvedStep> {
613        let mut out = Vec::new();
614        let mut attempted = std::collections::HashSet::new();
615        let mut resolved_names = std::collections::HashSet::new();
616
617        for entry in &run.step_entries {
618            let Some(name) = &entry.step_ref else {
619                continue;
620            };
621            if !attempted.insert(name.clone()) {
622                continue;
623            }
624            if let Ok(record) = self.data_store.get_latest_by_name(name).await {
625                if let Some(value) = final_value(&record.event) {
626                    out.push(ResolvedStep {
627                        name: name.clone(),
628                        value,
629                        source: ProjectionSource::DataPlane,
630                    });
631                    resolved_names.insert(name.clone());
632                }
633            }
634        }
635
636        if let Some(Value::Object(map)) = &run.result_ref {
637            for (name, value) in map {
638                if resolved_names.contains(name) {
639                    continue;
640                }
641                out.push(ResolvedStep {
642                    name: name.clone(),
643                    value: value.clone(),
644                    source: ProjectionSource::ResultRef,
645                });
646            }
647        }
648
649        out
650    }
651}
652
653impl ProjectionAdapter for McpQueryAdapter {
654    fn name(&self) -> &'static str {
655        "mcp-query"
656    }
657
658    /// `ctx_data` is used only to fail loud up front (mirrors
659    /// [`mlua_swarm::core::projection::FileProjectionAdapter::project`]'s
660    /// own not-found check) — the returned [`ProjectionRef::Query`]
661    /// locator carries `key` itself, not a resolved value; the real lookup
662    /// happens later, at [`Self::fetch`] time, against whatever the
663    /// addressed Run's backing is *then* (which may differ from
664    /// `ctx_data`, e.g. after a re-kick, or once a step submits through
665    /// the Data-plane store).
666    fn project(
667        &self,
668        key: &ProjectionKey,
669        ctx_data: &Value,
670    ) -> Result<ProjectionRef, ProjectionError> {
671        if key.task_id.is_empty() {
672            return Err(ProjectionError::InvalidKey(
673                "task_id must not be empty".to_string(),
674            ));
675        }
676        key.resolve(ctx_data)
677            .ok_or_else(|| ProjectionError::NotFound(key.clone()))?;
678        Ok(ProjectionRef::Query {
679            endpoint: format!(
680                "/v1/tasks/{}/runs/{}/steps/{}/content",
681                key.task_id,
682                key.run_id.as_deref().unwrap_or("latest"),
683                key.step.as_deref().unwrap_or("_ctx")
684            ),
685            key: key.clone(),
686        })
687    }
688
689    fn fetch(&self, key: &ProjectionKey) -> Result<Value, ProjectionError> {
690        // See the module doc: this bridge exists for `ProjectionAdapter`
691        // trait conformance only. `block_in_place` requires the Tokio
692        // multi-thread runtime flavor (the workspace's `tokio` dependency
693        // enables `features = ["full"]`, which includes it).
694        let handle = tokio::runtime::Handle::try_current().map_err(|e| {
695            ProjectionError::Io(std::io::Error::other(format!(
696                "McpQueryAdapter::fetch requires a Tokio runtime: {e}"
697            )))
698        })?;
699        let (_run, value) =
700            tokio::task::block_in_place(|| handle.block_on(self.resolve_async(key)))?;
701        Ok(value)
702    }
703
704    fn pointer_line(&self, r: &ProjectionRef) -> String {
705        match r {
706            ProjectionRef::Query { endpoint, key } => {
707                format!("projection(mcp-query): {endpoint} task_id={}", key.task_id)
708            }
709            ProjectionRef::File { path } => format!("projection(file): {path}"),
710        }
711    }
712}
713
714// ──────────────────────────────────────────────────────────────────────────
715// REST hierarchy: StepList / StepSummary / content plane
716// ──────────────────────────────────────────────────────────────────────────
717
718/// Response body for `GET /v1/tasks/:id/runs/:run/steps`.
719#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
720pub struct StepList {
721    /// The addressed Task.
722    pub task_id: String,
723    /// The Run this list resolved `:run` to (the concrete id, even when
724    /// the request path said `latest`).
725    pub run_id: String,
726    /// Every visible step, unfiltered (the HTTP debug plane serves the
727    /// full union — `ContextPolicy.steps` filtering only applies to the
728    /// Worker axis's `context.steps` pointer list; see the module doc).
729    pub steps: Vec<StepSummary>,
730}
731
732/// One step's metadata (operator / debug plane) — `GET
733/// /v1/tasks/:id/runs/:run/steps/:step`, and each entry of
734/// [`StepList::steps`].
735#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
736pub struct StepSummary {
737    /// The producing step's name.
738    pub name: String,
739    /// Byte length of the body [`Self::content_url`] serves (the exact
740    /// bytes a `GET` of that URL returns for this same `?path=`, if any).
741    pub size_bytes: u64,
742    /// MIME type [`Self::content_url`] serves this body as
743    /// (`text/markdown; charset=utf-8` when materialized-file-backed,
744    /// `application/json` otherwise — see the module doc's Content-Type
745    /// rule).
746    pub content_type: String,
747    /// SHA-256 hex digest of the body, matching the content endpoint's
748    /// `ETag` value (`sha256:<hex>`, minus the `sha256:` prefix).
749    pub sha256: String,
750    /// Which backing produced this entry.
751    pub source: ProjectionSource,
752    /// Absolute filesystem path to the materialized projection file
753    /// (`crate::core::projection::FileProjectionAdapter`'s
754    /// [`ProjectionPlacement`] resolver's target — byte-compat default
755    /// layout `<root>/workspace/tasks/<step_id>/ctx/<name>.md`), when one
756    /// exists AND this entry addresses the whole step (no `?path=`
757    /// narrowing — a narrowed fragment is never file-backed). `None`
758    /// otherwise.
759    #[serde(default, skip_serializing_if = "Option::is_none")]
760    pub file_path: Option<String>,
761    /// Fetch URL for this step's content (`GET
762    /// /v1/tasks/:id/runs/:run/steps/:step/content`, `?path=` echoed when
763    /// this entry is narrowed) — absolute (`AppState.base_url`-prefixed)
764    /// when the server has a configured base URL, relative otherwise.
765    pub content_url: String,
766    /// First <= 512 bytes of the body, UTF-8-boundary-safe (never splits
767    /// a multi-byte character), with a trailing `…` when truncated.
768    pub preview: String,
769    /// `true` when [`Self::preview`]'s underlying byte count is shorter
770    /// than [`Self::size_bytes`] (the body was truncated to build the
771    /// preview).
772    pub truncated: bool,
773}
774
775/// Query params shared by the metadata and content routes: narrows a
776/// single step's value via `$.a.b` dot-path form (the leading `$.` is
777/// optional) — same syntax `mlua_swarm::core::projection::ProjectionKey`
778/// already establishes.
779#[derive(Debug, Deserialize, Default, schemars::JsonSchema)]
780pub struct StepPathQuery {
781    /// `$.a.b` narrowing within the step's value. `None` = the whole
782    /// step value.
783    #[serde(default)]
784    pub path: Option<String>,
785}
786
787/// Narrows `value` by `path` (reuses [`ProjectionKey::resolve`]'s
788/// path-walk half — the step lookup is already done, this value IS the
789/// step's own content).
790fn narrow_step_value(value: &Value, path: Option<&str>) -> Option<Value> {
791    match path {
792        None => Some(value.clone()),
793        Some(p) => {
794            let path_only = ProjectionKey {
795                task_id: String::new(),
796                run_id: None,
797                step: None,
798                path: Some(p.to_string()),
799            };
800            path_only.resolve(value).cloned()
801        }
802    }
803}
804
805/// The materialize target [`mlua_swarm::core::projection::FileProjectionAdapter`]
806/// writes to for a submission, resolved via the SAME
807/// [`ProjectionPlacement`] (GH #27, follow-up to #23) the writer
808/// consulted — reconstructed here (rather than constructed through the
809/// adapter itself) because this module resolves the target for a step
810/// *other than* the one materializing it, key-first.
811fn materialized_file_path(
812    placement: &ProjectionPlacement,
813    root: &str,
814    step_id: &StepId,
815    name: &str,
816) -> std::path::PathBuf {
817    placement.target_path(root, step_id.as_ref(), name)
818}
819
820/// Resolves the materialized file body for `name` (a CANONICAL name — the
821/// GH #23 subtask-2 sink writes the materialize target under the
822/// canonical name, so this lookup canonicalizes `run.step_entries`' raw
823/// `step_ref`s the same way before matching) in `run`, when one exists:
824/// finds `name`'s most recent [`mlua_swarm::store::run::StepEntry`]
825/// (giving its own dispatch `StepId`) via
826/// [`find_step_id_for_canonical`], resolves that step's own
827/// `AgentContextView` root via the SAME [`ProjectionPlacement`]
828/// [`Engine::submit_output`]'s materialize sink snapshotted at dispatch
829/// time (GH #27, follow-up to #23 — see
830/// `mlua_swarm::core::projection_placement`'s module doc for the "3 path"
831/// convergence), via [`mlua_swarm::core::engine::Engine::agent_context_for`],
832/// and reads the file at the resulting path back.
833///
834/// Only tries `attempt = 1` (the common case — a single dispatch per
835/// flow.ir Step) — a step retried under the same `StepId` at a later
836/// attempt is a known, accepted limitation (matching this module's other
837/// KNOWN LIMITATION notes); the entry still resolves via its Data-plane /
838/// `result_ref` value, just without a `file_path`.
839async fn resolve_materialized_file(
840    state: &AppState,
841    run: &RunRecord,
842    name: &str,
843) -> Option<(std::path::PathBuf, Vec<u8>)> {
844    let naming = resolve_step_naming_for_run(&state.engine, run).await;
845    let step_id = find_step_id_for_canonical(run, naming.as_deref(), name)?;
846    let view = state.engine.agent_context_for(&step_id, 1).await?;
847    let placement = state
848        .engine
849        .projection_placement_for(&step_id)
850        .await
851        .unwrap_or_default();
852    let root = placement.resolve_root(&view)?;
853    let path = materialized_file_path(&placement, &root, &step_id, name);
854    let bytes = std::fs::read(&path).ok()?;
855    Some((path, bytes))
856}
857
858/// Free-function sibling of [`McpQueryAdapter::step_naming_for_run`] for
859/// [`resolve_materialized_file`], which has no `McpQueryAdapter` handle
860/// (it resolves a step OTHER than the one materializing it, from a bare
861/// `&AppState`) — same lookup, same fail-open contract.
862async fn resolve_step_naming_for_run(engine: &Engine, run: &RunRecord) -> Option<Arc<StepNaming>> {
863    for entry in &run.step_entries {
864        if let Some(naming) = engine.step_naming_for(&entry.step_id).await {
865            return Some(naming);
866        }
867    }
868    None
869}
870
871/// Renders the body [`Self`]'s content endpoint serves for `step`,
872/// narrowed by `path` when `Some`: whole-step + materialized-file-backed
873/// → the raw file bytes (`text/markdown; charset=utf-8`); anything else →
874/// the (possibly narrowed) value as pretty JSON (`application/json`).
875/// Returns `None` when `path` is `Some` and does not resolve against
876/// `step.value` (the caller's 404 case).
877async fn render_step_body(
878    state: &AppState,
879    run: &RunRecord,
880    step: &ResolvedStep,
881    path: Option<&str>,
882) -> Option<(Vec<u8>, &'static str, Option<String>)> {
883    if path.is_none() {
884        if let Some((file_path, bytes)) = resolve_materialized_file(state, run, &step.name).await {
885            return Some((
886                bytes,
887                "text/markdown; charset=utf-8",
888                Some(file_path.to_string_lossy().into_owned()),
889            ));
890        }
891    }
892    let narrowed = narrow_step_value(&step.value, path)?;
893    let body = serde_json::to_vec_pretty(&narrowed).ok()?;
894    Some((body, "application/json", None))
895}
896
897/// First <= 512 bytes of `body`, UTF-8-boundary-safe (never splits a
898/// multi-byte character), with a trailing `…` when truncated. Returns
899/// `(preview, truncated)`. `body` is expected to be valid UTF-8 (JSON /
900/// materialized-markdown text, per [`render_step_body`]'s own two output
901/// shapes); a malformed byte sequence falls back to a lossy decode rather
902/// than panicking.
903fn build_preview(body: &[u8]) -> (String, bool) {
904    const MAX_PREVIEW_BYTES: usize = 512;
905    if body.len() <= MAX_PREVIEW_BYTES {
906        return (String::from_utf8_lossy(body).into_owned(), false);
907    }
908    let preview = match std::str::from_utf8(body) {
909        Ok(s) => {
910            let mut end = MAX_PREVIEW_BYTES;
911            while end > 0 && !s.is_char_boundary(end) {
912                end -= 1;
913            }
914            s[..end].to_string()
915        }
916        Err(_) => String::from_utf8_lossy(&body[..MAX_PREVIEW_BYTES]).into_owned(),
917    };
918    (format!("{preview}…"), true)
919}
920
921/// `GET /v1/tasks/:id/runs/:run/steps/:step/content`'s URL — absolute
922/// (`base_url`-prefixed) when the server has one configured, relative
923/// otherwise. `path` is echoed back as `?path=` verbatim (unencoded — the
924/// dot-path syntax this module accepts uses no characters reserved in a
925/// URL query component).
926fn build_content_url(
927    base_url: &Option<Arc<str>>,
928    task_id: &TaskId,
929    run_id: &RunId,
930    name: &str,
931    path: Option<&str>,
932) -> String {
933    let mut url = format!("/v1/tasks/{task_id}/runs/{run_id}/steps/{name}/content");
934    if let Some(p) = path {
935        url.push_str("?path=");
936        url.push_str(p);
937    }
938    match base_url {
939        Some(base) => format!("{}{}", base.trim_end_matches('/'), url),
940        None => url,
941    }
942}
943
944/// Builds the full [`StepSummary`] for `step`, narrowed by `path` when
945/// `Some`. `None` when `path` does not resolve (the caller's 404 case).
946async fn build_step_summary(
947    state: &AppState,
948    run: &RunRecord,
949    step: &ResolvedStep,
950    path: Option<&str>,
951) -> Option<StepSummary> {
952    let (body, content_type, file_path) = render_step_body(state, run, step, path).await?;
953    let sha256 = hex::encode(sha2::Sha256::digest(&body));
954    let size_bytes = body.len() as u64;
955    let (preview, truncated) = build_preview(&body);
956    let content_url = build_content_url(&state.base_url, &run.task_id, &run.id, &step.name, path);
957    Some(StepSummary {
958        name: step.name.clone(),
959        size_bytes,
960        content_type: content_type.to_string(),
961        sha256,
962        source: step.source,
963        file_path,
964        content_url,
965        preview,
966        truncated,
967    })
968}
969
970/// Fields a Worker-axis
971/// [`mlua_swarm::core::agent_context::StepPointer`] needs —
972/// `crates/mlua-swarm-server/src/worker.rs`'s `GET /v1/worker/prompt`
973/// handler builds one per visible, policy-allowed step from this.
974/// Reuses the same whole-step body [`render_step_body`] renders for the
975/// content endpoint (`path = None`), so `sha256` / `size_bytes` always
976/// matches what a `GET` of the returned `content_url` serves. `None`
977/// when the body cannot be rendered at all (mirrors this crate's other
978/// best-effort projection hooks — never turns a would-have-succeeded
979/// fetch into a failure; the caller just omits this step's pointer).
980pub(crate) async fn resolve_step_pointer_fields(
981    state: &AppState,
982    run: &RunRecord,
983    step: &ResolvedStep,
984) -> Option<(u64, Option<String>, String, String)> {
985    let (body, _content_type, file_path) = render_step_body(state, run, step, None).await?;
986    let sha256 = hex::encode(sha2::Sha256::digest(&body));
987    let size_bytes = body.len() as u64;
988    let content_url = build_content_url(&state.base_url, &run.task_id, &run.id, &step.name, None);
989    Some((size_bytes, file_path, content_url, sha256))
990}
991
992/// Shared resolve: `:id` → `TaskId` (existence-checked against
993/// `state.task_store` first, so an unknown Task returns its own 404
994/// distinct from an unknown Run) + `:run` (`"latest"` or an explicit
995/// `R-<hex>`) → the [`McpQueryAdapter`] that resolved it (returned
996/// alongside so `step_get` / `step_content` can canonicalize their `:step`
997/// path segment through the SAME adapter, via
998/// [`McpQueryAdapter::resolve_step_name`] — GH #23 subtask-3), the
999/// addressed [`RunRecord`], and its enumerated [`ResolvedStep`]s.
1000async fn resolve_run_and_steps(
1001    state: &AppState,
1002    id: &str,
1003    run: &str,
1004) -> Result<(McpQueryAdapter, RunRecord, Vec<ResolvedStep>), ApiError> {
1005    let task_id = TaskId::parse(id.to_string())
1006        .map_err(|e| ApiError::bad_request(format!("invalid task id: {e}")))?;
1007    state
1008        .task_store
1009        .get(&task_id)
1010        .await
1011        .map_err(map_task_store_err)?;
1012    let adapter = McpQueryAdapter::new(
1013        state.data_store.clone(),
1014        state.run_store.clone(),
1015        state.engine.clone(),
1016    );
1017    let run_sel = if run == "latest" { None } else { Some(run) };
1018    let (run_record, steps) = adapter
1019        .list_steps(&task_id, run_sel)
1020        .await
1021        .map_err(map_projection_err)?;
1022    Ok((adapter, run_record, steps))
1023}
1024
1025/// `GET /v1/tasks/:id/runs/:run/steps` — every step visible for the
1026/// addressed Run, unfiltered (see the module doc's role split).
1027pub async fn steps_list(
1028    State(state): State<AppState>,
1029    Path((id, run)): Path<(String, String)>,
1030) -> Result<Json<StepList>, ApiError> {
1031    let (_adapter, run_record, steps) = resolve_run_and_steps(&state, &id, &run).await?;
1032    let mut summaries = Vec::with_capacity(steps.len());
1033    for step in &steps {
1034        if let Some(summary) = build_step_summary(&state, &run_record, step, None).await {
1035            summaries.push(summary);
1036        }
1037    }
1038    Ok(Json(StepList {
1039        task_id: run_record.task_id.to_string(),
1040        run_id: run_record.id.to_string(),
1041        steps: summaries,
1042    }))
1043}
1044
1045/// `GET /v1/tasks/:id/runs/:run/steps/:step?path=$.a.b` — one step's
1046/// metadata, optionally narrowed. GH #23 subtask-3: `:step` is
1047/// canonicalized (`adapter.resolve_step_name`) before the lookup, so
1048/// either the canonical name or any alias 200s — the reported
1049/// [`StepSummary::name`] is always the canonical form.
1050pub async fn step_get(
1051    State(state): State<AppState>,
1052    Path((id, run, step)): Path<(String, String, String)>,
1053    Query(q): Query<StepPathQuery>,
1054) -> Result<Json<StepSummary>, ApiError> {
1055    let (adapter, run_record, steps) = resolve_run_and_steps(&state, &id, &run).await?;
1056    let canonical = adapter.resolve_step_name(&run_record, &step).await;
1057    let resolved = steps
1058        .into_iter()
1059        .find(|s| s.name == canonical)
1060        .ok_or_else(|| ApiError::not_found(format!("step not found: {step}")))?;
1061    let summary = build_step_summary(&state, &run_record, &resolved, q.path.as_deref())
1062        .await
1063        .ok_or_else(|| ApiError::not_found(format!("path not found: {:?}", q.path)))?;
1064    Ok(Json(summary))
1065}
1066
1067/// `GET /v1/tasks/:id/runs/:run/steps/:step/content?path=$.a.b` — the raw
1068/// body: full bytes, no envelope, no Range support. `Content-Type` and
1069/// `ETag` follow [`StepSummary::content_type`] / [`StepSummary::sha256`]'s
1070/// same rules (module doc). GH #23 subtask-3: `:step` is canonicalized
1071/// the same way [`step_get`] does.
1072pub async fn step_content(
1073    State(state): State<AppState>,
1074    Path((id, run, step)): Path<(String, String, String)>,
1075    Query(q): Query<StepPathQuery>,
1076) -> Result<impl IntoResponse, ApiError> {
1077    let (adapter, run_record, steps) = resolve_run_and_steps(&state, &id, &run).await?;
1078    let canonical = adapter.resolve_step_name(&run_record, &step).await;
1079    let resolved = steps
1080        .into_iter()
1081        .find(|s| s.name == canonical)
1082        .ok_or_else(|| ApiError::not_found(format!("step not found: {step}")))?;
1083    let (body, content_type, _file_path) =
1084        render_step_body(&state, &run_record, &resolved, q.path.as_deref())
1085            .await
1086            .ok_or_else(|| ApiError::not_found(format!("path not found: {:?}", q.path)))?;
1087    let sha256 = hex::encode(sha2::Sha256::digest(&body));
1088    let mut headers = HeaderMap::new();
1089    headers.insert(
1090        header::CONTENT_TYPE,
1091        HeaderValue::from_str(content_type).expect("content_type is a static ASCII literal"),
1092    );
1093    headers.insert(
1094        header::ETAG,
1095        HeaderValue::from_str(&format!("\"sha256:{sha256}\""))
1096            .expect("hex digest is ASCII-safe for a header value"),
1097    );
1098    Ok((StatusCode::OK, headers, body))
1099}
1100
1101fn map_projection_err(e: ProjectionError) -> ApiError {
1102    match e {
1103        ProjectionError::NotFound(key) => {
1104            ApiError::not_found(format!("projection not found for key {key:?}"))
1105        }
1106        ProjectionError::InvalidKey(msg) => ApiError::bad_request(msg),
1107        other => ApiError::engine(other),
1108    }
1109}
1110
1111// ──────────────────────────────────────────────────────────────────────────
1112// UT
1113// ──────────────────────────────────────────────────────────────────────────
1114
1115#[cfg(test)]
1116mod tests {
1117    use super::*;
1118    use crate::TaskLaunchRequest;
1119    use axum::http::StatusCode;
1120    use mlua_swarm::application::BlueprintRef;
1121    use mlua_swarm::blueprint::{
1122        current_schema_version, AgentDef, AgentKind, AgentMeta, Blueprint, BlueprintMetadata,
1123        CompilerHints, CompilerStrategy, ProjectionPlacementSpec,
1124    };
1125    use mlua_swarm::core::config::EngineCfg;
1126    use mlua_swarm::core::engine::Engine;
1127    use mlua_swarm::store::output::InMemoryOutputStore;
1128    use mlua_swarm::store::run::InMemoryRunStore;
1129    use mlua_swarm::store::task::InMemoryTaskStore;
1130    use serde_json::json;
1131    use std::collections::HashMap;
1132    use tokio::sync::Mutex;
1133
1134    /// A single-step flow.ir Blueprint that echoes `$.greeting` into
1135    /// `$.out` (AG_IDENTITY wraps its input as `{"echoed": input}`), so
1136    /// `result_ref = {"out": {"echoed": <greeting>}}` — enough shape to
1137    /// exercise `step` + `path` narrowing. Mirrors `tasks.rs`'s own test
1138    /// helper (duplicated here rather than shared — this crate's
1139    /// established per-module test-helper convention; see e.g.
1140    /// `tasks::tests::test_state`).
1141    fn greeting_blueprint() -> Blueprint {
1142        Blueprint {
1143            schema_version: current_schema_version(),
1144            id: "projection-test-greeting-bp".into(),
1145            flow: serde_json::from_value(json!({
1146                "kind": "step",
1147                "ref": mlua_swarm::worker::baseline::AG_IDENTITY,
1148                "in": {"op": "path", "at": "$.greeting"},
1149                "out": {"op": "path", "at": "$.out"},
1150            }))
1151            .expect("flow parse"),
1152            agents: vec![AgentDef {
1153                name: mlua_swarm::worker::baseline::AG_IDENTITY.into(),
1154                kind: AgentKind::RustFn,
1155                spec: json!({"fn_id": mlua_swarm::worker::baseline::AG_IDENTITY}),
1156                profile: None,
1157                meta: None,
1158            }],
1159            operators: vec![],
1160            metas: vec![],
1161            hints: CompilerHints::default(),
1162            strategy: CompilerStrategy::default(),
1163            metadata: BlueprintMetadata::default(),
1164            spawner_hints: Default::default(),
1165            default_agent_kind: AgentKind::Operator,
1166            default_operator_kind: None,
1167            default_init_ctx: None,
1168            default_agent_ctx: None,
1169            default_context_policy: None,
1170            projection_placement: None,
1171        }
1172    }
1173
1174    fn test_state() -> AppState {
1175        let engine = Engine::new_with_layers(EngineCfg::default(), crate::default_layer_registry());
1176        let compiler = mlua_swarm::Compiler::new(crate::default_registry());
1177        let launch = Arc::new(mlua_swarm::TaskLaunchService::new(engine.clone(), compiler));
1178        let data_store: Arc<dyn mlua_swarm::store::output::OutputStore> =
1179            Arc::new(InMemoryOutputStore::new());
1180        // subtask-4 / ST2 rework: wire the SAME `OutputStore` into the
1181        // engine's submit-time projection sink (mirrors
1182        // `crate::build_router_full`'s own wiring), so tests exercising the
1183        // Data-plane / in-flight path see ordinary worker submissions land
1184        // here too, not just explicit `POST /v1/data/emit` calls.
1185        engine.set_output_store(data_store.clone());
1186        AppState {
1187            engine,
1188            sessions: Arc::new(Mutex::new(crate::SessionStore::default())),
1189            task_app: Arc::new(mlua_swarm::TaskApplication::new_inline_only(launch)),
1190            ws_operator_factory: None,
1191            data_store,
1192            operator_sessions: Arc::new(Mutex::new(HashMap::new())),
1193            roles_to_sid: Arc::new(Mutex::new(HashMap::new())),
1194            task_store: Arc::new(InMemoryTaskStore::new()),
1195            run_store: Arc::new(InMemoryRunStore::new()),
1196            base_url: None,
1197        }
1198    }
1199
1200    fn greeting_task_req(greeting: &str) -> TaskLaunchRequest {
1201        TaskLaunchRequest {
1202            blueprint: BlueprintRef::Inline {
1203                value: Box::new(greeting_blueprint()),
1204            },
1205            init_ctx: json!({ "greeting": greeting }),
1206            project_root: None,
1207            work_dir: None,
1208            task_metadata: None,
1209            ttl_secs: None,
1210            operator: None,
1211            operator_sid: None,
1212            goal: Some("projection test goal".to_string()),
1213        }
1214    }
1215
1216    /// GH #23 subtask-3: a single-step Blueprint whose sole agent
1217    /// (`AG_IDENTITY`) declares `AgentMeta.projection_name`, distinct from
1218    /// its own `Step.ref` — the declared-name E2E fixture. Same shape as
1219    /// [`greeting_blueprint`] otherwise (echoes `$.greeting` into
1220    /// `$.out`).
1221    fn declared_projection_name_blueprint(projection_name: &str) -> Blueprint {
1222        Blueprint {
1223            schema_version: current_schema_version(),
1224            id: "projection-test-declared-name-bp".into(),
1225            flow: serde_json::from_value(json!({
1226                "kind": "step",
1227                "ref": mlua_swarm::worker::baseline::AG_IDENTITY,
1228                "in": {"op": "path", "at": "$.greeting"},
1229                "out": {"op": "path", "at": "$.out"},
1230            }))
1231            .expect("flow parse"),
1232            agents: vec![AgentDef {
1233                name: mlua_swarm::worker::baseline::AG_IDENTITY.into(),
1234                kind: AgentKind::RustFn,
1235                spec: json!({"fn_id": mlua_swarm::worker::baseline::AG_IDENTITY}),
1236                profile: None,
1237                meta: Some(AgentMeta {
1238                    projection_name: Some(projection_name.to_string()),
1239                    ..Default::default()
1240                }),
1241            }],
1242            operators: vec![],
1243            metas: vec![],
1244            hints: CompilerHints::default(),
1245            strategy: CompilerStrategy::default(),
1246            metadata: BlueprintMetadata::default(),
1247            spawner_hints: Default::default(),
1248            default_agent_kind: AgentKind::Operator,
1249            default_operator_kind: None,
1250            default_init_ctx: None,
1251            default_agent_ctx: None,
1252            default_context_policy: None,
1253            projection_placement: None,
1254        }
1255    }
1256
1257    fn declared_task_req(greeting: &str, projection_name: &str) -> TaskLaunchRequest {
1258        TaskLaunchRequest {
1259            blueprint: BlueprintRef::Inline {
1260                value: Box::new(declared_projection_name_blueprint(projection_name)),
1261            },
1262            init_ctx: json!({ "greeting": greeting }),
1263            project_root: None,
1264            work_dir: None,
1265            task_metadata: None,
1266            ttl_secs: None,
1267            operator: None,
1268            operator_sid: None,
1269            goal: Some("projection test goal (declared name)".to_string()),
1270        }
1271    }
1272
1273    // ─── Test 8: steps collection, GH #23 subtask-3 table-driven addressing ───
1274
1275    /// GH #23 subtask-3 (backward-compat): an undeclared step's `Step.ref`
1276    /// (its own name) and its `out` ctx-path top-level segment are now ONE
1277    /// canonical addressing space — `enumerate_steps` reports exactly ONE
1278    /// entry (the canonical name = the ref itself), not the pre-GH-#23
1279    /// runtime union rule's two separate Data-plane/ResultRef entries.
1280    /// `step_get_resolves_alias_name_to_canonical_entry` below covers that
1281    /// "out" still 200s via alias resolution.
1282    #[tokio::test]
1283    async fn steps_list_undeclared_step_resolves_to_single_canonical_entry() {
1284        let state = test_state();
1285        let posted = crate::tasks_start(State(state.clone()), Json(greeting_task_req("hello")))
1286            .await
1287            .expect("tasks_start")
1288            .0;
1289
1290        let resp = steps_list(
1291            State(state.clone()),
1292            Path((posted.task_id.to_string(), "latest".to_string())),
1293        )
1294        .await
1295        .expect("steps_list")
1296        .0;
1297
1298        assert_eq!(resp.task_id, posted.task_id.to_string());
1299        assert_eq!(resp.run_id, posted.run_id.to_string());
1300        let identity_name = mlua_swarm::worker::baseline::AG_IDENTITY;
1301        assert_eq!(resp.steps.len(), 1, "steps: {:?}", resp.steps);
1302        let entry = &resp.steps[0];
1303        assert_eq!(entry.name, identity_name);
1304        assert_eq!(entry.source, ProjectionSource::DataPlane);
1305    }
1306
1307    /// GH #23 subtask-3 (backward-compat): `step_get`'s `:step` segment
1308    /// resolves through the `StepNaming` table — the raw `Step.ref` name
1309    /// and the `out` ctx-path alias both 200, to the SAME content, and
1310    /// both report the canonical name.
1311    #[tokio::test]
1312    async fn step_get_resolves_alias_name_to_canonical_entry() {
1313        let state = test_state();
1314        let posted = crate::tasks_start(State(state.clone()), Json(greeting_task_req("hi")))
1315            .await
1316            .expect("tasks_start")
1317            .0;
1318
1319        let identity_name = mlua_swarm::worker::baseline::AG_IDENTITY;
1320        let via_ref = step_get(
1321            State(state.clone()),
1322            Path((
1323                posted.task_id.to_string(),
1324                "latest".to_string(),
1325                identity_name.to_string(),
1326            )),
1327            Query(StepPathQuery::default()),
1328        )
1329        .await
1330        .expect("step_get via own ref name")
1331        .0;
1332        let via_alias = step_get(
1333            State(state.clone()),
1334            Path((
1335                posted.task_id.to_string(),
1336                "latest".to_string(),
1337                "out".to_string(),
1338            )),
1339            Query(StepPathQuery::default()),
1340        )
1341        .await
1342        .expect("step_get via out-top alias")
1343        .0;
1344
1345        assert_eq!(via_ref.name, identity_name);
1346        assert_eq!(
1347            via_alias.name, identity_name,
1348            "alias lookup must report the canonical name"
1349        );
1350        assert_eq!(
1351            via_ref.sha256, via_alias.sha256,
1352            "same OUTPUT regardless of which name was queried"
1353        );
1354    }
1355
1356    /// GH #23 subtask-3 (declared-name E2E): a Blueprint-declared
1357    /// `projection_name` drives `steps_list` (single canonical entry) AND
1358    /// `step_get`'s `:step` resolution (canonical name, the raw `Step.ref`
1359    /// alias, AND the `out`-top alias all 200 to the SAME content).
1360    #[tokio::test]
1361    async fn declared_projection_name_e2e_resolves_via_canonical_and_alias() {
1362        let state = test_state();
1363        let posted = crate::tasks_start(
1364            State(state.clone()),
1365            Json(declared_task_req("hi", "plan-out")),
1366        )
1367        .await
1368        .expect("tasks_start")
1369        .0;
1370
1371        let list = steps_list(
1372            State(state.clone()),
1373            Path((posted.task_id.to_string(), "latest".to_string())),
1374        )
1375        .await
1376        .expect("steps_list")
1377        .0;
1378        assert_eq!(list.steps.len(), 1, "steps: {:?}", list.steps);
1379        assert_eq!(list.steps[0].name, "plan-out");
1380        assert_eq!(list.steps[0].source, ProjectionSource::DataPlane);
1381
1382        let by_canonical = step_get(
1383            State(state.clone()),
1384            Path((
1385                posted.task_id.to_string(),
1386                "latest".to_string(),
1387                "plan-out".to_string(),
1388            )),
1389            Query(StepPathQuery::default()),
1390        )
1391        .await
1392        .expect("step_get canonical")
1393        .0;
1394        assert_eq!(by_canonical.name, "plan-out");
1395
1396        let identity_name = mlua_swarm::worker::baseline::AG_IDENTITY;
1397        let by_ref_alias = step_get(
1398            State(state.clone()),
1399            Path((
1400                posted.task_id.to_string(),
1401                "latest".to_string(),
1402                identity_name.to_string(),
1403            )),
1404            Query(StepPathQuery::default()),
1405        )
1406        .await
1407        .expect("step_get ref alias")
1408        .0;
1409        assert_eq!(by_ref_alias.name, "plan-out");
1410        assert_eq!(by_ref_alias.sha256, by_canonical.sha256);
1411
1412        let by_out_alias = step_get(
1413            State(state.clone()),
1414            Path((
1415                posted.task_id.to_string(),
1416                "latest".to_string(),
1417                "out".to_string(),
1418            )),
1419            Query(StepPathQuery::default()),
1420        )
1421        .await
1422        .expect("step_get out-top alias")
1423        .0;
1424        assert_eq!(by_out_alias.name, "plan-out");
1425        assert_eq!(by_out_alias.sha256, by_canonical.sha256);
1426    }
1427
1428    /// GH #23 subtask-3 (declared-name E2E, materialized file half): the
1429    /// GH #23 subtask-2 sink writes the materialize target under the
1430    /// CANONICAL name — `resolve_materialized_file`'s lookup must
1431    /// canonicalize `run.step_entries`' raw `step_ref` the same way to
1432    /// find it, so the stem the server reports is `plan-out.md`, not
1433    /// `identity.md`.
1434    #[tokio::test]
1435    async fn declared_projection_name_materialized_file_stem_is_canonical() {
1436        let dir = tempfile::TempDir::new().unwrap();
1437        let state = test_state();
1438        let mut req = declared_task_req("materialized-declared", "plan-out");
1439        req.work_dir = Some(dir.path().to_string_lossy().into_owned());
1440        let posted = crate::tasks_start(State(state.clone()), Json(req))
1441            .await
1442            .expect("tasks_start")
1443            .0;
1444
1445        let summary = step_get(
1446            State(state.clone()),
1447            Path((
1448                posted.task_id.to_string(),
1449                "latest".to_string(),
1450                "plan-out".to_string(),
1451            )),
1452            Query(StepPathQuery::default()),
1453        )
1454        .await
1455        .expect("step_get")
1456        .0;
1457
1458        let file_path = summary.file_path.expect("materialized file_path present");
1459        assert!(
1460            file_path.ends_with("plan-out.md"),
1461            "materialized file stem must be the canonical name: {file_path}"
1462        );
1463    }
1464
1465    /// GH #27 (follow-up to #23), 3-path consistency E2E: a Blueprint
1466    /// declaring `projection_placement` (`root = "project_root"`, a
1467    /// custom `dir_template`) drives BOTH the submit-time write (`Engine`'s
1468    /// `materialize_final_submission`, dispatched off the `Compiler`-built
1469    /// resolver) AND the server read-back
1470    /// (`resolve_materialized_file`, which re-fetches the SAME resolver via
1471    /// `Engine::projection_placement_for`) to the identical custom
1472    /// location — proof the "3 path" convergence
1473    /// `crate::core::projection_placement`'s module doc describes holds
1474    /// end-to-end, and that `work_dir` (absent here) is correctly NOT
1475    /// preferred when `root = "project_root"` is declared.
1476    #[tokio::test]
1477    async fn declared_projection_placement_e2e_write_and_read_back_converge() {
1478        let project_root_dir = tempfile::TempDir::new().unwrap();
1479        let state = test_state();
1480        let mut bp = declared_projection_name_blueprint("plan-out");
1481        bp.projection_placement = Some(ProjectionPlacementSpec {
1482            root: Some("project_root".to_string()),
1483            dir_template: Some("custom/{task_id}/out".to_string()),
1484        });
1485        let req = TaskLaunchRequest {
1486            blueprint: BlueprintRef::Inline {
1487                value: Box::new(bp),
1488            },
1489            init_ctx: json!({ "greeting": "materialized-custom-placement" }),
1490            project_root: Some(project_root_dir.path().to_string_lossy().into_owned()),
1491            work_dir: None,
1492            task_metadata: None,
1493            ttl_secs: None,
1494            operator: None,
1495            operator_sid: None,
1496            goal: Some("projection placement test goal".to_string()),
1497        };
1498        let posted = crate::tasks_start(State(state.clone()), Json(req))
1499            .await
1500            .expect("tasks_start")
1501            .0;
1502
1503        let summary = step_get(
1504            State(state.clone()),
1505            Path((
1506                posted.task_id.to_string(),
1507                "latest".to_string(),
1508                "plan-out".to_string(),
1509            )),
1510            Query(StepPathQuery::default()),
1511        )
1512        .await
1513        .expect("step_get")
1514        .0;
1515
1516        // NOTE: the `{task_id}` the placement resolver substitutes is the
1517        // dispatched Step's own `StepId` (`ProjectionKey.task_id`), which is
1518        // NOT the same value as `posted.task_id` (the outer `TaskId` this
1519        // flow-of-one-Step was launched under) — so the expected path is
1520        // built from the ACTUAL observed `file_path` shape (prefix / infix
1521        // / suffix), not a predicted exact id, mirroring
1522        // `declared_projection_name_materialized_file_stem_is_canonical`'s
1523        // own suffix-only assertion style.
1524        let file_path = summary.file_path.expect("materialized file_path present");
1525        let path = std::path::Path::new(&file_path);
1526        assert!(
1527            path.starts_with(project_root_dir.path()),
1528            "file must be rooted at project_root (root_preference=ProjectRoot): {file_path}"
1529        );
1530        assert!(
1531            file_path.ends_with("out/plan-out.md"),
1532            "file must follow the custom dir_template's tail: {file_path}"
1533        );
1534        assert!(
1535            file_path.contains("/custom/"),
1536            "file must follow the custom dir_template's prefix segment: {file_path}"
1537        );
1538        assert!(
1539            path.exists(),
1540            "the write side must have materialized the file the read-back reports: {file_path}"
1541        );
1542    }
1543
1544    /// GH #23 subtask-3 (collision): a declared `projection_name` that
1545    /// collides with another Step's own `ref` is rejected at
1546    /// register/compile time — `StepNaming::from_blueprint`'s hard-error
1547    /// validation (subtask-1) surfaces end-to-end through the real
1548    /// `tasks_start` dispatch path, not just the unit-level `StepNaming`
1549    /// tests.
1550    #[tokio::test]
1551    async fn declared_projection_name_colliding_with_another_steps_ref_is_rejected_at_register_time(
1552    ) {
1553        use mlua_flow_ir::{Expr, Node as FlowNode};
1554        use mlua_swarm::worker::adapter::WorkerResult;
1555        use mlua_swarm::{RustFnInProcessSpawnerFactory, SpawnerRegistry};
1556
1557        let factory = RustFnInProcessSpawnerFactory::new()
1558            .register_fn("step-a", |inv| async move {
1559                Ok(WorkerResult {
1560                    value: json!(inv.prompt),
1561                    ok: true,
1562                })
1563            })
1564            .register_fn("step-b", |inv| async move {
1565                Ok(WorkerResult {
1566                    value: json!(inv.prompt),
1567                    ok: true,
1568                })
1569            });
1570        let mut reg = SpawnerRegistry::new();
1571        reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
1572
1573        let engine = Engine::new_with_layers(EngineCfg::default(), crate::default_layer_registry());
1574        let data_store: Arc<dyn mlua_swarm::store::output::OutputStore> =
1575            Arc::new(InMemoryOutputStore::new());
1576        engine.set_output_store(data_store.clone());
1577        let compiler = mlua_swarm::Compiler::new(reg);
1578        let launch = Arc::new(mlua_swarm::TaskLaunchService::new(engine.clone(), compiler));
1579        let state = AppState {
1580            engine,
1581            sessions: Arc::new(Mutex::new(crate::SessionStore::default())),
1582            task_app: Arc::new(mlua_swarm::TaskApplication::new_inline_only(launch)),
1583            ws_operator_factory: None,
1584            data_store,
1585            operator_sessions: Arc::new(Mutex::new(HashMap::new())),
1586            roles_to_sid: Arc::new(Mutex::new(HashMap::new())),
1587            task_store: Arc::new(InMemoryTaskStore::new()),
1588            run_store: Arc::new(InMemoryRunStore::new()),
1589            base_url: None,
1590        };
1591
1592        let flow = FlowNode::Seq {
1593            children: vec![
1594                FlowNode::Step {
1595                    ref_: "step-a".to_string(),
1596                    in_: Expr::Path {
1597                        at: "$.greeting".to_string(),
1598                    },
1599                    out: Expr::Path {
1600                        at: "$.a_out".to_string(),
1601                    },
1602                },
1603                FlowNode::Step {
1604                    ref_: "step-b".to_string(),
1605                    in_: Expr::Path {
1606                        at: "$.greeting".to_string(),
1607                    },
1608                    out: Expr::Path {
1609                        at: "$.b_out".to_string(),
1610                    },
1611                },
1612            ],
1613        };
1614        let blueprint = Blueprint {
1615            schema_version: current_schema_version(),
1616            id: "projection-test-collision-bp".into(),
1617            flow,
1618            agents: vec![
1619                AgentDef {
1620                    name: "step-a".into(),
1621                    kind: AgentKind::RustFn,
1622                    spec: json!({"fn_id": "step-a"}),
1623                    profile: None,
1624                    // Declares a projection_name colliding with "step-b"'s
1625                    // own (undeclared) ref — hard collision.
1626                    meta: Some(AgentMeta {
1627                        projection_name: Some("step-b".to_string()),
1628                        ..Default::default()
1629                    }),
1630                },
1631                AgentDef {
1632                    name: "step-b".into(),
1633                    kind: AgentKind::RustFn,
1634                    spec: json!({"fn_id": "step-b"}),
1635                    profile: None,
1636                    meta: None,
1637                },
1638            ],
1639            operators: vec![],
1640            metas: vec![],
1641            hints: CompilerHints::default(),
1642            strategy: CompilerStrategy::default(),
1643            metadata: BlueprintMetadata::default(),
1644            spawner_hints: Default::default(),
1645            default_agent_kind: AgentKind::Operator,
1646            default_operator_kind: None,
1647            default_init_ctx: None,
1648            default_agent_ctx: None,
1649            default_context_policy: None,
1650            projection_placement: None,
1651        };
1652
1653        let req = TaskLaunchRequest {
1654            blueprint: BlueprintRef::Inline {
1655                value: Box::new(blueprint),
1656            },
1657            init_ctx: json!({ "greeting": "hi" }),
1658            project_root: None,
1659            work_dir: None,
1660            task_metadata: None,
1661            ttl_secs: None,
1662            operator: None,
1663            operator_sid: None,
1664            goal: None,
1665        };
1666
1667        // `TaskLaunchResponse` (the `Ok` side) does not implement `Debug`,
1668        // so `.expect_err` (which requires `T: Debug`) is not usable here —
1669        // match instead.
1670        let result = crate::tasks_start(State(state), Json(req)).await;
1671        let err = match result {
1672            Err(e) => e,
1673            Ok(_) => {
1674                panic!("declared projection_name colliding with another step's own ref must reject")
1675            }
1676        };
1677        assert_eq!(err.status, StatusCode::BAD_REQUEST);
1678    }
1679
1680    /// GH #23 subtask-3 (race close): two separate Tasks whose flow.ir
1681    /// each dispatch the SAME producer name (`AG_IDENTITY`) — pre-GH-#23,
1682    /// `OutputStore::get_latest_by_name` would resolve whichever Task
1683    /// submitted LAST, globally, regardless of which Run's steps were
1684    /// being enumerated (the former KNOWN LIMITATION race). The
1685    /// Run-scoped `get_latest_by_name_in_run` lookup this subtask wires
1686    /// (keyed by each step's own globally-unique `StepId`) must keep each
1687    /// Task's own value distinct even though both share a producer name.
1688    #[tokio::test]
1689    async fn steps_list_run_scoped_lookup_does_not_bleed_across_tasks_sharing_a_producer_name() {
1690        let state = test_state();
1691        let first = crate::tasks_start(State(state.clone()), Json(greeting_task_req("first-task")))
1692            .await
1693            .expect("first tasks_start")
1694            .0;
1695        let second =
1696            crate::tasks_start(State(state.clone()), Json(greeting_task_req("second-task")))
1697                .await
1698                .expect("second tasks_start")
1699                .0;
1700
1701        let first_steps = steps_list(
1702            State(state.clone()),
1703            Path((first.task_id.to_string(), "latest".to_string())),
1704        )
1705        .await
1706        .expect("first steps_list")
1707        .0;
1708        let second_steps = steps_list(
1709            State(state.clone()),
1710            Path((second.task_id.to_string(), "latest".to_string())),
1711        )
1712        .await
1713        .expect("second steps_list")
1714        .0;
1715
1716        let identity_name = mlua_swarm::worker::baseline::AG_IDENTITY;
1717        let first_entry = first_steps
1718            .steps
1719            .iter()
1720            .find(|s| s.name == identity_name)
1721            .expect("first entry present");
1722        let second_entry = second_steps
1723            .steps
1724            .iter()
1725            .find(|s| s.name == identity_name)
1726            .expect("second entry present");
1727        assert_eq!(first_entry.source, ProjectionSource::DataPlane);
1728        assert_eq!(second_entry.source, ProjectionSource::DataPlane);
1729        assert_ne!(
1730            first_entry.sha256, second_entry.sha256,
1731            "each Task's own greeting must resolve, not the globally-latest submission"
1732        );
1733    }
1734
1735    // ─── Test 9: `:run = latest` resolves to newest Run; explicit pin still works ───
1736
1737    #[tokio::test]
1738    async fn steps_list_latest_resolves_newest_run_explicit_pin_still_works() {
1739        let state = test_state();
1740        let first = crate::tasks_start(State(state.clone()), Json(greeting_task_req("first")))
1741            .await
1742            .expect("tasks_start")
1743            .0;
1744        let (status, rekicked) = crate::tasks::task_rekick(
1745            State(state.clone()),
1746            Path(first.task_id.to_string()),
1747            Some(Json(crate::tasks::RunKickRequest {
1748                init_ctx_override: Some(json!({ "greeting": "second" })),
1749                task_input_override: None,
1750            })),
1751        )
1752        .await
1753        .expect("task_rekick");
1754        assert_eq!(status, StatusCode::CREATED);
1755
1756        let latest = steps_list(
1757            State(state.clone()),
1758            Path((first.task_id.to_string(), "latest".to_string())),
1759        )
1760        .await
1761        .expect("steps_list latest")
1762        .0;
1763        assert_eq!(latest.run_id, rekicked.0.run_id.to_string());
1764
1765        let pinned = steps_list(
1766            State(state.clone()),
1767            Path((first.task_id.to_string(), first.run_id.to_string())),
1768        )
1769        .await
1770        .expect("steps_list pinned")
1771        .0;
1772        assert_eq!(pinned.run_id, first.run_id.to_string());
1773    }
1774
1775    // ─── Test 10: preview <= 512 bytes, UTF-8 boundary safe, truncated flag ───
1776
1777    #[tokio::test]
1778    async fn step_get_preview_is_utf8_boundary_safe_and_truncated_flag_is_correct() {
1779        let state = test_state();
1780        // A multi-byte fixture: repeat a 3-byte UTF-8 character (U+3042
1781        // "あ") past the 512-byte preview cap so the boundary-safety guard
1782        // is actually exercised, then wrap it as the greeting value.
1783        let long_value = "あ".repeat(300); // 900 bytes
1784        let posted = crate::tasks_start(State(state.clone()), Json(greeting_task_req(&long_value)))
1785            .await
1786            .expect("tasks_start")
1787            .0;
1788
1789        let identity_name = mlua_swarm::worker::baseline::AG_IDENTITY;
1790        let summary = step_get(
1791            State(state.clone()),
1792            Path((
1793                posted.task_id.to_string(),
1794                "latest".to_string(),
1795                identity_name.to_string(),
1796            )),
1797            Query(StepPathQuery::default()),
1798        )
1799        .await
1800        .expect("step_get")
1801        .0;
1802
1803        assert!(
1804            summary.preview.len() <= 512 + "…".len(),
1805            "preview must stay near the 512-byte cap: {} bytes",
1806            summary.preview.len()
1807        );
1808        assert!(
1809            summary.truncated,
1810            "a 900-byte body must be reported truncated"
1811        );
1812        assert!(
1813            summary.preview.ends_with('…'),
1814            "truncated preview must end with an ellipsis: {}",
1815            summary.preview
1816        );
1817        // The boundary-safety guard: a valid `String` never panics on
1818        // construction from a byte slice that split a multi-byte char —
1819        // reaching this assertion at all is the proof (an unsafe/naive
1820        // byte-slice truncation would have panicked above on `str`
1821        // reconstruction).
1822        assert!(summary.preview.chars().all(|c| c != '\u{FFFD}'));
1823    }
1824
1825    // ─── Test 11: content = full body + Content-Type branch + ETag ────────
1826
1827    #[tokio::test]
1828    async fn step_content_in_memory_fallback_is_json_with_matching_etag() {
1829        let state = test_state();
1830        let posted = crate::tasks_start(State(state.clone()), Json(greeting_task_req("hi")))
1831            .await
1832            .expect("tasks_start")
1833            .0;
1834
1835        let resp = step_content(
1836            State(state.clone()),
1837            Path((
1838                posted.task_id.to_string(),
1839                "latest".to_string(),
1840                "out".to_string(),
1841            )),
1842            Query(StepPathQuery::default()),
1843        )
1844        .await
1845        .expect("step_content")
1846        .into_response();
1847
1848        assert_eq!(resp.status(), StatusCode::OK);
1849        let content_type = resp
1850            .headers()
1851            .get(header::CONTENT_TYPE)
1852            .expect("content-type header")
1853            .to_str()
1854            .expect("ascii");
1855        assert_eq!(content_type, "application/json");
1856        let etag = resp
1857            .headers()
1858            .get(header::ETAG)
1859            .expect("etag header")
1860            .to_str()
1861            .expect("ascii")
1862            .to_string();
1863        let body_bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1864            .await
1865            .expect("body bytes");
1866        let expected_sha = hex::encode(sha2::Sha256::digest(&body_bytes));
1867        assert_eq!(etag, format!("\"sha256:{expected_sha}\""));
1868        let parsed: Value = serde_json::from_slice(&body_bytes).expect("valid json body");
1869        assert_eq!(parsed["echoed"], json!("hi"));
1870    }
1871
1872    /// Test 11 (materialized-file half): when the producing step's
1873    /// submission was materialized to disk (`work_dir` resolved),
1874    /// `step_content` serves the RAW file bytes as `text/markdown`, not
1875    /// the in-memory JSON fallback.
1876    #[tokio::test]
1877    async fn step_content_materialized_file_is_served_as_markdown() {
1878        let dir = tempfile::TempDir::new().unwrap();
1879        let state = test_state();
1880        let mut req = greeting_task_req("materialized");
1881        req.work_dir = Some(dir.path().to_string_lossy().into_owned());
1882        let posted = crate::tasks_start(State(state.clone()), Json(req))
1883            .await
1884            .expect("tasks_start")
1885            .0;
1886
1887        let identity_name = mlua_swarm::worker::baseline::AG_IDENTITY;
1888        let resp = step_content(
1889            State(state.clone()),
1890            Path((
1891                posted.task_id.to_string(),
1892                "latest".to_string(),
1893                identity_name.to_string(),
1894            )),
1895            Query(StepPathQuery::default()),
1896        )
1897        .await
1898        .expect("step_content")
1899        .into_response();
1900
1901        assert_eq!(resp.status(), StatusCode::OK);
1902        let content_type = resp
1903            .headers()
1904            .get(header::CONTENT_TYPE)
1905            .expect("content-type header")
1906            .to_str()
1907            .expect("ascii");
1908        assert_eq!(content_type, "text/markdown; charset=utf-8");
1909        let body_bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1910            .await
1911            .expect("body bytes");
1912        let body_str = String::from_utf8(body_bytes.to_vec()).expect("utf8 body");
1913        assert!(
1914            body_str.contains("```json"),
1915            "materialized file must carry the fenced json block: {body_str}"
1916        );
1917    }
1918
1919    // ─── Test 12: content `?path=` narrow → application/json fragment ─────
1920
1921    #[tokio::test]
1922    async fn step_content_path_narrow_returns_json_fragment() {
1923        let state = test_state();
1924        let posted = crate::tasks_start(State(state.clone()), Json(greeting_task_req("narrowed")))
1925            .await
1926            .expect("tasks_start")
1927            .0;
1928
1929        let resp = step_content(
1930            State(state.clone()),
1931            Path((
1932                posted.task_id.to_string(),
1933                "latest".to_string(),
1934                "out".to_string(),
1935            )),
1936            Query(StepPathQuery {
1937                path: Some("echoed".to_string()),
1938            }),
1939        )
1940        .await
1941        .expect("step_content narrowed")
1942        .into_response();
1943
1944        assert_eq!(resp.status(), StatusCode::OK);
1945        let content_type = resp
1946            .headers()
1947            .get(header::CONTENT_TYPE)
1948            .expect("content-type header")
1949            .to_str()
1950            .expect("ascii");
1951        assert_eq!(content_type, "application/json");
1952        let body_bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1953            .await
1954            .expect("body bytes");
1955        let parsed: Value = serde_json::from_slice(&body_bytes).expect("valid json body");
1956        assert_eq!(parsed, json!("narrowed"));
1957    }
1958
1959    // ─── Test 13: unknown task / run / step → 404 ───────────────────────────
1960
1961    #[tokio::test]
1962    async fn steps_list_unknown_task_returns_404() {
1963        let state = test_state();
1964        let err = steps_list(
1965            State(state),
1966            Path(("T-does-not-exist".to_string(), "latest".to_string())),
1967        )
1968        .await
1969        .expect_err("unknown task must 404");
1970        assert_eq!(err.status, StatusCode::NOT_FOUND);
1971    }
1972
1973    #[tokio::test]
1974    async fn steps_list_unknown_run_returns_404() {
1975        let state = test_state();
1976        let posted = crate::tasks_start(State(state.clone()), Json(greeting_task_req("hi")))
1977            .await
1978            .expect("tasks_start")
1979            .0;
1980        let err = steps_list(
1981            State(state),
1982            Path((posted.task_id.to_string(), "R-does-not-exist".to_string())),
1983        )
1984        .await
1985        .expect_err("unknown run must 404");
1986        assert_eq!(err.status, StatusCode::NOT_FOUND);
1987    }
1988
1989    #[tokio::test]
1990    async fn step_get_unknown_step_returns_404() {
1991        let state = test_state();
1992        let posted = crate::tasks_start(State(state.clone()), Json(greeting_task_req("hi")))
1993            .await
1994            .expect("tasks_start")
1995            .0;
1996        let err = step_get(
1997            State(state),
1998            Path((
1999                posted.task_id.to_string(),
2000                "latest".to_string(),
2001                "does-not-exist".to_string(),
2002            )),
2003            Query(StepPathQuery::default()),
2004        )
2005        .await
2006        .expect_err("unknown step must 404");
2007        assert_eq!(err.status, StatusCode::NOT_FOUND);
2008    }
2009
2010    // ─── Test 14: the old /ctx route is gone ────────────────────────────────
2011
2012    #[tokio::test]
2013    async fn old_ctx_route_returns_404_not_found_by_router() {
2014        let engine = Engine::new(EngineCfg::default());
2015        let router = mlua_swarm_server_router_for_test(engine);
2016        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
2017            .await
2018            .expect("bind ephemeral port");
2019        let addr = listener.local_addr().expect("local addr");
2020        tokio::spawn(async move {
2021            let _ = axum::serve(listener, router).await;
2022        });
2023        let client = reqwest::Client::new();
2024        let resp = client
2025            .get(format!("http://{addr}/v1/tasks/T-anything/ctx"))
2026            .send()
2027            .await
2028            .expect("request");
2029        assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND);
2030    }
2031
2032    /// Local alias so the test above reads as "the crate's router", without
2033    /// importing `crate::build_router` under a name that shadows this
2034    /// module's own items.
2035    fn mlua_swarm_server_router_for_test(engine: Engine) -> axum::Router {
2036        crate::build_router(engine)
2037    }
2038
2039    // ─── McpQueryAdapter: single-key resolve (still exercised standalone) ───
2040
2041    #[test]
2042    fn mcp_query_adapter_project_builds_query_ref() {
2043        let adapter = McpQueryAdapter::new(
2044            Arc::new(InMemoryOutputStore::new()),
2045            Arc::new(InMemoryRunStore::new()),
2046            Engine::new(EngineCfg::default()),
2047        );
2048        let key = ProjectionKey {
2049            task_id: "T-abc".to_string(),
2050            run_id: None,
2051            step: Some("planner".to_string()),
2052            path: None,
2053        };
2054        let ctx_data = json!({"planner": {"plan": "do it"}});
2055        let reference = adapter.project(&key, &ctx_data).expect("project");
2056        match &reference {
2057            ProjectionRef::Query { endpoint, key: k } => {
2058                assert!(endpoint.contains("/steps/planner/content"));
2059                assert_eq!(k, &key);
2060            }
2061            other => panic!("expected Query ref, got {other:?}"),
2062        }
2063        let line = adapter.pointer_line(&reference);
2064        assert!(line.contains("T-abc"));
2065    }
2066
2067    #[test]
2068    fn mcp_query_adapter_project_rejects_key_not_present_in_ctx_data() {
2069        let adapter = McpQueryAdapter::new(
2070            Arc::new(InMemoryOutputStore::new()),
2071            Arc::new(InMemoryRunStore::new()),
2072            Engine::new(EngineCfg::default()),
2073        );
2074        let key = ProjectionKey {
2075            task_id: "T-abc".to_string(),
2076            run_id: None,
2077            step: Some("missing".to_string()),
2078            path: None,
2079        };
2080        let err = adapter.project(&key, &json!({"planner": {}})).unwrap_err();
2081        assert!(matches!(err, ProjectionError::NotFound(_)));
2082    }
2083
2084    #[tokio::test(flavor = "multi_thread")]
2085    async fn mcp_query_adapter_fetch_bridges_to_resolve_async() {
2086        let state = test_state();
2087        let posted = crate::tasks_start(State(state.clone()), Json(greeting_task_req("bridged")))
2088            .await
2089            .expect("tasks_start")
2090            .0;
2091
2092        let adapter = McpQueryAdapter::new(
2093            state.data_store.clone(),
2094            state.run_store.clone(),
2095            state.engine.clone(),
2096        );
2097        let key = ProjectionKey {
2098            task_id: posted.task_id.to_string(),
2099            run_id: None,
2100            step: Some("out".to_string()),
2101            path: Some("echoed".to_string()),
2102        };
2103        // `fetch` is a sync trait method that bridges to `resolve_async`
2104        // via `block_in_place` + `Handle::block_on` — calling it directly
2105        // (not via `spawn_blocking`, which runs on the *blocking* pool
2106        // rather than a runtime worker thread and is not a valid
2107        // `block_in_place` call site) from this multi-thread-flavor test
2108        // task is exactly the context the bridge is built for (module
2109        // doc).
2110        let value = adapter.fetch(&key).expect("fetch");
2111        assert_eq!(value, json!("bridged"));
2112    }
2113
2114    // ─── subtask-4 / ST2 rework: Data-plane-backed, in-flight-safe query ───
2115
2116    /// Subtask 4 Test #5: path narrowing works against the Data-plane
2117    /// `Final` content — `AG_IDENTITY`'s own name (the producer_agent
2118    /// `Engine::submit_output`'s dual-write submits under) is queryable
2119    /// directly against the Run-scoped Data-plane store, narrowed by
2120    /// `path`. GH #23 subtask-3: `greeting_blueprint`'s flow.ir ctx-path
2121    /// segment `"out"` is now an ALIAS of this same canonical entry (not
2122    /// a separate `result_ref`-only name) — `mcp_query_adapter_fetch_bridges_to_resolve_async`
2123    /// above queries `"out"` and, since the table unifies it with
2124    /// `AG_IDENTITY`'s canonical entry, now resolves via this SAME
2125    /// Data-plane path too, not the `result_ref` fallback.
2126    #[tokio::test]
2127    async fn resolve_async_path_narrows_within_data_plane_final_content() {
2128        let state = test_state();
2129        let posted = crate::tasks_start(State(state.clone()), Json(greeting_task_req("hi")))
2130            .await
2131            .expect("tasks_start")
2132            .0;
2133
2134        let adapter = McpQueryAdapter::new(
2135            state.data_store.clone(),
2136            state.run_store.clone(),
2137            state.engine.clone(),
2138        );
2139        let key = ProjectionKey {
2140            task_id: posted.task_id.to_string(),
2141            run_id: None,
2142            step: Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string()),
2143            path: Some("echoed".to_string()),
2144        };
2145        let (_run, value) = adapter.resolve_async(&key).await.expect("resolve_async");
2146        assert_eq!(value, json!("hi"));
2147    }
2148
2149    /// Subtask 4 Test #1 (the in-flight scenario this rework exists for):
2150    /// a 2-step `Seq` flow where `step2` blocks on a gate until the test
2151    /// releases it. By the time `step2` has started, `step1`'s
2152    /// `dispatch_attempt_with` — and therefore its `submit_output` (and
2153    /// this rework's dual-write into the Data-plane store), plus its
2154    /// `RunRecord.step_entries` append — has unconditionally already
2155    /// completed (flow.ir's `Seq` awaits each child before starting the
2156    /// next), while the overall Run is still `Running` (not yet
2157    /// finalized). `GET /v1/tasks/:id/runs/:run/steps/step1` must return
2158    /// `step1`'s OUTPUT during that window.
2159    #[tokio::test(flavor = "multi_thread")]
2160    async fn steps_list_returns_in_flight_step_output_before_run_completes() {
2161        use mlua_flow_ir::{Expr, Node as FlowNode};
2162        use mlua_swarm::worker::adapter::WorkerResult;
2163        use mlua_swarm::{RustFnInProcessSpawnerFactory, SpawnerRegistry};
2164
2165        let started = Arc::new(tokio::sync::Notify::new());
2166        let gate = Arc::new(tokio::sync::Notify::new());
2167        let started_bg = started.clone();
2168        let gate_bg = gate.clone();
2169
2170        let factory = RustFnInProcessSpawnerFactory::new()
2171            .register_fn("step1", |inv| async move {
2172                Ok(WorkerResult {
2173                    value: json!({ "step1_out": inv.prompt }),
2174                    ok: true,
2175                })
2176            })
2177            .register_fn("step2", move |_inv| {
2178                let started = started_bg.clone();
2179                let gate = gate_bg.clone();
2180                async move {
2181                    started.notify_one();
2182                    gate.notified().await;
2183                    Ok(WorkerResult {
2184                        value: json!("step2 done"),
2185                        ok: true,
2186                    })
2187                }
2188            });
2189        let mut reg = SpawnerRegistry::new();
2190        reg.register::<RustFnInProcessSpawnerFactory>(Arc::new(factory));
2191
2192        let engine = Engine::new_with_layers(EngineCfg::default(), crate::default_layer_registry());
2193        let data_store: Arc<dyn mlua_swarm::store::output::OutputStore> =
2194            Arc::new(InMemoryOutputStore::new());
2195        engine.set_output_store(data_store.clone());
2196        let compiler = mlua_swarm::Compiler::new(reg);
2197        let launch = Arc::new(mlua_swarm::TaskLaunchService::new(engine.clone(), compiler));
2198        let state = AppState {
2199            engine,
2200            sessions: Arc::new(Mutex::new(crate::SessionStore::default())),
2201            task_app: Arc::new(mlua_swarm::TaskApplication::new_inline_only(launch)),
2202            ws_operator_factory: None,
2203            data_store,
2204            operator_sessions: Arc::new(Mutex::new(HashMap::new())),
2205            roles_to_sid: Arc::new(Mutex::new(HashMap::new())),
2206            task_store: Arc::new(InMemoryTaskStore::new()),
2207            run_store: Arc::new(InMemoryRunStore::new()),
2208            base_url: None,
2209        };
2210
2211        let flow = FlowNode::Seq {
2212            children: vec![
2213                FlowNode::Step {
2214                    ref_: "step1".to_string(),
2215                    in_: Expr::Path {
2216                        at: "$.greeting".to_string(),
2217                    },
2218                    out: Expr::Path {
2219                        at: "$.step1".to_string(),
2220                    },
2221                },
2222                FlowNode::Step {
2223                    ref_: "step2".to_string(),
2224                    in_: Expr::Path {
2225                        at: "$.step1".to_string(),
2226                    },
2227                    out: Expr::Path {
2228                        at: "$.step2".to_string(),
2229                    },
2230                },
2231            ],
2232        };
2233        let blueprint = Blueprint {
2234            schema_version: current_schema_version(),
2235            id: "projection-test-in-flight-bp".into(),
2236            flow,
2237            agents: vec![
2238                AgentDef {
2239                    name: "step1".into(),
2240                    kind: AgentKind::RustFn,
2241                    spec: json!({"fn_id": "step1"}),
2242                    profile: None,
2243                    meta: None,
2244                },
2245                AgentDef {
2246                    name: "step2".into(),
2247                    kind: AgentKind::RustFn,
2248                    spec: json!({"fn_id": "step2"}),
2249                    profile: None,
2250                    meta: None,
2251                },
2252            ],
2253            operators: vec![],
2254            metas: vec![],
2255            hints: CompilerHints::default(),
2256            strategy: CompilerStrategy::default(),
2257            metadata: BlueprintMetadata::default(),
2258            spawner_hints: Default::default(),
2259            default_agent_kind: AgentKind::Operator,
2260            default_operator_kind: None,
2261            default_init_ctx: None,
2262            default_agent_ctx: None,
2263            default_context_policy: None,
2264            projection_placement: None,
2265        };
2266
2267        let req = TaskLaunchRequest {
2268            blueprint: BlueprintRef::Inline {
2269                value: Box::new(blueprint),
2270            },
2271            init_ctx: json!({ "greeting": "hi" }),
2272            project_root: None,
2273            work_dir: None,
2274            task_metadata: None,
2275            ttl_secs: None,
2276            operator: None,
2277            operator_sid: None,
2278            goal: None,
2279        };
2280
2281        let state_bg = state.clone();
2282        let launch_handle =
2283            tokio::spawn(async move { crate::tasks_start(State(state_bg), Json(req)).await });
2284
2285        // step2 signals `started` only after step1's dispatch (and its
2286        // submit_output / Data-plane dual-write, and its step_entries
2287        // append) has fully returned — see the doc above.
2288        started.notified().await;
2289
2290        let in_flight_tasks = state.task_store.list().await.expect("task_store list");
2291        assert_eq!(in_flight_tasks.len(), 1, "exactly one Task minted");
2292        let task_id = in_flight_tasks[0].id.clone();
2293
2294        let resp = steps_list(
2295            State(state.clone()),
2296            Path((task_id.to_string(), "latest".to_string())),
2297        )
2298        .await
2299        .expect("steps_list while step2 is still in flight");
2300        let step1_entry = resp
2301            .steps
2302            .iter()
2303            .find(|s| s.name == "step1")
2304            .expect("step1 must already be visible");
2305        assert_eq!(step1_entry.source, ProjectionSource::DataPlane);
2306
2307        // Release step2 so the background `tasks_start` can complete and
2308        // the test can join it cleanly.
2309        gate.notify_one();
2310        let posted = launch_handle.await.expect("join").expect("tasks_start").0;
2311        assert_eq!(posted.final_ctx["step2"], json!("step2 done"));
2312    }
2313}