Skip to main content

mlua_swarm/core/
projection.rs

1//! `ProjectionAdapter` — pull-based supply of step OUTPUT data to
2//! downstream Agent steps, materialized from run [`Ctx`](crate::core::ctx::Ctx)
3//! state.
4//!
5//! # Architecture (ST5: role separation, MCP tool retired)
6//!
7//! Worker MCP dispatch stores each step's OUTPUT in the run ctx
8//! (`Ctx.data` while a run is live, `RunRecord.result_ref` once a Run has
9//! finalized on the server). `projection-adapter` ST5 settles this
10//! module's role split around two axes, replacing the ST2-ST4 single
11//! `mse_ctx_get` MCP tool:
12//!
13//! - **Worker axis (primary supply)** — a worker's `GET
14//!   /v1/worker/prompt` fetch payload carries
15//!   [`crate::core::agent_context::AgentContextView::steps`]:
16//!   `Vec<`[`crate::core::agent_context::StepPointer`]`>`, a
17//!   `ContextPolicy.steps`-filtered pointer list assembled automatically
18//!   at fetch time by `crates/mlua-swarm-server/src/worker.rs`. No
19//!   separate MCP tool call needed — a fetch already has every visible
20//!   prior step's pointer, pre-filtered by the Blueprint-declared
21//!   [`mlua_swarm_schema::ContextPolicy::steps`] / `steps_exclude`.
22//! - **HTTP debug plane (metadata + content)** — `crates/mlua-swarm-server/src/projection.rs`'s
23//!   `GET /v1/tasks/:id/runs/:run/steps*` REST hierarchy is the content
24//!   the pointers' `content_url` addresses, plus an unfiltered
25//!   metadata/content view for operators / humans debugging a run. The
26//!   old `mse_ctx_get` MCP tool (a manual pull wrapper over the ST2/ST4
27//!   `GET /v1/tasks/:id/ctx` single-value endpoint) is retired: its
28//!   entire reason for being — a way to pull a *prior* step's OUTPUT on
29//!   demand — is now automatic on the Worker axis.
30//!
31//! [`ProjectionAdapter::project`] turns a [`ProjectionKey`] (identifying a
32//! slice of run ctx by task / run / step / field path) plus the
33//! policy-filtered ctx data into a [`ProjectionRef`] locator; a caller
34//! later calls [`ProjectionAdapter::fetch`] to retrieve the actual value.
35//! The directive header only ever carries
36//! [`ProjectionAdapter::pointer_line`]'s 1-3 line pointer — never the
37//! projected value itself (no inline full-embed) — the SAME pointer-only
38//! discipline `AgentContextView.steps` follows on the Worker axis.
39//!
40//! The run ctx / `RunRecord.result_ref` stays the single source of truth;
41//! an adapter only decides *how the pull is served* — as a materialized
42//! file ([`FileProjectionAdapter`], this module, still used for the
43//! spawning agent's own `AgentContextView` — see
44//! `crates/mlua-swarm-server/src/operator_ws/session.rs`'s
45//! `append_projection_pointer`) or as an MCP query endpoint
46//! (`McpQueryAdapter`, server-side, see `ProjectionRef::Query`). Both
47//! adapters share the one [`ProjectionKey`] addressing type so callers
48//! never need to branch on which adapter backs a given pointer.
49//!
50//! # Submit-path projection (subtask-4 / ST2 rework, carried into ST5)
51//!
52//! The subtask-4 rework adds a *third*, submit-triggered supply path,
53//! sitting beside the two above rather than replacing either: the moment a
54//! worker's `Final` output lands in [`crate::core::engine::Engine::submit_output`]
55//! / `submit_worker_result_trusted` (the canonical worker-submit path, `POST
56//! /v1/worker/submit` and `/v1/worker/result`), the engine materializes that
57//! step's OUTPUT to the [`crate::core::projection_placement::ProjectionPlacement`]
58//! resolver's target (`<root>/<dir_template>/<canonical_agent>.md`; the
59//! byte-compat default layout is `workspace/tasks/<task_id>/ctx/`) via
60//! [`FileProjectionAdapter::materialize_submission`] — this is the file a
61//! Worker-axis `StepPointer.file_path` (when `Some`) and the HTTP debug
62//! plane's `StepSummary.file_path` both address, so a *later* Agent step
63//! (or an operator, over HTTP) can read a *prior* step's OUTPUT while the
64//! overall run is still in flight, without waiting for
65//! `RunRecord.result_ref` to be set at finalization. `<root>` is resolved
66//! from the [`crate::core::agent_context::AgentContextView`]
67//! `AgentContextMiddleware` snapshotted at spawn time, via
68//! [`ProjectionPlacement::resolve_root`] (`work_dir` falling back to
69//! `project_root` for the byte-compat default preference); this sink is
70//! best-effort (fail-open — see the Invariants on `Engine::submit_output`'s
71//! doc): an unresolved root, or a `Final` from a spawn that never ran
72//! through that middleware, is a silent no-op, never a submit failure.
73//!
74//! `<canonical_agent>` (GH #23) is `producer_agent` (`TaskState.spec.agent`)
75//! resolved through `Engine::step_naming_for(task_id)`'s
76//! `crate::core::step_naming::StepNaming::canonical_of_producer` when a
77//! table was snapshotted for this task's dispatch — `producer_agent`
78//! unchanged (byte-identical file stem to pre-GH-#23 behavior) for any
79//! step whose `AgentMeta.projection_name` is undeclared, or when no table
80//! exists for this `task_id` at all.
81//!
82//! # Design: addressing
83//!
84//! [`ProjectionKey`] combines three independent axes — ID (`task_id` /
85//! `run_id`), Name (`step`), and Path (`path`, a `$.a.b`-style dot path
86//! into the step's OUTPUT value) — so a caller can address anything from
87//! "the whole run ctx" down to "one field of one step's OUTPUT" with the
88//! same type. [`ProjectionKey::resolve`] is the pure, adapter-independent
89//! implementation of that narrowing, shared by every adapter.
90
91use crate::core::projection_placement::ProjectionPlacement;
92use serde::{Deserialize, Serialize};
93use serde_json::Value;
94use std::fs;
95use std::path::PathBuf;
96use thiserror::Error;
97
98/// Addressing for one projection target. ID axis (`task_id` / `run_id`) +
99/// Name axis (`step`) + Path axis (`path`) — the one addressing type both
100/// [`FileProjectionAdapter`] and the future `McpQueryAdapter` (ST2) accept.
101#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
102pub struct ProjectionKey {
103    /// Task identity (`StepId`'s `Display` string form, the same shape
104    /// `AgentContextView.task_id` uses).
105    pub task_id: String,
106    /// Run identity. `None` means "the latest run".
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub run_id: Option<String>,
109    /// Step / agent name — the key directly under `ctx.data` this
110    /// projection narrows to. `None` means "the whole ctx".
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub step: Option<String>,
113    /// Field path within the step's value, `$.a.b` dot-path form (the
114    /// leading `$.` is optional). `None` means "the whole step value".
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub path: Option<String>,
117}
118
119impl ProjectionKey {
120    /// Pure narrowing helper: resolves `self` against `ctx_data`, first by
121    /// `step` (a direct key lookup) then by `path` (a `.`-separated walk of
122    /// nested object fields, or — GH #36 ST2 — RFC 9535-style bracket
123    /// notation for keys containing a literal `.`, e.g.
124    /// `$.parts["plan.md"]`). Returns `None` if any segment is absent, or
125    /// if a `[`-containing `path` is malformed — this is a pure lookup,
126    /// not a fallible parse (an unparseable `path` simply yields no match
127    /// rather than an error, matching the `Option`-returning contract
128    /// callers expect from a resolve step).
129    ///
130    /// Bracket syntax is kept in lockstep with `flow-ir-core::read_path`
131    /// (mlua-flow-ir 0.1.2)'s `parse_path_segments`: bracket segments
132    /// `["name"]` (double-quoted, no escaping — a literal `"` inside a
133    /// name cannot be represented) may be chained directly
134    /// (`$.a["x"]["y"]`) or combined with `.`-separated plain segments in
135    /// either order (`$.a["x"].b`, `$["x.y"].inner`). A `path` containing
136    /// no `[` takes the original dot-split code path unchanged — no
137    /// behavioural change for existing callers.
138    pub fn resolve<'a>(&self, ctx_data: &'a Value) -> Option<&'a Value> {
139        let mut current = match &self.step {
140            Some(step) => ctx_data.get(step)?,
141            None => ctx_data,
142        };
143        if let Some(path) = &self.path {
144            let path = path.strip_prefix("$.").unwrap_or(path.as_str());
145            if path.contains('[') {
146                let segments = parse_bracket_path_segments(path)?;
147                for segment in &segments {
148                    current = current.get(segment)?;
149                }
150            } else {
151                for segment in path.split('.').filter(|s| !s.is_empty()) {
152                    current = current.get(segment)?;
153                }
154            }
155        }
156        Some(current)
157    }
158
159    /// Filename-safe step slug for [`FileProjectionAdapter`]'s materialize
160    /// target — `step` verbatim, or `"_ctx"` when addressing the whole
161    /// ctx (`step` is `None`).
162    fn step_slug(&self) -> &str {
163        self.step.as_deref().unwrap_or("_ctx")
164    }
165}
166
167/// Parses a (`$.`-prefix-stripped, non-empty, `[`-containing) path into its
168/// object-key segments — the same RFC 9535-style bracket-notation syntax as
169/// `flow-ir-core::read_path`'s private `parse_path_segments` (mlua-flow-ir
170/// 0.1.2; syntax kept in lockstep by hand, not by sharing code — that
171/// helper is private to its crate). Supports:
172///
173/// - plain segment: any run of chars excluding `.` and `[`, non-empty.
174/// - bracket segment: `["<name>"]`, where `<name>` is one or more chars
175///   excluding `"` (no escape support — a key containing `"` is rejected).
176/// - plain segments are `.`-separated; a bracket segment may follow
177///   directly after the previous segment (`a["x"]`) or after a `.`
178///   (`a.["x"]`), and a bracket segment may itself be followed directly by
179///   another bracket (`a["x"]["y"]`) or by a `.` before the next plain
180///   segment (`a["x"].b`).
181///
182/// Returns `None` for any malformed sequence (unterminated bracket, missing
183/// quote, empty key, empty segment, bracket directly followed by an
184/// unseparated plain segment, ...) — [`ProjectionKey::resolve`] is a pure
185/// `Option`-returning lookup, not a fallible parse, so this never panics
186/// and never silently mis-segments; a malformed path is simply "no match".
187fn parse_bracket_path_segments(trimmed: &str) -> Option<Vec<String>> {
188    let bytes = trimmed.as_bytes();
189    let len = bytes.len();
190    let mut segments = Vec::new();
191    let mut i = 0usize;
192    // true at path start and immediately after a `.`: the next byte must
193    // begin a new segment (plain or bracket), not another `.` or EOF.
194    let mut expect_segment_start = true;
195
196    while i < len {
197        match bytes[i] {
198            b'[' => {
199                if i + 1 >= len || bytes[i + 1] != b'"' {
200                    return None;
201                }
202                let name_start = i + 2;
203                let mut j = name_start;
204                while j < len && bytes[j] != b'"' {
205                    j += 1;
206                }
207                if j >= len {
208                    return None;
209                }
210                let name = &trimmed[name_start..j];
211                if name.is_empty() {
212                    return None;
213                }
214                if j + 1 >= len || bytes[j + 1] != b']' {
215                    return None;
216                }
217                segments.push(name.to_string());
218                i = j + 2;
219                expect_segment_start = false;
220                // Only `.` or another `[` (or EOF) may directly follow a
221                // bracket segment — a bare plain-segment continuation
222                // (`a["x"]b`) is ambiguous and rejected.
223                if i < len && bytes[i] != b'.' && bytes[i] != b'[' {
224                    return None;
225                }
226            }
227            b'.' => {
228                if expect_segment_start {
229                    return None;
230                }
231                i += 1;
232                expect_segment_start = true;
233                if i >= len {
234                    return None;
235                }
236            }
237            _ => {
238                let start = i;
239                while i < len && bytes[i] != b'.' && bytes[i] != b'[' {
240                    i += 1;
241                }
242                segments.push(trimmed[start..i].to_string());
243                expect_segment_start = false;
244            }
245        }
246    }
247
248    if expect_segment_start {
249        return None;
250    }
251
252    Some(segments)
253}
254
255/// [`ProjectionAdapter::project`]'s return value — the locator a worker
256/// uses to pull the projected value.
257#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
258pub enum ProjectionRef {
259    /// [`FileProjectionAdapter`]: absolute path to the materialized file.
260    File {
261        /// Absolute filesystem path of the materialized projection file.
262        path: String,
263    },
264    /// `McpQueryAdapter` (server-side): a fetch endpoint plus the key to
265    /// query it with.
266    Query {
267        /// Fetch endpoint (e.g. `GET
268        /// /v1/tasks/:id/runs/:run/steps/:step/content`, ST5) the adapter
269        /// serves this projection through.
270        endpoint: String,
271        /// Key identifying which projection to fetch at `endpoint`.
272        key: ProjectionKey,
273    },
274}
275
276/// All ways a [`ProjectionAdapter`] operation can fail.
277#[derive(Debug, Error)]
278pub enum ProjectionError {
279    /// No value exists for the given key — either `project()` could not
280    /// resolve `key` against the supplied ctx data, or `fetch()` found no
281    /// materialized projection for it.
282    #[error("projection not found for key {0:?}")]
283    NotFound(ProjectionKey),
284    /// A filesystem operation (materialize write / read-back) failed.
285    #[error("projection io error: {0}")]
286    Io(#[from] std::io::Error),
287    /// `key` is structurally invalid for this adapter (e.g. an empty
288    /// `task_id`).
289    #[error("invalid projection key: {0}")]
290    InvalidKey(String),
291    /// Serializing the projected value to its on-disk/on-wire form, or
292    /// deserializing it back, failed.
293    #[error("projection serialize error: {0}")]
294    Serialize(String),
295}
296
297/// Context projection supply abstraction. The single source of truth is
298/// the run ctx passed into [`Self::project`]; an adapter only decides
299/// *how the pull is served* (module doc has the full narrative).
300pub trait ProjectionAdapter: Send + Sync {
301    /// Stable adapter name (`"file"` / `"mcp-query"`), used in log lines
302    /// and diagnostics.
303    fn name(&self) -> &'static str;
304
305    /// Projects the slice of `ctx_data` addressed by `key` and returns a
306    /// locator a worker can later [`Self::fetch`]. `ctx_data` is the
307    /// policy-filtered ctx data slice this projection is drawn from — the
308    /// adapter never mutates it.
309    fn project(
310        &self,
311        key: &ProjectionKey,
312        ctx_data: &Value,
313    ) -> Result<ProjectionRef, ProjectionError>;
314
315    /// Pulls the value addressed by `key` directly, without going through
316    /// a previously returned [`ProjectionRef`] locator.
317    fn fetch(&self, key: &ProjectionKey) -> Result<Value, ProjectionError>;
318
319    /// Renders the 1-3 line pointer this adapter's [`ProjectionRef`]
320    /// contributes to the directive header. Must never embed the
321    /// projected value itself (inline full-embed is the exact problem
322    /// projection exists to avoid — see the module doc).
323    fn pointer_line(&self, r: &ProjectionRef) -> String;
324}
325
326/// File-backed [`ProjectionAdapter`]: materializes the projected value
327/// under `root`, at the target [`ProjectionPlacement::target_path`]
328/// resolves for `key`, and reads it back on [`Self::fetch`]. [`Self::new`]
329/// resolves through [`ProjectionPlacement::default`] (the pre-GH-#27
330/// hardcoded `<root>/workspace/tasks/<task_id>/ctx/<step-or-_ctx>.md`
331/// layout, unchanged); [`Self::with_placement`] resolves through a
332/// caller-supplied resolver instead — see
333/// `crate::core::projection_placement`'s module doc for the "3 path"
334/// convergence this collapses.
335pub struct FileProjectionAdapter {
336    root: PathBuf,
337    placement: ProjectionPlacement,
338}
339
340impl FileProjectionAdapter {
341    /// Builds an adapter rooted at `root` (typically the resolved
342    /// `work_dir` / `project_root`) using the byte-compat
343    /// [`ProjectionPlacement::default`] resolver.
344    pub fn new(root: impl Into<PathBuf>) -> Self {
345        Self {
346            root: root.into(),
347            placement: ProjectionPlacement::default(),
348        }
349    }
350
351    /// Builds an adapter rooted at `root`, resolving materialize targets
352    /// through the given `placement` instead of the byte-compat default —
353    /// the constructor every one of the "3 path" call sites
354    /// (`crate::core::projection_placement`'s module doc) uses once they
355    /// hold a Blueprint-resolved [`ProjectionPlacement`].
356    pub fn with_placement(root: impl Into<PathBuf>, placement: ProjectionPlacement) -> Self {
357        Self {
358            root: root.into(),
359            placement,
360        }
361    }
362
363    /// The materialize target for `key`, resolved via
364    /// [`ProjectionPlacement::target_path`].
365    fn target_path(&self, key: &ProjectionKey) -> PathBuf {
366        self.placement
367            .target_path(&self.root, &key.task_id, key.step_slug())
368    }
369
370    /// Submit-path materialize (subtask-4 / ST2 rework — see the module
371    /// doc's "Submit-path projection" section). Unlike [`Self::project`],
372    /// `value` is not narrowed out of a larger `ctx_data` via
373    /// [`ProjectionKey::resolve`] — it is the exact submitted content, so
374    /// this writes it directly. Reuses [`Self::target_path`] (the same
375    /// [`ProjectionPlacement`]-resolved target — byte-compat default
376    /// `<root>/workspace/tasks/<task_id>/ctx/<step>.md` — as
377    /// [`Self::project`]), so a later [`Self::fetch`] against the same
378    /// `key` reads it back unchanged (`fetch` only parses the fenced
379    /// ```` ```json ```` block, so the extra `attempt` / `ok` front-matter
380    /// fields this writes are inert to it). A full replace, never append —
381    /// re-submitting the same `(task_id, producer_agent)` overwrites
382    /// (idempotent, latest wins — Subtask 4 Invariant 2 / Test 4).
383    ///
384    /// `key.step` must be `Some(producer_agent)` — this is a submission
385    /// slot, never "the whole ctx" (`step: None` still resolves to a valid
386    /// path via [`ProjectionKey::step_slug`]'s `"_ctx"` fallback, but a
387    /// caller addressing an actual submission should always name the
388    /// producing agent).
389    pub fn materialize_submission(
390        &self,
391        key: &ProjectionKey,
392        value: &Value,
393        attempt: u32,
394        ok: bool,
395    ) -> Result<ProjectionRef, ProjectionError> {
396        if key.task_id.is_empty() {
397            return Err(ProjectionError::InvalidKey(
398                "task_id must not be empty".to_string(),
399            ));
400        }
401        let target = self.target_path(key);
402        if let Some(parent) = target.parent() {
403            fs::create_dir_all(parent)?;
404        }
405        fs::write(&target, render_submission_file(key, value, attempt, ok)?)?;
406        Ok(ProjectionRef::File {
407            path: target.to_string_lossy().into_owned(),
408        })
409    }
410
411    /// Submit-path materialize for a *staged named part* — the file half
412    /// of the `Artifact` staging sink, sibling
413    /// of [`Self::materialize_submission`]. Where `materialize_submission`
414    /// wraps a `Final` OUTPUT in the YAML front-matter + fenced-JSON
415    /// convention [`Self::fetch`] parses back, this writes a part's content
416    /// **raw**, because a part file is not a `fetch`-round-tripped
417    /// projection: it is the literal IN file the *next* Agent step reads. A
418    /// part named `plan.md` is the plan document itself, byte-for-byte, not
419    /// a JSON envelope around it — so a `Value::String` lands as its own
420    /// bytes with no front matter and no fenced wrapper; any other `Value`
421    /// is rendered as pretty JSON (the best faithful text form for a
422    /// non-string part).
423    ///
424    /// `name` is written **verbatim** as the file name — unlike
425    /// [`Self::target_path`]'s `<stem>.md` synthesis, a part's `name`
426    /// already carries its own extension (`plan.md`), so it IS the file
427    /// name. Because `name` is caller-supplied and joined onto the ctx
428    /// directory, it is guarded first: it must be a plain file name — empty,
429    /// `.`, `..`, or any `name` containing `/` or `\` is rejected with
430    /// [`ProjectionError::InvalidKey`], structurally closing every path that
431    /// could escape [`ProjectionPlacement::target_dir`].
432    ///
433    /// A full replace, never append — re-staging the same `name` overwrites
434    /// (idempotent, latest wins — matching the fold's last-write-wins per
435    /// name).
436    pub fn materialize_part(
437        &self,
438        task_id: &str,
439        name: &str,
440        value: &Value,
441    ) -> Result<ProjectionRef, ProjectionError> {
442        if task_id.is_empty() {
443            return Err(ProjectionError::InvalidKey(
444                "task_id must not be empty".to_string(),
445            ));
446        }
447        if name.is_empty()
448            || name == "."
449            || name == ".."
450            || name.contains('/')
451            || name.contains('\\')
452        {
453            return Err(ProjectionError::InvalidKey(format!(
454                "part name must be a plain file name (no '/', '\\', '.', or '..'): {name:?}"
455            )));
456        }
457        let target = self.placement.target_dir(&self.root, task_id).join(name);
458        if let Some(parent) = target.parent() {
459            fs::create_dir_all(parent)?;
460        }
461        let body: Vec<u8> = match value {
462            Value::String(s) => s.as_bytes().to_vec(),
463            other => serde_json::to_string_pretty(other)
464                .map_err(|err| ProjectionError::Serialize(err.to_string()))?
465                .into_bytes(),
466        };
467        fs::write(&target, body)?;
468        Ok(ProjectionRef::File {
469            path: target.to_string_lossy().into_owned(),
470        })
471    }
472}
473
474/// Front matter for a submit-time materialized projection file
475/// ([`FileProjectionAdapter::materialize_submission`]) — the same
476/// addressing fields as [`ProjectionKey`], flattened, plus the
477/// submission's own `attempt` / `ok`. No `out_id`: `Engine::submit_output`
478/// / `submit_worker_result_trusted` do not allocate one (that only happens
479/// on the separate `POST /v1/data/emit` Data-plane path — see
480/// `crate::store::output`'s module doc) — so there is nothing to carry
481/// here.
482#[derive(Serialize)]
483struct SubmissionFrontMatter<'a> {
484    #[serde(flatten)]
485    key: &'a ProjectionKey,
486    /// The submitting attempt number.
487    attempt: u32,
488    /// The submission's transport-level success flag (`OutputEvent::Final.ok`).
489    ok: bool,
490}
491
492/// Renders a submit-time materialized projection file — same fenced-JSON
493/// body convention as [`render_projection_file`], with a front matter that
494/// additionally carries `attempt` / `ok` (see [`SubmissionFrontMatter`]).
495fn render_submission_file(
496    key: &ProjectionKey,
497    value: &Value,
498    attempt: u32,
499    ok: bool,
500) -> Result<String, ProjectionError> {
501    let front = SubmissionFrontMatter { key, attempt, ok };
502    let front_matter =
503        serde_yaml::to_string(&front).map_err(|err| ProjectionError::Serialize(err.to_string()))?;
504    let body = serde_json::to_string_pretty(value)
505        .map_err(|err| ProjectionError::Serialize(err.to_string()))?;
506    Ok(format!("---\n{front_matter}---\n\n```json\n{body}\n```\n"))
507}
508
509impl ProjectionAdapter for FileProjectionAdapter {
510    fn name(&self) -> &'static str {
511        "file"
512    }
513
514    fn project(
515        &self,
516        key: &ProjectionKey,
517        ctx_data: &Value,
518    ) -> Result<ProjectionRef, ProjectionError> {
519        if key.task_id.is_empty() {
520            return Err(ProjectionError::InvalidKey(
521                "task_id must not be empty".to_string(),
522            ));
523        }
524        let value = key
525            .resolve(ctx_data)
526            .ok_or_else(|| ProjectionError::NotFound(key.clone()))?;
527        let target = self.target_path(key);
528        if let Some(parent) = target.parent() {
529            fs::create_dir_all(parent)?;
530        }
531        // Full replace, never append — re-projecting the same key is
532        // idempotent (invariant 3).
533        fs::write(&target, render_projection_file(key, value)?)?;
534        Ok(ProjectionRef::File {
535            path: target.to_string_lossy().into_owned(),
536        })
537    }
538
539    fn fetch(&self, key: &ProjectionKey) -> Result<Value, ProjectionError> {
540        let target = self.target_path(key);
541        let body = fs::read_to_string(&target).map_err(|err| {
542            if err.kind() == std::io::ErrorKind::NotFound {
543                ProjectionError::NotFound(key.clone())
544            } else {
545                ProjectionError::Io(err)
546            }
547        })?;
548        parse_projection_file(&body)
549    }
550
551    fn pointer_line(&self, r: &ProjectionRef) -> String {
552        match r {
553            ProjectionRef::File { path } => format!("projection(file): {path}"),
554            ProjectionRef::Query { endpoint, key } => {
555                format!("projection(mcp-query): {endpoint} task_id={}", key.task_id)
556            }
557        }
558    }
559}
560
561/// Renders a materialized projection file: a `key`-describing YAML
562/// front-matter header followed by the projected `value` as a fenced JSON
563/// block ([`parse_projection_file`] reads the fence back out).
564fn render_projection_file(key: &ProjectionKey, value: &Value) -> Result<String, ProjectionError> {
565    let front_matter =
566        serde_yaml::to_string(key).map_err(|err| ProjectionError::Serialize(err.to_string()))?;
567    let body = serde_json::to_string_pretty(value)
568        .map_err(|err| ProjectionError::Serialize(err.to_string()))?;
569    Ok(format!("---\n{front_matter}---\n\n```json\n{body}\n```\n"))
570}
571
572/// Reads a materialized projection file back into its JSON value, the
573/// inverse of [`render_projection_file`]. Only the fenced ```` ```json ````
574/// block is parsed; the front-matter header is documentation for a human
575/// reader and is not required for the round trip.
576fn parse_projection_file(text: &str) -> Result<Value, ProjectionError> {
577    const FENCE_OPEN: &str = "```json";
578    const FENCE_CLOSE: &str = "```";
579    let after_open = text.find(FENCE_OPEN).ok_or_else(|| {
580        ProjectionError::Serialize("materialized projection file missing ```json block".into())
581    })?;
582    let body_start = after_open + FENCE_OPEN.len();
583    let close_offset = text[body_start..].find(FENCE_CLOSE).ok_or_else(|| {
584        ProjectionError::Serialize("materialized projection file missing closing ``` fence".into())
585    })?;
586    let json_body = text[body_start..body_start + close_offset].trim();
587    serde_json::from_str(json_body).map_err(|err| ProjectionError::Serialize(err.to_string()))
588}
589
590#[cfg(test)]
591mod tests {
592    use super::*;
593    use crate::core::projection_placement::RootPreference;
594    use serde_json::json;
595    use std::path::Path;
596    use tempfile::TempDir;
597
598    fn sample_ctx_data() -> Value {
599        json!({
600            "planner": {
601                "plan": "do the thing",
602                "nested": { "field": 42 }
603            },
604            "other_step": { "value": "x" }
605        })
606    }
607
608    fn key(step: Option<&str>, path: Option<&str>) -> ProjectionKey {
609        ProjectionKey {
610            task_id: "T-1".to_string(),
611            run_id: None,
612            step: step.map(str::to_string),
613            path: path.map(str::to_string),
614        }
615    }
616
617    #[test]
618    fn resolve_step_only_returns_step_value() {
619        let ctx_data = sample_ctx_data();
620        let resolved = key(Some("planner"), None).resolve(&ctx_data).unwrap();
621        assert_eq!(
622            resolved,
623            &json!({"plan": "do the thing", "nested": {"field": 42}})
624        );
625    }
626
627    #[test]
628    fn resolve_step_and_path_narrows_to_field() {
629        let ctx_data = sample_ctx_data();
630        let resolved = key(Some("planner"), Some("$.nested.field"))
631            .resolve(&ctx_data)
632            .unwrap();
633        assert_eq!(resolved, &json!(42));
634    }
635
636    #[test]
637    fn resolve_missing_step_returns_none() {
638        assert!(key(Some("does-not-exist"), None)
639            .resolve(&sample_ctx_data())
640            .is_none());
641    }
642
643    #[test]
644    fn resolve_missing_path_returns_none() {
645        assert!(key(Some("planner"), Some("$.nested.missing"))
646            .resolve(&sample_ctx_data())
647            .is_none());
648    }
649
650    // --- GH #36 ST2: bracket-notation path resolve tests ---
651
652    fn parts_ctx_data() -> Value {
653        json!({
654            "worker_step": {
655                "out": "final answer",
656                "parts": {
657                    "plan.md": "the plan",
658                    "notes": { "todo": "ship it" }
659                }
660            }
661        })
662    }
663
664    #[test]
665    fn resolve_bracket_path_reads_key_with_literal_dot() {
666        let ctx_data = parts_ctx_data();
667        let resolved = key(Some("worker_step"), Some("$.parts[\"plan.md\"]"))
668            .resolve(&ctx_data)
669            .unwrap();
670        assert_eq!(resolved, &json!("the plan"));
671    }
672
673    #[test]
674    fn resolve_bracket_path_chained_brackets() {
675        let ctx_data = json!({
676            "s": { "a": { "x.y": { "z.w": "chained" } } }
677        });
678        let resolved = key(Some("s"), Some("$.a[\"x.y\"][\"z.w\"]"))
679            .resolve(&ctx_data)
680            .unwrap();
681        assert_eq!(resolved, &json!("chained"));
682    }
683
684    #[test]
685    fn resolve_bracket_path_followed_by_dot_segment() {
686        let ctx_data = parts_ctx_data();
687        let resolved = key(Some("worker_step"), Some("$.parts[\"notes\"].todo"))
688            .resolve(&ctx_data)
689            .unwrap();
690        assert_eq!(resolved, &json!("ship it"));
691    }
692
693    /// Existing dot-only paths (no `[`) must resolve byte-for-byte
694    /// identically after the bracket-notation addition — this is the
695    /// regression guard for "paths without `[` take the original
696    /// dot-split code path unchanged".
697    #[test]
698    fn resolve_dot_only_path_unchanged_by_bracket_support() {
699        let ctx_data = sample_ctx_data();
700        let resolved = key(Some("planner"), Some("$.nested.field"))
701            .resolve(&ctx_data)
702            .unwrap();
703        assert_eq!(resolved, &json!(42));
704    }
705
706    #[test]
707    fn resolve_bracket_path_missing_key_returns_none() {
708        assert!(
709            key(Some("worker_step"), Some("$.parts[\"does-not-exist\"]"))
710                .resolve(&parts_ctx_data())
711                .is_none()
712        );
713    }
714
715    #[test]
716    fn resolve_malformed_bracket_path_returns_none() {
717        let ctx_data = parts_ctx_data();
718        for malformed in [
719            "$.parts[\"plan.md\"",   // unterminated bracket (missing ])
720            "$.parts[plan.md]",      // missing quotes
721            "$.parts[\"\"]",         // empty key
722            "$.parts[\"plan.md\"]x", // unseparated plain-segment continuation
723            "$..parts[\"plan.md\"]", // empty segment (leading '.' right after prefix strip)
724        ] {
725            assert!(
726                key(Some("worker_step"), Some(malformed))
727                    .resolve(&ctx_data)
728                    .is_none(),
729                "expected None for malformed path {malformed:?}"
730            );
731        }
732    }
733
734    #[test]
735    fn file_adapter_project_then_fetch_round_trips() {
736        let dir = TempDir::new().unwrap();
737        let adapter = FileProjectionAdapter::new(dir.path());
738        let k = key(Some("planner"), None);
739        let ctx_data = sample_ctx_data();
740
741        let reference = adapter.project(&k, &ctx_data).unwrap();
742        let path = match &reference {
743            ProjectionRef::File { path } => path.clone(),
744            other => panic!("expected File ref, got {other:?}"),
745        };
746        assert!(Path::new(&path).exists());
747
748        let fetched = adapter.fetch(&k).unwrap();
749        assert_eq!(&fetched, k.resolve(&ctx_data).unwrap());
750    }
751
752    /// GH #27 (follow-up to #23): `with_placement` resolves the target
753    /// through the given `ProjectionPlacement` instead of the byte-compat
754    /// default — the file lands at the custom `dir_template`, and a
755    /// `fetch` against the SAME key/adapter still round-trips (write and
756    /// read agree by construction, since both go through the same
757    /// resolver instance).
758    #[test]
759    fn file_adapter_with_placement_uses_custom_dir_template() {
760        let dir = TempDir::new().unwrap();
761        let placement = ProjectionPlacement {
762            root_preference: RootPreference::WorkDir,
763            dir_template: "custom/{task_id}/out".to_string(),
764        };
765        let adapter = FileProjectionAdapter::with_placement(dir.path(), placement);
766        let k = key(Some("planner"), None);
767        let ctx_data = sample_ctx_data();
768
769        let reference = adapter.project(&k, &ctx_data).unwrap();
770        let path = match &reference {
771            ProjectionRef::File { path } => path.clone(),
772            other => panic!("expected File ref, got {other:?}"),
773        };
774        assert!(
775            path.ends_with("custom/T-1/out/planner.md"),
776            "path must follow the custom dir_template: {path}"
777        );
778        assert!(Path::new(&path).exists());
779
780        let fetched = adapter.fetch(&k).unwrap();
781        assert_eq!(&fetched, k.resolve(&ctx_data).unwrap());
782    }
783
784    #[test]
785    fn file_adapter_reproject_is_idempotent() {
786        let dir = TempDir::new().unwrap();
787        let adapter = FileProjectionAdapter::new(dir.path());
788        let k = key(Some("planner"), None);
789        let ctx_data = sample_ctx_data();
790
791        let first = adapter.project(&k, &ctx_data).unwrap();
792        let second = adapter.project(&k, &ctx_data).unwrap();
793        assert_eq!(first, second);
794        assert_eq!(adapter.fetch(&k).unwrap(), adapter.fetch(&k).unwrap());
795    }
796
797    #[test]
798    fn pointer_line_carries_path_not_value() {
799        let reference = ProjectionRef::File {
800            path: "/tmp/some/materialized.md".to_string(),
801        };
802        let adapter = FileProjectionAdapter::new("/unused");
803        let line = adapter.pointer_line(&reference);
804        assert!(line.contains("/tmp/some/materialized.md"));
805        assert!(!line.contains('{'));
806    }
807
808    #[test]
809    fn fetch_missing_projection_returns_not_found() {
810        let dir = TempDir::new().unwrap();
811        let adapter = FileProjectionAdapter::new(dir.path());
812        let k = key(Some("nope"), None);
813
814        let err = adapter.fetch(&k).unwrap_err();
815        assert!(matches!(err, ProjectionError::NotFound(_)));
816    }
817
818    #[test]
819    fn project_rejects_empty_task_id() {
820        let dir = TempDir::new().unwrap();
821        let adapter = FileProjectionAdapter::new(dir.path());
822        let mut k = key(Some("planner"), None);
823        k.task_id = String::new();
824
825        let err = adapter.project(&k, &sample_ctx_data()).unwrap_err();
826        assert!(matches!(err, ProjectionError::InvalidKey(_)));
827    }
828
829    // ─── subtask-4 / ST2 rework: FileProjectionAdapter::materialize_submission ───
830
831    #[test]
832    fn materialize_submission_writes_value_directly_and_fetch_reads_it_back() {
833        let dir = TempDir::new().unwrap();
834        let adapter = FileProjectionAdapter::new(dir.path());
835        let k = key(Some("planner"), None);
836        let value = json!({"plan": "do the thing"});
837
838        let reference = adapter.materialize_submission(&k, &value, 1, true).unwrap();
839        let path = match &reference {
840            ProjectionRef::File { path } => path.clone(),
841            other => panic!("expected File ref, got {other:?}"),
842        };
843        assert!(Path::new(&path).exists());
844
845        // fetch() only parses the fenced ```json block, so the submission
846        // front matter (attempt / ok) is inert to it — same round trip
847        // contract as `project`.
848        let fetched = adapter.fetch(&k).unwrap();
849        assert_eq!(fetched, value);
850    }
851
852    #[test]
853    fn materialize_submission_front_matter_carries_attempt_and_ok() {
854        let dir = TempDir::new().unwrap();
855        let adapter = FileProjectionAdapter::new(dir.path());
856        let k = key(Some("reviewer"), None);
857
858        let reference = adapter
859            .materialize_submission(&k, &json!("hi"), 2, false)
860            .unwrap();
861        let path = match &reference {
862            ProjectionRef::File { path } => path.clone(),
863            other => panic!("expected File ref, got {other:?}"),
864        };
865        let body = std::fs::read_to_string(path).unwrap();
866        assert!(body.contains("attempt: 2"), "front matter: {body}");
867        assert!(body.contains("ok: false"), "front matter: {body}");
868        assert!(body.contains("step: reviewer"), "front matter: {body}");
869    }
870
871    #[test]
872    fn materialize_submission_resubmit_overwrites_with_latest() {
873        let dir = TempDir::new().unwrap();
874        let adapter = FileProjectionAdapter::new(dir.path());
875        let k = key(Some("planner"), None);
876
877        adapter
878            .materialize_submission(&k, &json!("first"), 1, true)
879            .unwrap();
880        adapter
881            .materialize_submission(&k, &json!("second"), 1, true)
882            .unwrap();
883
884        assert_eq!(adapter.fetch(&k).unwrap(), json!("second"));
885    }
886
887    #[test]
888    fn materialize_submission_rejects_empty_task_id() {
889        let dir = TempDir::new().unwrap();
890        let adapter = FileProjectionAdapter::new(dir.path());
891        let mut k = key(Some("planner"), None);
892        k.task_id = String::new();
893
894        let err = adapter
895            .materialize_submission(&k, &json!("x"), 1, true)
896            .unwrap_err();
897        assert!(matches!(err, ProjectionError::InvalidKey(_)));
898    }
899
900    // ─── staged named parts: FileProjectionAdapter::materialize_part ───
901
902    #[test]
903    fn materialize_part_writes_raw_string_content() {
904        let dir = TempDir::new().unwrap();
905        let adapter = FileProjectionAdapter::new(dir.path());
906
907        let reference = adapter
908            .materialize_part("T-1", "plan.md", &json!("# The Plan\n\ndo the thing\n"))
909            .unwrap();
910        let path = match &reference {
911            ProjectionRef::File { path } => path.clone(),
912            other => panic!("expected File ref, got {other:?}"),
913        };
914        let body = std::fs::read_to_string(path).unwrap();
915        // Raw content — no YAML front matter, no ```json fence.
916        assert_eq!(body, "# The Plan\n\ndo the thing\n");
917        assert!(!body.contains("---"), "no front matter: {body}");
918        assert!(!body.contains("```json"), "no fenced json: {body}");
919    }
920
921    #[test]
922    fn materialize_part_writes_json_pretty_for_non_string() {
923        let dir = TempDir::new().unwrap();
924        let adapter = FileProjectionAdapter::new(dir.path());
925
926        let reference = adapter
927            .materialize_part("T-1", "meta.json", &json!({"k": "v", "n": 1}))
928            .unwrap();
929        let path = match &reference {
930            ProjectionRef::File { path } => path.clone(),
931            other => panic!("expected File ref, got {other:?}"),
932        };
933        let body = std::fs::read_to_string(path).unwrap();
934        let expected = serde_json::to_string_pretty(&json!({"k": "v", "n": 1})).unwrap();
935        assert_eq!(body, expected);
936    }
937
938    #[test]
939    fn materialize_part_resubmit_overwrites_with_latest() {
940        let dir = TempDir::new().unwrap();
941        let adapter = FileProjectionAdapter::new(dir.path());
942
943        adapter
944            .materialize_part("T-1", "plan.md", &json!("first"))
945            .unwrap();
946        let reference = adapter
947            .materialize_part("T-1", "plan.md", &json!("second"))
948            .unwrap();
949        let path = match &reference {
950            ProjectionRef::File { path } => path.clone(),
951            other => panic!("expected File ref, got {other:?}"),
952        };
953        let body = std::fs::read_to_string(path).unwrap();
954        assert_eq!(body, "second");
955    }
956
957    #[test]
958    fn materialize_part_rejects_traversal_names() {
959        let dir = TempDir::new().unwrap();
960        let adapter = FileProjectionAdapter::new(dir.path());
961
962        for bad in ["../x", "a/b", "a\\b", "..", ""] {
963            let err = adapter
964                .materialize_part("T-1", bad, &json!("x"))
965                .unwrap_err();
966            assert!(
967                matches!(err, ProjectionError::InvalidKey(_)),
968                "expected InvalidKey for name {bad:?}"
969            );
970        }
971        // No file (nor an escaped one) was created — the guard rejects
972        // before any `create_dir_all`, so the ctx dir stays empty/absent.
973        let ctx_dir = ProjectionPlacement::default().target_dir(dir.path(), "T-1");
974        assert!(
975            !ctx_dir.exists() || std::fs::read_dir(&ctx_dir).unwrap().next().is_none(),
976            "no part file may be created for a rejected name"
977        );
978    }
979
980    #[test]
981    fn materialize_part_lands_next_to_submission_file() {
982        let dir = TempDir::new().unwrap();
983        let adapter = FileProjectionAdapter::new(dir.path());
984        let k = key(Some("planner"), None);
985
986        let submission = adapter
987            .materialize_submission(&k, &json!({"plan": "x"}), 1, true)
988            .unwrap();
989        let part = adapter
990            .materialize_part("T-1", "plan.md", &json!("the plan"))
991            .unwrap();
992
993        let submission_path = match submission {
994            ProjectionRef::File { path } => PathBuf::from(path),
995            other => panic!("expected File ref, got {other:?}"),
996        };
997        let part_path = match part {
998            ProjectionRef::File { path } => PathBuf::from(path),
999            other => panic!("expected File ref, got {other:?}"),
1000        };
1001        assert_eq!(
1002            submission_path.parent(),
1003            part_path.parent(),
1004            "submission ctx/<step>.md and part ctx/<name> must share a directory"
1005        );
1006        assert_eq!(part_path.file_name().unwrap(), "plan.md");
1007    }
1008}