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}