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 `<root>/workspace/tasks/<task_id>/ctx/<producer_agent>.md`
58//! via [`FileProjectionAdapter::materialize_submission`] — this is the file
59//! a Worker-axis `StepPointer.file_path` (when `Some`) and the HTTP debug
60//! plane's `StepSummary.file_path` both address, so a *later* Agent step
61//! (or an operator, over HTTP) can read a *prior* step's OUTPUT while the
62//! overall run is still in flight, without waiting for
63//! `RunRecord.result_ref` to be set at finalization. `<root>` is resolved
64//! from the [`crate::core::agent_context::AgentContextView`]
65//! `AgentContextMiddleware` snapshotted at spawn time (`work_dir`, falling
66//! back to `project_root`); this sink is best-effort (fail-open — see the
67//! Invariants on `Engine::submit_output`'s doc): an unresolved root, or a
68//! `Final` from a spawn that never ran through that middleware, is a silent
69//! no-op, never a submit failure.
70//!
71//! # Design: addressing
72//!
73//! [`ProjectionKey`] combines three independent axes — ID (`task_id` /
74//! `run_id`), Name (`step`), and Path (`path`, a `$.a.b`-style dot path
75//! into the step's OUTPUT value) — so a caller can address anything from
76//! "the whole run ctx" down to "one field of one step's OUTPUT" with the
77//! same type. [`ProjectionKey::resolve`] is the pure, adapter-independent
78//! implementation of that narrowing, shared by every adapter.
79
80use serde::{Deserialize, Serialize};
81use serde_json::Value;
82use std::fs;
83use std::path::PathBuf;
84use thiserror::Error;
85
86/// Addressing for one projection target. ID axis (`task_id` / `run_id`) +
87/// Name axis (`step`) + Path axis (`path`) — the one addressing type both
88/// [`FileProjectionAdapter`] and the future `McpQueryAdapter` (ST2) accept.
89#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
90pub struct ProjectionKey {
91    /// Task identity (`StepId`'s `Display` string form, the same shape
92    /// `AgentContextView.task_id` uses).
93    pub task_id: String,
94    /// Run identity. `None` means "the latest run".
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub run_id: Option<String>,
97    /// Step / agent name — the key directly under `ctx.data` this
98    /// projection narrows to. `None` means "the whole ctx".
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub step: Option<String>,
101    /// Field path within the step's value, `$.a.b` dot-path form (the
102    /// leading `$.` is optional). `None` means "the whole step value".
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub path: Option<String>,
105}
106
107impl ProjectionKey {
108    /// Pure narrowing helper: resolves `self` against `ctx_data`, first by
109    /// `step` (a direct key lookup) then by `path` (a `.`-separated walk of
110    /// nested object fields). Returns `None` if any segment is absent —
111    /// this is a pure lookup, not a fallible parse (an unparseable `path`
112    /// simply yields no match rather than an error, matching the
113    /// `Option`-returning contract callers expect from a resolve step).
114    pub fn resolve<'a>(&self, ctx_data: &'a Value) -> Option<&'a Value> {
115        let mut current = match &self.step {
116            Some(step) => ctx_data.get(step)?,
117            None => ctx_data,
118        };
119        if let Some(path) = &self.path {
120            let path = path.strip_prefix("$.").unwrap_or(path.as_str());
121            for segment in path.split('.').filter(|s| !s.is_empty()) {
122                current = current.get(segment)?;
123            }
124        }
125        Some(current)
126    }
127
128    /// Filename-safe step slug for [`FileProjectionAdapter`]'s materialize
129    /// target — `step` verbatim, or `"_ctx"` when addressing the whole
130    /// ctx (`step` is `None`).
131    fn step_slug(&self) -> &str {
132        self.step.as_deref().unwrap_or("_ctx")
133    }
134}
135
136/// [`ProjectionAdapter::project`]'s return value — the locator a worker
137/// uses to pull the projected value.
138#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
139pub enum ProjectionRef {
140    /// [`FileProjectionAdapter`]: absolute path to the materialized file.
141    File {
142        /// Absolute filesystem path of the materialized projection file.
143        path: String,
144    },
145    /// `McpQueryAdapter` (server-side): a fetch endpoint plus the key to
146    /// query it with.
147    Query {
148        /// Fetch endpoint (e.g. `GET
149        /// /v1/tasks/:id/runs/:run/steps/:step/content`, ST5) the adapter
150        /// serves this projection through.
151        endpoint: String,
152        /// Key identifying which projection to fetch at `endpoint`.
153        key: ProjectionKey,
154    },
155}
156
157/// All ways a [`ProjectionAdapter`] operation can fail.
158#[derive(Debug, Error)]
159pub enum ProjectionError {
160    /// No value exists for the given key — either `project()` could not
161    /// resolve `key` against the supplied ctx data, or `fetch()` found no
162    /// materialized projection for it.
163    #[error("projection not found for key {0:?}")]
164    NotFound(ProjectionKey),
165    /// A filesystem operation (materialize write / read-back) failed.
166    #[error("projection io error: {0}")]
167    Io(#[from] std::io::Error),
168    /// `key` is structurally invalid for this adapter (e.g. an empty
169    /// `task_id`).
170    #[error("invalid projection key: {0}")]
171    InvalidKey(String),
172    /// Serializing the projected value to its on-disk/on-wire form, or
173    /// deserializing it back, failed.
174    #[error("projection serialize error: {0}")]
175    Serialize(String),
176}
177
178/// Context projection supply abstraction. The single source of truth is
179/// the run ctx passed into [`Self::project`]; an adapter only decides
180/// *how the pull is served* (module doc has the full narrative).
181pub trait ProjectionAdapter: Send + Sync {
182    /// Stable adapter name (`"file"` / `"mcp-query"`), used in log lines
183    /// and diagnostics.
184    fn name(&self) -> &'static str;
185
186    /// Projects the slice of `ctx_data` addressed by `key` and returns a
187    /// locator a worker can later [`Self::fetch`]. `ctx_data` is the
188    /// policy-filtered ctx data slice this projection is drawn from — the
189    /// adapter never mutates it.
190    fn project(
191        &self,
192        key: &ProjectionKey,
193        ctx_data: &Value,
194    ) -> Result<ProjectionRef, ProjectionError>;
195
196    /// Pulls the value addressed by `key` directly, without going through
197    /// a previously returned [`ProjectionRef`] locator.
198    fn fetch(&self, key: &ProjectionKey) -> Result<Value, ProjectionError>;
199
200    /// Renders the 1-3 line pointer this adapter's [`ProjectionRef`]
201    /// contributes to the directive header. Must never embed the
202    /// projected value itself (inline full-embed is the exact problem
203    /// projection exists to avoid — see the module doc).
204    fn pointer_line(&self, r: &ProjectionRef) -> String;
205}
206
207/// File-backed [`ProjectionAdapter`]: materializes the projected value to
208/// `<root>/workspace/tasks/<task_id>/ctx/<step-or-_ctx>.md` and reads it
209/// back on [`Self::fetch`].
210pub struct FileProjectionAdapter {
211    root: PathBuf,
212}
213
214impl FileProjectionAdapter {
215    /// Builds an adapter rooted at `root` (the project root — materialize
216    /// targets are resolved under `<root>/workspace/tasks/...`).
217    pub fn new(root: impl Into<PathBuf>) -> Self {
218        Self { root: root.into() }
219    }
220
221    /// The materialize target for `key`: `<root>/workspace/tasks/<task_id>/ctx/<step-or-_ctx>.md`.
222    fn target_path(&self, key: &ProjectionKey) -> PathBuf {
223        self.root
224            .join("workspace")
225            .join("tasks")
226            .join(&key.task_id)
227            .join("ctx")
228            .join(format!("{}.md", key.step_slug()))
229    }
230
231    /// Submit-path materialize (subtask-4 / ST2 rework — see the module
232    /// doc's "Submit-path projection" section). Unlike [`Self::project`],
233    /// `value` is not narrowed out of a larger `ctx_data` via
234    /// [`ProjectionKey::resolve`] — it is the exact submitted content, so
235    /// this writes it directly. Reuses [`Self::target_path`] (same
236    /// `<root>/workspace/tasks/<task_id>/ctx/<step>.md` convention as
237    /// [`Self::project`]), so a later [`Self::fetch`] against the same
238    /// `key` reads it back unchanged (`fetch` only parses the fenced
239    /// ```` ```json ```` block, so the extra `attempt` / `ok` front-matter
240    /// fields this writes are inert to it). A full replace, never append —
241    /// re-submitting the same `(task_id, producer_agent)` overwrites
242    /// (idempotent, latest wins — Subtask 4 Invariant 2 / Test 4).
243    ///
244    /// `key.step` must be `Some(producer_agent)` — this is a submission
245    /// slot, never "the whole ctx" (`step: None` still resolves to a valid
246    /// path via [`ProjectionKey::step_slug`]'s `"_ctx"` fallback, but a
247    /// caller addressing an actual submission should always name the
248    /// producing agent).
249    pub fn materialize_submission(
250        &self,
251        key: &ProjectionKey,
252        value: &Value,
253        attempt: u32,
254        ok: bool,
255    ) -> Result<ProjectionRef, ProjectionError> {
256        if key.task_id.is_empty() {
257            return Err(ProjectionError::InvalidKey(
258                "task_id must not be empty".to_string(),
259            ));
260        }
261        let target = self.target_path(key);
262        if let Some(parent) = target.parent() {
263            fs::create_dir_all(parent)?;
264        }
265        fs::write(&target, render_submission_file(key, value, attempt, ok)?)?;
266        Ok(ProjectionRef::File {
267            path: target.to_string_lossy().into_owned(),
268        })
269    }
270}
271
272/// Front matter for a submit-time materialized projection file
273/// ([`FileProjectionAdapter::materialize_submission`]) — the same
274/// addressing fields as [`ProjectionKey`], flattened, plus the
275/// submission's own `attempt` / `ok`. No `out_id`: `Engine::submit_output`
276/// / `submit_worker_result_trusted` do not allocate one (that only happens
277/// on the separate `POST /v1/data/emit` Data-plane path — see
278/// `crate::store::output`'s module doc) — so there is nothing to carry
279/// here.
280#[derive(Serialize)]
281struct SubmissionFrontMatter<'a> {
282    #[serde(flatten)]
283    key: &'a ProjectionKey,
284    /// The submitting attempt number.
285    attempt: u32,
286    /// The submission's transport-level success flag (`OutputEvent::Final.ok`).
287    ok: bool,
288}
289
290/// Renders a submit-time materialized projection file — same fenced-JSON
291/// body convention as [`render_projection_file`], with a front matter that
292/// additionally carries `attempt` / `ok` (see [`SubmissionFrontMatter`]).
293fn render_submission_file(
294    key: &ProjectionKey,
295    value: &Value,
296    attempt: u32,
297    ok: bool,
298) -> Result<String, ProjectionError> {
299    let front = SubmissionFrontMatter { key, attempt, ok };
300    let front_matter =
301        serde_yaml::to_string(&front).map_err(|err| ProjectionError::Serialize(err.to_string()))?;
302    let body = serde_json::to_string_pretty(value)
303        .map_err(|err| ProjectionError::Serialize(err.to_string()))?;
304    Ok(format!("---\n{front_matter}---\n\n```json\n{body}\n```\n"))
305}
306
307impl ProjectionAdapter for FileProjectionAdapter {
308    fn name(&self) -> &'static str {
309        "file"
310    }
311
312    fn project(
313        &self,
314        key: &ProjectionKey,
315        ctx_data: &Value,
316    ) -> Result<ProjectionRef, ProjectionError> {
317        if key.task_id.is_empty() {
318            return Err(ProjectionError::InvalidKey(
319                "task_id must not be empty".to_string(),
320            ));
321        }
322        let value = key
323            .resolve(ctx_data)
324            .ok_or_else(|| ProjectionError::NotFound(key.clone()))?;
325        let target = self.target_path(key);
326        if let Some(parent) = target.parent() {
327            fs::create_dir_all(parent)?;
328        }
329        // Full replace, never append — re-projecting the same key is
330        // idempotent (invariant 3).
331        fs::write(&target, render_projection_file(key, value)?)?;
332        Ok(ProjectionRef::File {
333            path: target.to_string_lossy().into_owned(),
334        })
335    }
336
337    fn fetch(&self, key: &ProjectionKey) -> Result<Value, ProjectionError> {
338        let target = self.target_path(key);
339        let body = fs::read_to_string(&target).map_err(|err| {
340            if err.kind() == std::io::ErrorKind::NotFound {
341                ProjectionError::NotFound(key.clone())
342            } else {
343                ProjectionError::Io(err)
344            }
345        })?;
346        parse_projection_file(&body)
347    }
348
349    fn pointer_line(&self, r: &ProjectionRef) -> String {
350        match r {
351            ProjectionRef::File { path } => format!("projection(file): {path}"),
352            ProjectionRef::Query { endpoint, key } => {
353                format!("projection(mcp-query): {endpoint} task_id={}", key.task_id)
354            }
355        }
356    }
357}
358
359/// Renders a materialized projection file: a `key`-describing YAML
360/// front-matter header followed by the projected `value` as a fenced JSON
361/// block ([`parse_projection_file`] reads the fence back out).
362fn render_projection_file(key: &ProjectionKey, value: &Value) -> Result<String, ProjectionError> {
363    let front_matter =
364        serde_yaml::to_string(key).map_err(|err| ProjectionError::Serialize(err.to_string()))?;
365    let body = serde_json::to_string_pretty(value)
366        .map_err(|err| ProjectionError::Serialize(err.to_string()))?;
367    Ok(format!("---\n{front_matter}---\n\n```json\n{body}\n```\n"))
368}
369
370/// Reads a materialized projection file back into its JSON value, the
371/// inverse of [`render_projection_file`]. Only the fenced ```` ```json ````
372/// block is parsed; the front-matter header is documentation for a human
373/// reader and is not required for the round trip.
374fn parse_projection_file(text: &str) -> Result<Value, ProjectionError> {
375    const FENCE_OPEN: &str = "```json";
376    const FENCE_CLOSE: &str = "```";
377    let after_open = text.find(FENCE_OPEN).ok_or_else(|| {
378        ProjectionError::Serialize("materialized projection file missing ```json block".into())
379    })?;
380    let body_start = after_open + FENCE_OPEN.len();
381    let close_offset = text[body_start..].find(FENCE_CLOSE).ok_or_else(|| {
382        ProjectionError::Serialize("materialized projection file missing closing ``` fence".into())
383    })?;
384    let json_body = text[body_start..body_start + close_offset].trim();
385    serde_json::from_str(json_body).map_err(|err| ProjectionError::Serialize(err.to_string()))
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391    use serde_json::json;
392    use std::path::Path;
393    use tempfile::TempDir;
394
395    fn sample_ctx_data() -> Value {
396        json!({
397            "planner": {
398                "plan": "do the thing",
399                "nested": { "field": 42 }
400            },
401            "other_step": { "value": "x" }
402        })
403    }
404
405    fn key(step: Option<&str>, path: Option<&str>) -> ProjectionKey {
406        ProjectionKey {
407            task_id: "T-1".to_string(),
408            run_id: None,
409            step: step.map(str::to_string),
410            path: path.map(str::to_string),
411        }
412    }
413
414    #[test]
415    fn resolve_step_only_returns_step_value() {
416        let ctx_data = sample_ctx_data();
417        let resolved = key(Some("planner"), None).resolve(&ctx_data).unwrap();
418        assert_eq!(
419            resolved,
420            &json!({"plan": "do the thing", "nested": {"field": 42}})
421        );
422    }
423
424    #[test]
425    fn resolve_step_and_path_narrows_to_field() {
426        let ctx_data = sample_ctx_data();
427        let resolved = key(Some("planner"), Some("$.nested.field"))
428            .resolve(&ctx_data)
429            .unwrap();
430        assert_eq!(resolved, &json!(42));
431    }
432
433    #[test]
434    fn resolve_missing_step_returns_none() {
435        assert!(key(Some("does-not-exist"), None)
436            .resolve(&sample_ctx_data())
437            .is_none());
438    }
439
440    #[test]
441    fn resolve_missing_path_returns_none() {
442        assert!(key(Some("planner"), Some("$.nested.missing"))
443            .resolve(&sample_ctx_data())
444            .is_none());
445    }
446
447    #[test]
448    fn file_adapter_project_then_fetch_round_trips() {
449        let dir = TempDir::new().unwrap();
450        let adapter = FileProjectionAdapter::new(dir.path());
451        let k = key(Some("planner"), None);
452        let ctx_data = sample_ctx_data();
453
454        let reference = adapter.project(&k, &ctx_data).unwrap();
455        let path = match &reference {
456            ProjectionRef::File { path } => path.clone(),
457            other => panic!("expected File ref, got {other:?}"),
458        };
459        assert!(Path::new(&path).exists());
460
461        let fetched = adapter.fetch(&k).unwrap();
462        assert_eq!(&fetched, k.resolve(&ctx_data).unwrap());
463    }
464
465    #[test]
466    fn file_adapter_reproject_is_idempotent() {
467        let dir = TempDir::new().unwrap();
468        let adapter = FileProjectionAdapter::new(dir.path());
469        let k = key(Some("planner"), None);
470        let ctx_data = sample_ctx_data();
471
472        let first = adapter.project(&k, &ctx_data).unwrap();
473        let second = adapter.project(&k, &ctx_data).unwrap();
474        assert_eq!(first, second);
475        assert_eq!(adapter.fetch(&k).unwrap(), adapter.fetch(&k).unwrap());
476    }
477
478    #[test]
479    fn pointer_line_carries_path_not_value() {
480        let reference = ProjectionRef::File {
481            path: "/tmp/some/materialized.md".to_string(),
482        };
483        let adapter = FileProjectionAdapter::new("/unused");
484        let line = adapter.pointer_line(&reference);
485        assert!(line.contains("/tmp/some/materialized.md"));
486        assert!(!line.contains('{'));
487    }
488
489    #[test]
490    fn fetch_missing_projection_returns_not_found() {
491        let dir = TempDir::new().unwrap();
492        let adapter = FileProjectionAdapter::new(dir.path());
493        let k = key(Some("nope"), None);
494
495        let err = adapter.fetch(&k).unwrap_err();
496        assert!(matches!(err, ProjectionError::NotFound(_)));
497    }
498
499    #[test]
500    fn project_rejects_empty_task_id() {
501        let dir = TempDir::new().unwrap();
502        let adapter = FileProjectionAdapter::new(dir.path());
503        let mut k = key(Some("planner"), None);
504        k.task_id = String::new();
505
506        let err = adapter.project(&k, &sample_ctx_data()).unwrap_err();
507        assert!(matches!(err, ProjectionError::InvalidKey(_)));
508    }
509
510    // ─── subtask-4 / ST2 rework: FileProjectionAdapter::materialize_submission ───
511
512    #[test]
513    fn materialize_submission_writes_value_directly_and_fetch_reads_it_back() {
514        let dir = TempDir::new().unwrap();
515        let adapter = FileProjectionAdapter::new(dir.path());
516        let k = key(Some("planner"), None);
517        let value = json!({"plan": "do the thing"});
518
519        let reference = adapter.materialize_submission(&k, &value, 1, true).unwrap();
520        let path = match &reference {
521            ProjectionRef::File { path } => path.clone(),
522            other => panic!("expected File ref, got {other:?}"),
523        };
524        assert!(Path::new(&path).exists());
525
526        // fetch() only parses the fenced ```json block, so the submission
527        // front matter (attempt / ok) is inert to it — same round trip
528        // contract as `project`.
529        let fetched = adapter.fetch(&k).unwrap();
530        assert_eq!(fetched, value);
531    }
532
533    #[test]
534    fn materialize_submission_front_matter_carries_attempt_and_ok() {
535        let dir = TempDir::new().unwrap();
536        let adapter = FileProjectionAdapter::new(dir.path());
537        let k = key(Some("reviewer"), None);
538
539        let reference = adapter
540            .materialize_submission(&k, &json!("hi"), 2, false)
541            .unwrap();
542        let path = match &reference {
543            ProjectionRef::File { path } => path.clone(),
544            other => panic!("expected File ref, got {other:?}"),
545        };
546        let body = std::fs::read_to_string(path).unwrap();
547        assert!(body.contains("attempt: 2"), "front matter: {body}");
548        assert!(body.contains("ok: false"), "front matter: {body}");
549        assert!(body.contains("step: reviewer"), "front matter: {body}");
550    }
551
552    #[test]
553    fn materialize_submission_resubmit_overwrites_with_latest() {
554        let dir = TempDir::new().unwrap();
555        let adapter = FileProjectionAdapter::new(dir.path());
556        let k = key(Some("planner"), None);
557
558        adapter
559            .materialize_submission(&k, &json!("first"), 1, true)
560            .unwrap();
561        adapter
562            .materialize_submission(&k, &json!("second"), 1, true)
563            .unwrap();
564
565        assert_eq!(adapter.fetch(&k).unwrap(), json!("second"));
566    }
567
568    #[test]
569    fn materialize_submission_rejects_empty_task_id() {
570        let dir = TempDir::new().unwrap();
571        let adapter = FileProjectionAdapter::new(dir.path());
572        let mut k = key(Some("planner"), None);
573        k.task_id = String::new();
574
575        let err = adapter
576            .materialize_submission(&k, &json!("x"), 1, true)
577            .unwrap_err();
578        assert!(matches!(err, ProjectionError::InvalidKey(_)));
579    }
580}