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
412/// Front matter for a submit-time materialized projection file
413/// ([`FileProjectionAdapter::materialize_submission`]) — the same
414/// addressing fields as [`ProjectionKey`], flattened, plus the
415/// submission's own `attempt` / `ok`. No `out_id`: `Engine::submit_output`
416/// / `submit_worker_result_trusted` do not allocate one (that only happens
417/// on the separate `POST /v1/data/emit` Data-plane path — see
418/// `crate::store::output`'s module doc) — so there is nothing to carry
419/// here.
420#[derive(Serialize)]
421struct SubmissionFrontMatter<'a> {
422 #[serde(flatten)]
423 key: &'a ProjectionKey,
424 /// The submitting attempt number.
425 attempt: u32,
426 /// The submission's transport-level success flag (`OutputEvent::Final.ok`).
427 ok: bool,
428}
429
430/// Renders a submit-time materialized projection file — same fenced-JSON
431/// body convention as [`render_projection_file`], with a front matter that
432/// additionally carries `attempt` / `ok` (see [`SubmissionFrontMatter`]).
433fn render_submission_file(
434 key: &ProjectionKey,
435 value: &Value,
436 attempt: u32,
437 ok: bool,
438) -> Result<String, ProjectionError> {
439 let front = SubmissionFrontMatter { key, attempt, ok };
440 let front_matter =
441 serde_yaml::to_string(&front).map_err(|err| ProjectionError::Serialize(err.to_string()))?;
442 let body = serde_json::to_string_pretty(value)
443 .map_err(|err| ProjectionError::Serialize(err.to_string()))?;
444 Ok(format!("---\n{front_matter}---\n\n```json\n{body}\n```\n"))
445}
446
447impl ProjectionAdapter for FileProjectionAdapter {
448 fn name(&self) -> &'static str {
449 "file"
450 }
451
452 fn project(
453 &self,
454 key: &ProjectionKey,
455 ctx_data: &Value,
456 ) -> Result<ProjectionRef, ProjectionError> {
457 if key.task_id.is_empty() {
458 return Err(ProjectionError::InvalidKey(
459 "task_id must not be empty".to_string(),
460 ));
461 }
462 let value = key
463 .resolve(ctx_data)
464 .ok_or_else(|| ProjectionError::NotFound(key.clone()))?;
465 let target = self.target_path(key);
466 if let Some(parent) = target.parent() {
467 fs::create_dir_all(parent)?;
468 }
469 // Full replace, never append — re-projecting the same key is
470 // idempotent (invariant 3).
471 fs::write(&target, render_projection_file(key, value)?)?;
472 Ok(ProjectionRef::File {
473 path: target.to_string_lossy().into_owned(),
474 })
475 }
476
477 fn fetch(&self, key: &ProjectionKey) -> Result<Value, ProjectionError> {
478 let target = self.target_path(key);
479 let body = fs::read_to_string(&target).map_err(|err| {
480 if err.kind() == std::io::ErrorKind::NotFound {
481 ProjectionError::NotFound(key.clone())
482 } else {
483 ProjectionError::Io(err)
484 }
485 })?;
486 parse_projection_file(&body)
487 }
488
489 fn pointer_line(&self, r: &ProjectionRef) -> String {
490 match r {
491 ProjectionRef::File { path } => format!("projection(file): {path}"),
492 ProjectionRef::Query { endpoint, key } => {
493 format!("projection(mcp-query): {endpoint} task_id={}", key.task_id)
494 }
495 }
496 }
497}
498
499/// Renders a materialized projection file: a `key`-describing YAML
500/// front-matter header followed by the projected `value` as a fenced JSON
501/// block ([`parse_projection_file`] reads the fence back out).
502fn render_projection_file(key: &ProjectionKey, value: &Value) -> Result<String, ProjectionError> {
503 let front_matter =
504 serde_yaml::to_string(key).map_err(|err| ProjectionError::Serialize(err.to_string()))?;
505 let body = serde_json::to_string_pretty(value)
506 .map_err(|err| ProjectionError::Serialize(err.to_string()))?;
507 Ok(format!("---\n{front_matter}---\n\n```json\n{body}\n```\n"))
508}
509
510/// Reads a materialized projection file back into its JSON value, the
511/// inverse of [`render_projection_file`]. Only the fenced ```` ```json ````
512/// block is parsed; the front-matter header is documentation for a human
513/// reader and is not required for the round trip.
514fn parse_projection_file(text: &str) -> Result<Value, ProjectionError> {
515 const FENCE_OPEN: &str = "```json";
516 const FENCE_CLOSE: &str = "```";
517 let after_open = text.find(FENCE_OPEN).ok_or_else(|| {
518 ProjectionError::Serialize("materialized projection file missing ```json block".into())
519 })?;
520 let body_start = after_open + FENCE_OPEN.len();
521 let close_offset = text[body_start..].find(FENCE_CLOSE).ok_or_else(|| {
522 ProjectionError::Serialize("materialized projection file missing closing ``` fence".into())
523 })?;
524 let json_body = text[body_start..body_start + close_offset].trim();
525 serde_json::from_str(json_body).map_err(|err| ProjectionError::Serialize(err.to_string()))
526}
527
528#[cfg(test)]
529mod tests {
530 use super::*;
531 use crate::core::projection_placement::RootPreference;
532 use serde_json::json;
533 use std::path::Path;
534 use tempfile::TempDir;
535
536 fn sample_ctx_data() -> Value {
537 json!({
538 "planner": {
539 "plan": "do the thing",
540 "nested": { "field": 42 }
541 },
542 "other_step": { "value": "x" }
543 })
544 }
545
546 fn key(step: Option<&str>, path: Option<&str>) -> ProjectionKey {
547 ProjectionKey {
548 task_id: "T-1".to_string(),
549 run_id: None,
550 step: step.map(str::to_string),
551 path: path.map(str::to_string),
552 }
553 }
554
555 #[test]
556 fn resolve_step_only_returns_step_value() {
557 let ctx_data = sample_ctx_data();
558 let resolved = key(Some("planner"), None).resolve(&ctx_data).unwrap();
559 assert_eq!(
560 resolved,
561 &json!({"plan": "do the thing", "nested": {"field": 42}})
562 );
563 }
564
565 #[test]
566 fn resolve_step_and_path_narrows_to_field() {
567 let ctx_data = sample_ctx_data();
568 let resolved = key(Some("planner"), Some("$.nested.field"))
569 .resolve(&ctx_data)
570 .unwrap();
571 assert_eq!(resolved, &json!(42));
572 }
573
574 #[test]
575 fn resolve_missing_step_returns_none() {
576 assert!(key(Some("does-not-exist"), None)
577 .resolve(&sample_ctx_data())
578 .is_none());
579 }
580
581 #[test]
582 fn resolve_missing_path_returns_none() {
583 assert!(key(Some("planner"), Some("$.nested.missing"))
584 .resolve(&sample_ctx_data())
585 .is_none());
586 }
587
588 // --- GH #36 ST2: bracket-notation path resolve tests ---
589
590 fn parts_ctx_data() -> Value {
591 json!({
592 "worker_step": {
593 "out": "final answer",
594 "parts": {
595 "plan.md": "the plan",
596 "notes": { "todo": "ship it" }
597 }
598 }
599 })
600 }
601
602 #[test]
603 fn resolve_bracket_path_reads_key_with_literal_dot() {
604 let ctx_data = parts_ctx_data();
605 let resolved = key(Some("worker_step"), Some("$.parts[\"plan.md\"]"))
606 .resolve(&ctx_data)
607 .unwrap();
608 assert_eq!(resolved, &json!("the plan"));
609 }
610
611 #[test]
612 fn resolve_bracket_path_chained_brackets() {
613 let ctx_data = json!({
614 "s": { "a": { "x.y": { "z.w": "chained" } } }
615 });
616 let resolved = key(Some("s"), Some("$.a[\"x.y\"][\"z.w\"]"))
617 .resolve(&ctx_data)
618 .unwrap();
619 assert_eq!(resolved, &json!("chained"));
620 }
621
622 #[test]
623 fn resolve_bracket_path_followed_by_dot_segment() {
624 let ctx_data = parts_ctx_data();
625 let resolved = key(Some("worker_step"), Some("$.parts[\"notes\"].todo"))
626 .resolve(&ctx_data)
627 .unwrap();
628 assert_eq!(resolved, &json!("ship it"));
629 }
630
631 /// Existing dot-only paths (no `[`) must resolve byte-for-byte
632 /// identically after the bracket-notation addition — this is the
633 /// regression guard for "paths without `[` take the original
634 /// dot-split code path unchanged".
635 #[test]
636 fn resolve_dot_only_path_unchanged_by_bracket_support() {
637 let ctx_data = sample_ctx_data();
638 let resolved = key(Some("planner"), Some("$.nested.field"))
639 .resolve(&ctx_data)
640 .unwrap();
641 assert_eq!(resolved, &json!(42));
642 }
643
644 #[test]
645 fn resolve_bracket_path_missing_key_returns_none() {
646 assert!(
647 key(Some("worker_step"), Some("$.parts[\"does-not-exist\"]"))
648 .resolve(&parts_ctx_data())
649 .is_none()
650 );
651 }
652
653 #[test]
654 fn resolve_malformed_bracket_path_returns_none() {
655 let ctx_data = parts_ctx_data();
656 for malformed in [
657 "$.parts[\"plan.md\"", // unterminated bracket (missing ])
658 "$.parts[plan.md]", // missing quotes
659 "$.parts[\"\"]", // empty key
660 "$.parts[\"plan.md\"]x", // unseparated plain-segment continuation
661 "$..parts[\"plan.md\"]", // empty segment (leading '.' right after prefix strip)
662 ] {
663 assert!(
664 key(Some("worker_step"), Some(malformed))
665 .resolve(&ctx_data)
666 .is_none(),
667 "expected None for malformed path {malformed:?}"
668 );
669 }
670 }
671
672 #[test]
673 fn file_adapter_project_then_fetch_round_trips() {
674 let dir = TempDir::new().unwrap();
675 let adapter = FileProjectionAdapter::new(dir.path());
676 let k = key(Some("planner"), None);
677 let ctx_data = sample_ctx_data();
678
679 let reference = adapter.project(&k, &ctx_data).unwrap();
680 let path = match &reference {
681 ProjectionRef::File { path } => path.clone(),
682 other => panic!("expected File ref, got {other:?}"),
683 };
684 assert!(Path::new(&path).exists());
685
686 let fetched = adapter.fetch(&k).unwrap();
687 assert_eq!(&fetched, k.resolve(&ctx_data).unwrap());
688 }
689
690 /// GH #27 (follow-up to #23): `with_placement` resolves the target
691 /// through the given `ProjectionPlacement` instead of the byte-compat
692 /// default — the file lands at the custom `dir_template`, and a
693 /// `fetch` against the SAME key/adapter still round-trips (write and
694 /// read agree by construction, since both go through the same
695 /// resolver instance).
696 #[test]
697 fn file_adapter_with_placement_uses_custom_dir_template() {
698 let dir = TempDir::new().unwrap();
699 let placement = ProjectionPlacement {
700 root_preference: RootPreference::WorkDir,
701 dir_template: "custom/{task_id}/out".to_string(),
702 };
703 let adapter = FileProjectionAdapter::with_placement(dir.path(), placement);
704 let k = key(Some("planner"), None);
705 let ctx_data = sample_ctx_data();
706
707 let reference = adapter.project(&k, &ctx_data).unwrap();
708 let path = match &reference {
709 ProjectionRef::File { path } => path.clone(),
710 other => panic!("expected File ref, got {other:?}"),
711 };
712 assert!(
713 path.ends_with("custom/T-1/out/planner.md"),
714 "path must follow the custom dir_template: {path}"
715 );
716 assert!(Path::new(&path).exists());
717
718 let fetched = adapter.fetch(&k).unwrap();
719 assert_eq!(&fetched, k.resolve(&ctx_data).unwrap());
720 }
721
722 #[test]
723 fn file_adapter_reproject_is_idempotent() {
724 let dir = TempDir::new().unwrap();
725 let adapter = FileProjectionAdapter::new(dir.path());
726 let k = key(Some("planner"), None);
727 let ctx_data = sample_ctx_data();
728
729 let first = adapter.project(&k, &ctx_data).unwrap();
730 let second = adapter.project(&k, &ctx_data).unwrap();
731 assert_eq!(first, second);
732 assert_eq!(adapter.fetch(&k).unwrap(), adapter.fetch(&k).unwrap());
733 }
734
735 #[test]
736 fn pointer_line_carries_path_not_value() {
737 let reference = ProjectionRef::File {
738 path: "/tmp/some/materialized.md".to_string(),
739 };
740 let adapter = FileProjectionAdapter::new("/unused");
741 let line = adapter.pointer_line(&reference);
742 assert!(line.contains("/tmp/some/materialized.md"));
743 assert!(!line.contains('{'));
744 }
745
746 #[test]
747 fn fetch_missing_projection_returns_not_found() {
748 let dir = TempDir::new().unwrap();
749 let adapter = FileProjectionAdapter::new(dir.path());
750 let k = key(Some("nope"), None);
751
752 let err = adapter.fetch(&k).unwrap_err();
753 assert!(matches!(err, ProjectionError::NotFound(_)));
754 }
755
756 #[test]
757 fn project_rejects_empty_task_id() {
758 let dir = TempDir::new().unwrap();
759 let adapter = FileProjectionAdapter::new(dir.path());
760 let mut k = key(Some("planner"), None);
761 k.task_id = String::new();
762
763 let err = adapter.project(&k, &sample_ctx_data()).unwrap_err();
764 assert!(matches!(err, ProjectionError::InvalidKey(_)));
765 }
766
767 // ─── subtask-4 / ST2 rework: FileProjectionAdapter::materialize_submission ───
768
769 #[test]
770 fn materialize_submission_writes_value_directly_and_fetch_reads_it_back() {
771 let dir = TempDir::new().unwrap();
772 let adapter = FileProjectionAdapter::new(dir.path());
773 let k = key(Some("planner"), None);
774 let value = json!({"plan": "do the thing"});
775
776 let reference = adapter.materialize_submission(&k, &value, 1, true).unwrap();
777 let path = match &reference {
778 ProjectionRef::File { path } => path.clone(),
779 other => panic!("expected File ref, got {other:?}"),
780 };
781 assert!(Path::new(&path).exists());
782
783 // fetch() only parses the fenced ```json block, so the submission
784 // front matter (attempt / ok) is inert to it — same round trip
785 // contract as `project`.
786 let fetched = adapter.fetch(&k).unwrap();
787 assert_eq!(fetched, value);
788 }
789
790 #[test]
791 fn materialize_submission_front_matter_carries_attempt_and_ok() {
792 let dir = TempDir::new().unwrap();
793 let adapter = FileProjectionAdapter::new(dir.path());
794 let k = key(Some("reviewer"), None);
795
796 let reference = adapter
797 .materialize_submission(&k, &json!("hi"), 2, false)
798 .unwrap();
799 let path = match &reference {
800 ProjectionRef::File { path } => path.clone(),
801 other => panic!("expected File ref, got {other:?}"),
802 };
803 let body = std::fs::read_to_string(path).unwrap();
804 assert!(body.contains("attempt: 2"), "front matter: {body}");
805 assert!(body.contains("ok: false"), "front matter: {body}");
806 assert!(body.contains("step: reviewer"), "front matter: {body}");
807 }
808
809 #[test]
810 fn materialize_submission_resubmit_overwrites_with_latest() {
811 let dir = TempDir::new().unwrap();
812 let adapter = FileProjectionAdapter::new(dir.path());
813 let k = key(Some("planner"), None);
814
815 adapter
816 .materialize_submission(&k, &json!("first"), 1, true)
817 .unwrap();
818 adapter
819 .materialize_submission(&k, &json!("second"), 1, true)
820 .unwrap();
821
822 assert_eq!(adapter.fetch(&k).unwrap(), json!("second"));
823 }
824
825 #[test]
826 fn materialize_submission_rejects_empty_task_id() {
827 let dir = TempDir::new().unwrap();
828 let adapter = FileProjectionAdapter::new(dir.path());
829 let mut k = key(Some("planner"), None);
830 k.task_id = String::new();
831
832 let err = adapter
833 .materialize_submission(&k, &json!("x"), 1, true)
834 .unwrap_err();
835 assert!(matches!(err, ProjectionError::InvalidKey(_)));
836 }
837}