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