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). Returns `None` if any segment is absent —
123    /// this is a pure lookup, not a fallible parse (an unparseable `path`
124    /// simply yields no match rather than an error, matching the
125    /// `Option`-returning contract callers expect from a resolve step).
126    pub fn resolve<'a>(&self, ctx_data: &'a Value) -> Option<&'a Value> {
127        let mut current = match &self.step {
128            Some(step) => ctx_data.get(step)?,
129            None => ctx_data,
130        };
131        if let Some(path) = &self.path {
132            let path = path.strip_prefix("$.").unwrap_or(path.as_str());
133            for segment in path.split('.').filter(|s| !s.is_empty()) {
134                current = current.get(segment)?;
135            }
136        }
137        Some(current)
138    }
139
140    /// Filename-safe step slug for [`FileProjectionAdapter`]'s materialize
141    /// target — `step` verbatim, or `"_ctx"` when addressing the whole
142    /// ctx (`step` is `None`).
143    fn step_slug(&self) -> &str {
144        self.step.as_deref().unwrap_or("_ctx")
145    }
146}
147
148/// [`ProjectionAdapter::project`]'s return value — the locator a worker
149/// uses to pull the projected value.
150#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
151pub enum ProjectionRef {
152    /// [`FileProjectionAdapter`]: absolute path to the materialized file.
153    File {
154        /// Absolute filesystem path of the materialized projection file.
155        path: String,
156    },
157    /// `McpQueryAdapter` (server-side): a fetch endpoint plus the key to
158    /// query it with.
159    Query {
160        /// Fetch endpoint (e.g. `GET
161        /// /v1/tasks/:id/runs/:run/steps/:step/content`, ST5) the adapter
162        /// serves this projection through.
163        endpoint: String,
164        /// Key identifying which projection to fetch at `endpoint`.
165        key: ProjectionKey,
166    },
167}
168
169/// All ways a [`ProjectionAdapter`] operation can fail.
170#[derive(Debug, Error)]
171pub enum ProjectionError {
172    /// No value exists for the given key — either `project()` could not
173    /// resolve `key` against the supplied ctx data, or `fetch()` found no
174    /// materialized projection for it.
175    #[error("projection not found for key {0:?}")]
176    NotFound(ProjectionKey),
177    /// A filesystem operation (materialize write / read-back) failed.
178    #[error("projection io error: {0}")]
179    Io(#[from] std::io::Error),
180    /// `key` is structurally invalid for this adapter (e.g. an empty
181    /// `task_id`).
182    #[error("invalid projection key: {0}")]
183    InvalidKey(String),
184    /// Serializing the projected value to its on-disk/on-wire form, or
185    /// deserializing it back, failed.
186    #[error("projection serialize error: {0}")]
187    Serialize(String),
188}
189
190/// Context projection supply abstraction. The single source of truth is
191/// the run ctx passed into [`Self::project`]; an adapter only decides
192/// *how the pull is served* (module doc has the full narrative).
193pub trait ProjectionAdapter: Send + Sync {
194    /// Stable adapter name (`"file"` / `"mcp-query"`), used in log lines
195    /// and diagnostics.
196    fn name(&self) -> &'static str;
197
198    /// Projects the slice of `ctx_data` addressed by `key` and returns a
199    /// locator a worker can later [`Self::fetch`]. `ctx_data` is the
200    /// policy-filtered ctx data slice this projection is drawn from — the
201    /// adapter never mutates it.
202    fn project(
203        &self,
204        key: &ProjectionKey,
205        ctx_data: &Value,
206    ) -> Result<ProjectionRef, ProjectionError>;
207
208    /// Pulls the value addressed by `key` directly, without going through
209    /// a previously returned [`ProjectionRef`] locator.
210    fn fetch(&self, key: &ProjectionKey) -> Result<Value, ProjectionError>;
211
212    /// Renders the 1-3 line pointer this adapter's [`ProjectionRef`]
213    /// contributes to the directive header. Must never embed the
214    /// projected value itself (inline full-embed is the exact problem
215    /// projection exists to avoid — see the module doc).
216    fn pointer_line(&self, r: &ProjectionRef) -> String;
217}
218
219/// File-backed [`ProjectionAdapter`]: materializes the projected value
220/// under `root`, at the target [`ProjectionPlacement::target_path`]
221/// resolves for `key`, and reads it back on [`Self::fetch`]. [`Self::new`]
222/// resolves through [`ProjectionPlacement::default`] (the pre-GH-#27
223/// hardcoded `<root>/workspace/tasks/<task_id>/ctx/<step-or-_ctx>.md`
224/// layout, unchanged); [`Self::with_placement`] resolves through a
225/// caller-supplied resolver instead — see
226/// `crate::core::projection_placement`'s module doc for the "3 path"
227/// convergence this collapses.
228pub struct FileProjectionAdapter {
229    root: PathBuf,
230    placement: ProjectionPlacement,
231}
232
233impl FileProjectionAdapter {
234    /// Builds an adapter rooted at `root` (typically the resolved
235    /// `work_dir` / `project_root`) using the byte-compat
236    /// [`ProjectionPlacement::default`] resolver.
237    pub fn new(root: impl Into<PathBuf>) -> Self {
238        Self {
239            root: root.into(),
240            placement: ProjectionPlacement::default(),
241        }
242    }
243
244    /// Builds an adapter rooted at `root`, resolving materialize targets
245    /// through the given `placement` instead of the byte-compat default —
246    /// the constructor every one of the "3 path" call sites
247    /// (`crate::core::projection_placement`'s module doc) uses once they
248    /// hold a Blueprint-resolved [`ProjectionPlacement`].
249    pub fn with_placement(root: impl Into<PathBuf>, placement: ProjectionPlacement) -> Self {
250        Self {
251            root: root.into(),
252            placement,
253        }
254    }
255
256    /// The materialize target for `key`, resolved via
257    /// [`ProjectionPlacement::target_path`].
258    fn target_path(&self, key: &ProjectionKey) -> PathBuf {
259        self.placement
260            .target_path(&self.root, &key.task_id, key.step_slug())
261    }
262
263    /// Submit-path materialize (subtask-4 / ST2 rework — see the module
264    /// doc's "Submit-path projection" section). Unlike [`Self::project`],
265    /// `value` is not narrowed out of a larger `ctx_data` via
266    /// [`ProjectionKey::resolve`] — it is the exact submitted content, so
267    /// this writes it directly. Reuses [`Self::target_path`] (the same
268    /// [`ProjectionPlacement`]-resolved target — byte-compat default
269    /// `<root>/workspace/tasks/<task_id>/ctx/<step>.md` — as
270    /// [`Self::project`]), so a later [`Self::fetch`] against the same
271    /// `key` reads it back unchanged (`fetch` only parses the fenced
272    /// ```` ```json ```` block, so the extra `attempt` / `ok` front-matter
273    /// fields this writes are inert to it). A full replace, never append —
274    /// re-submitting the same `(task_id, producer_agent)` overwrites
275    /// (idempotent, latest wins — Subtask 4 Invariant 2 / Test 4).
276    ///
277    /// `key.step` must be `Some(producer_agent)` — this is a submission
278    /// slot, never "the whole ctx" (`step: None` still resolves to a valid
279    /// path via [`ProjectionKey::step_slug`]'s `"_ctx"` fallback, but a
280    /// caller addressing an actual submission should always name the
281    /// producing agent).
282    pub fn materialize_submission(
283        &self,
284        key: &ProjectionKey,
285        value: &Value,
286        attempt: u32,
287        ok: bool,
288    ) -> Result<ProjectionRef, ProjectionError> {
289        if key.task_id.is_empty() {
290            return Err(ProjectionError::InvalidKey(
291                "task_id must not be empty".to_string(),
292            ));
293        }
294        let target = self.target_path(key);
295        if let Some(parent) = target.parent() {
296            fs::create_dir_all(parent)?;
297        }
298        fs::write(&target, render_submission_file(key, value, attempt, ok)?)?;
299        Ok(ProjectionRef::File {
300            path: target.to_string_lossy().into_owned(),
301        })
302    }
303}
304
305/// Front matter for a submit-time materialized projection file
306/// ([`FileProjectionAdapter::materialize_submission`]) — the same
307/// addressing fields as [`ProjectionKey`], flattened, plus the
308/// submission's own `attempt` / `ok`. No `out_id`: `Engine::submit_output`
309/// / `submit_worker_result_trusted` do not allocate one (that only happens
310/// on the separate `POST /v1/data/emit` Data-plane path — see
311/// `crate::store::output`'s module doc) — so there is nothing to carry
312/// here.
313#[derive(Serialize)]
314struct SubmissionFrontMatter<'a> {
315    #[serde(flatten)]
316    key: &'a ProjectionKey,
317    /// The submitting attempt number.
318    attempt: u32,
319    /// The submission's transport-level success flag (`OutputEvent::Final.ok`).
320    ok: bool,
321}
322
323/// Renders a submit-time materialized projection file — same fenced-JSON
324/// body convention as [`render_projection_file`], with a front matter that
325/// additionally carries `attempt` / `ok` (see [`SubmissionFrontMatter`]).
326fn render_submission_file(
327    key: &ProjectionKey,
328    value: &Value,
329    attempt: u32,
330    ok: bool,
331) -> Result<String, ProjectionError> {
332    let front = SubmissionFrontMatter { key, attempt, ok };
333    let front_matter =
334        serde_yaml::to_string(&front).map_err(|err| ProjectionError::Serialize(err.to_string()))?;
335    let body = serde_json::to_string_pretty(value)
336        .map_err(|err| ProjectionError::Serialize(err.to_string()))?;
337    Ok(format!("---\n{front_matter}---\n\n```json\n{body}\n```\n"))
338}
339
340impl ProjectionAdapter for FileProjectionAdapter {
341    fn name(&self) -> &'static str {
342        "file"
343    }
344
345    fn project(
346        &self,
347        key: &ProjectionKey,
348        ctx_data: &Value,
349    ) -> Result<ProjectionRef, ProjectionError> {
350        if key.task_id.is_empty() {
351            return Err(ProjectionError::InvalidKey(
352                "task_id must not be empty".to_string(),
353            ));
354        }
355        let value = key
356            .resolve(ctx_data)
357            .ok_or_else(|| ProjectionError::NotFound(key.clone()))?;
358        let target = self.target_path(key);
359        if let Some(parent) = target.parent() {
360            fs::create_dir_all(parent)?;
361        }
362        // Full replace, never append — re-projecting the same key is
363        // idempotent (invariant 3).
364        fs::write(&target, render_projection_file(key, value)?)?;
365        Ok(ProjectionRef::File {
366            path: target.to_string_lossy().into_owned(),
367        })
368    }
369
370    fn fetch(&self, key: &ProjectionKey) -> Result<Value, ProjectionError> {
371        let target = self.target_path(key);
372        let body = fs::read_to_string(&target).map_err(|err| {
373            if err.kind() == std::io::ErrorKind::NotFound {
374                ProjectionError::NotFound(key.clone())
375            } else {
376                ProjectionError::Io(err)
377            }
378        })?;
379        parse_projection_file(&body)
380    }
381
382    fn pointer_line(&self, r: &ProjectionRef) -> String {
383        match r {
384            ProjectionRef::File { path } => format!("projection(file): {path}"),
385            ProjectionRef::Query { endpoint, key } => {
386                format!("projection(mcp-query): {endpoint} task_id={}", key.task_id)
387            }
388        }
389    }
390}
391
392/// Renders a materialized projection file: a `key`-describing YAML
393/// front-matter header followed by the projected `value` as a fenced JSON
394/// block ([`parse_projection_file`] reads the fence back out).
395fn render_projection_file(key: &ProjectionKey, value: &Value) -> Result<String, ProjectionError> {
396    let front_matter =
397        serde_yaml::to_string(key).map_err(|err| ProjectionError::Serialize(err.to_string()))?;
398    let body = serde_json::to_string_pretty(value)
399        .map_err(|err| ProjectionError::Serialize(err.to_string()))?;
400    Ok(format!("---\n{front_matter}---\n\n```json\n{body}\n```\n"))
401}
402
403/// Reads a materialized projection file back into its JSON value, the
404/// inverse of [`render_projection_file`]. Only the fenced ```` ```json ````
405/// block is parsed; the front-matter header is documentation for a human
406/// reader and is not required for the round trip.
407fn parse_projection_file(text: &str) -> Result<Value, ProjectionError> {
408    const FENCE_OPEN: &str = "```json";
409    const FENCE_CLOSE: &str = "```";
410    let after_open = text.find(FENCE_OPEN).ok_or_else(|| {
411        ProjectionError::Serialize("materialized projection file missing ```json block".into())
412    })?;
413    let body_start = after_open + FENCE_OPEN.len();
414    let close_offset = text[body_start..].find(FENCE_CLOSE).ok_or_else(|| {
415        ProjectionError::Serialize("materialized projection file missing closing ``` fence".into())
416    })?;
417    let json_body = text[body_start..body_start + close_offset].trim();
418    serde_json::from_str(json_body).map_err(|err| ProjectionError::Serialize(err.to_string()))
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424    use crate::core::projection_placement::RootPreference;
425    use serde_json::json;
426    use std::path::Path;
427    use tempfile::TempDir;
428
429    fn sample_ctx_data() -> Value {
430        json!({
431            "planner": {
432                "plan": "do the thing",
433                "nested": { "field": 42 }
434            },
435            "other_step": { "value": "x" }
436        })
437    }
438
439    fn key(step: Option<&str>, path: Option<&str>) -> ProjectionKey {
440        ProjectionKey {
441            task_id: "T-1".to_string(),
442            run_id: None,
443            step: step.map(str::to_string),
444            path: path.map(str::to_string),
445        }
446    }
447
448    #[test]
449    fn resolve_step_only_returns_step_value() {
450        let ctx_data = sample_ctx_data();
451        let resolved = key(Some("planner"), None).resolve(&ctx_data).unwrap();
452        assert_eq!(
453            resolved,
454            &json!({"plan": "do the thing", "nested": {"field": 42}})
455        );
456    }
457
458    #[test]
459    fn resolve_step_and_path_narrows_to_field() {
460        let ctx_data = sample_ctx_data();
461        let resolved = key(Some("planner"), Some("$.nested.field"))
462            .resolve(&ctx_data)
463            .unwrap();
464        assert_eq!(resolved, &json!(42));
465    }
466
467    #[test]
468    fn resolve_missing_step_returns_none() {
469        assert!(key(Some("does-not-exist"), None)
470            .resolve(&sample_ctx_data())
471            .is_none());
472    }
473
474    #[test]
475    fn resolve_missing_path_returns_none() {
476        assert!(key(Some("planner"), Some("$.nested.missing"))
477            .resolve(&sample_ctx_data())
478            .is_none());
479    }
480
481    #[test]
482    fn file_adapter_project_then_fetch_round_trips() {
483        let dir = TempDir::new().unwrap();
484        let adapter = FileProjectionAdapter::new(dir.path());
485        let k = key(Some("planner"), None);
486        let ctx_data = sample_ctx_data();
487
488        let reference = adapter.project(&k, &ctx_data).unwrap();
489        let path = match &reference {
490            ProjectionRef::File { path } => path.clone(),
491            other => panic!("expected File ref, got {other:?}"),
492        };
493        assert!(Path::new(&path).exists());
494
495        let fetched = adapter.fetch(&k).unwrap();
496        assert_eq!(&fetched, k.resolve(&ctx_data).unwrap());
497    }
498
499    /// GH #27 (follow-up to #23): `with_placement` resolves the target
500    /// through the given `ProjectionPlacement` instead of the byte-compat
501    /// default — the file lands at the custom `dir_template`, and a
502    /// `fetch` against the SAME key/adapter still round-trips (write and
503    /// read agree by construction, since both go through the same
504    /// resolver instance).
505    #[test]
506    fn file_adapter_with_placement_uses_custom_dir_template() {
507        let dir = TempDir::new().unwrap();
508        let placement = ProjectionPlacement {
509            root_preference: RootPreference::WorkDir,
510            dir_template: "custom/{task_id}/out".to_string(),
511        };
512        let adapter = FileProjectionAdapter::with_placement(dir.path(), placement);
513        let k = key(Some("planner"), None);
514        let ctx_data = sample_ctx_data();
515
516        let reference = adapter.project(&k, &ctx_data).unwrap();
517        let path = match &reference {
518            ProjectionRef::File { path } => path.clone(),
519            other => panic!("expected File ref, got {other:?}"),
520        };
521        assert!(
522            path.ends_with("custom/T-1/out/planner.md"),
523            "path must follow the custom dir_template: {path}"
524        );
525        assert!(Path::new(&path).exists());
526
527        let fetched = adapter.fetch(&k).unwrap();
528        assert_eq!(&fetched, k.resolve(&ctx_data).unwrap());
529    }
530
531    #[test]
532    fn file_adapter_reproject_is_idempotent() {
533        let dir = TempDir::new().unwrap();
534        let adapter = FileProjectionAdapter::new(dir.path());
535        let k = key(Some("planner"), None);
536        let ctx_data = sample_ctx_data();
537
538        let first = adapter.project(&k, &ctx_data).unwrap();
539        let second = adapter.project(&k, &ctx_data).unwrap();
540        assert_eq!(first, second);
541        assert_eq!(adapter.fetch(&k).unwrap(), adapter.fetch(&k).unwrap());
542    }
543
544    #[test]
545    fn pointer_line_carries_path_not_value() {
546        let reference = ProjectionRef::File {
547            path: "/tmp/some/materialized.md".to_string(),
548        };
549        let adapter = FileProjectionAdapter::new("/unused");
550        let line = adapter.pointer_line(&reference);
551        assert!(line.contains("/tmp/some/materialized.md"));
552        assert!(!line.contains('{'));
553    }
554
555    #[test]
556    fn fetch_missing_projection_returns_not_found() {
557        let dir = TempDir::new().unwrap();
558        let adapter = FileProjectionAdapter::new(dir.path());
559        let k = key(Some("nope"), None);
560
561        let err = adapter.fetch(&k).unwrap_err();
562        assert!(matches!(err, ProjectionError::NotFound(_)));
563    }
564
565    #[test]
566    fn project_rejects_empty_task_id() {
567        let dir = TempDir::new().unwrap();
568        let adapter = FileProjectionAdapter::new(dir.path());
569        let mut k = key(Some("planner"), None);
570        k.task_id = String::new();
571
572        let err = adapter.project(&k, &sample_ctx_data()).unwrap_err();
573        assert!(matches!(err, ProjectionError::InvalidKey(_)));
574    }
575
576    // ─── subtask-4 / ST2 rework: FileProjectionAdapter::materialize_submission ───
577
578    #[test]
579    fn materialize_submission_writes_value_directly_and_fetch_reads_it_back() {
580        let dir = TempDir::new().unwrap();
581        let adapter = FileProjectionAdapter::new(dir.path());
582        let k = key(Some("planner"), None);
583        let value = json!({"plan": "do the thing"});
584
585        let reference = adapter.materialize_submission(&k, &value, 1, true).unwrap();
586        let path = match &reference {
587            ProjectionRef::File { path } => path.clone(),
588            other => panic!("expected File ref, got {other:?}"),
589        };
590        assert!(Path::new(&path).exists());
591
592        // fetch() only parses the fenced ```json block, so the submission
593        // front matter (attempt / ok) is inert to it — same round trip
594        // contract as `project`.
595        let fetched = adapter.fetch(&k).unwrap();
596        assert_eq!(fetched, value);
597    }
598
599    #[test]
600    fn materialize_submission_front_matter_carries_attempt_and_ok() {
601        let dir = TempDir::new().unwrap();
602        let adapter = FileProjectionAdapter::new(dir.path());
603        let k = key(Some("reviewer"), None);
604
605        let reference = adapter
606            .materialize_submission(&k, &json!("hi"), 2, false)
607            .unwrap();
608        let path = match &reference {
609            ProjectionRef::File { path } => path.clone(),
610            other => panic!("expected File ref, got {other:?}"),
611        };
612        let body = std::fs::read_to_string(path).unwrap();
613        assert!(body.contains("attempt: 2"), "front matter: {body}");
614        assert!(body.contains("ok: false"), "front matter: {body}");
615        assert!(body.contains("step: reviewer"), "front matter: {body}");
616    }
617
618    #[test]
619    fn materialize_submission_resubmit_overwrites_with_latest() {
620        let dir = TempDir::new().unwrap();
621        let adapter = FileProjectionAdapter::new(dir.path());
622        let k = key(Some("planner"), None);
623
624        adapter
625            .materialize_submission(&k, &json!("first"), 1, true)
626            .unwrap();
627        adapter
628            .materialize_submission(&k, &json!("second"), 1, true)
629            .unwrap();
630
631        assert_eq!(adapter.fetch(&k).unwrap(), json!("second"));
632    }
633
634    #[test]
635    fn materialize_submission_rejects_empty_task_id() {
636        let dir = TempDir::new().unwrap();
637        let adapter = FileProjectionAdapter::new(dir.path());
638        let mut k = key(Some("planner"), None);
639        k.task_id = String::new();
640
641        let err = adapter
642            .materialize_submission(&k, &json!("x"), 1, true)
643            .unwrap_err();
644        assert!(matches!(err, ProjectionError::InvalidKey(_)));
645    }
646}