Skip to main content

mlua_swarm/core/
projection_placement.rs

1//! `ProjectionPlacement` — the single resolver that decides where a
2//! Step's materialized OUTPUT file lives on disk.
3//!
4//! # Architecture
5//!
6//! Before this module, the target path
7//! `<root>/workspace/tasks/<task_id>/ctx/<name>.md` was hardcoded
8//! independently at three call sites — the submit-time sink
9//! (`crate::core::engine::Engine::submit_output`'s
10//! `materialize_final_submission`, via
11//! [`crate::core::projection::FileProjectionAdapter`]), the server-side
12//! read-back (`crates/mlua-swarm-server/src/projection.rs`'s
13//! `materialized_file_path` / `resolve_materialized_file`), and the
14//! spawn-time in-flight pointer
15//! (`crates/mlua-swarm-server/src/operator_ws/session.rs`'s
16//! `append_projection_pointer`). The three copies had drifted: the first
17//! two resolved `root` as `work_dir` falling back to `project_root`, the
18//! third used `work_dir` ONLY (no fallback) — an asymmetry a Blueprint
19//! whose spawn only carries `project_root` silently loses its in-flight
20//! `ctx_projection` pointer to.
21//!
22//! [`ProjectionPlacement`] collapses all three copies into one type: the
23//! sole construction site is `Compiler::compile` (see [`Self::from_spec`]),
24//! built once from `Blueprint.projection_placement`
25//! (`mlua_swarm_schema::ProjectionPlacementSpec`), then threaded — the
26//! SAME "construct once, read many" pattern
27//! `crate::core::step_naming::StepNaming` established for GH #23 —
28//! through `crate::blueprint::EngineDispatcher::with_projection_placement`
29//! into `EngineState.projection_placements` (keyed by `StepId`), and read
30//! back via `crate::core::engine::Engine::projection_placement_for`. All
31//! three call sites above now resolve `root` via [`Self::resolve_root`]
32//! and the target path via [`Self::target_path`] — the fallback order and
33//! the directory template are, by construction, identical wherever the
34//! resolver is consulted.
35//!
36//! # Byte-compat default
37//!
38//! [`ProjectionPlacement::default`] reproduces the pre-GH-#27 hardcoded
39//! behavior exactly: `root_preference = WorkDir` (work_dir falling back
40//! to project_root) and `dir_template = "workspace/tasks/{task_id}/ctx"`.
41//! Every Blueprint that never declares `projection_placement` resolves
42//! through this default, so this module changes no observable behavior
43//! for pre-#27 Blueprints.
44
45use std::path::{Path, PathBuf};
46
47use serde::{Deserialize, Serialize};
48
49use crate::core::agent_context::AgentContextView;
50
51/// The `{task_id}` placeholder substituted into
52/// [`ProjectionPlacement::dir_template`] at materialize time.
53const TASK_ID_PLACEHOLDER: &str = "{task_id}";
54
55/// The byte-compat default directory template (pre-GH-#27 hardcoded
56/// value).
57const DEFAULT_DIR_TEMPLATE: &str = "workspace/tasks/{task_id}/ctx";
58
59/// Which of the spawn-time `work_dir` / `project_root` [`AgentContextView`]
60/// fields [`ProjectionPlacement::resolve_root`] prefers, falling back to
61/// the other when the preferred one is absent.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
63pub enum RootPreference {
64    /// Prefer `work_dir`, falling back to `project_root` — the pre-GH-#27
65    /// behavior every existing call site resolved (or, for the spawn-time
66    /// pointer, should have resolved — see the module doc's asymmetry
67    /// note).
68    #[default]
69    WorkDir,
70    /// Prefer `project_root`, falling back to `work_dir`.
71    ProjectRoot,
72}
73
74/// The projection placement resolver (see the module doc for the full
75/// architecture narrative). Small and `Clone` — every consumer holds its
76/// own copy rather than sharing a lock. `Serialize`/`Deserialize`: the
77/// spawn-time in-flight pointer
78/// (`crates/mlua-swarm-server/src/operator_ws/session.rs`'s
79/// `append_projection_pointer`) has no direct `Engine` handle, so
80/// `crate::middleware::agent_context::AgentContextMiddleware` (which does)
81/// resolves this once per spawn and stashes it JSON-serialized into
82/// `ctx.meta.runtime[crate::core::agent_context::PROJECTION_PLACEMENT_KEY]`
83/// — the same channel `AGENT_CONTEXT_KEY` already establishes for
84/// [`AgentContextView`] itself.
85#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
86pub struct ProjectionPlacement {
87    /// Root resolution preference.
88    pub root_preference: RootPreference,
89    /// Target directory template, relative to the resolved root, with
90    /// [`TASK_ID_PLACEHOLDER`] substituted at materialize time. Validated
91    /// by [`Self::validate_dir_template`] before being accepted into a
92    /// `ProjectionPlacement` — every instance in circulation already
93    /// satisfies that contract.
94    pub dir_template: String,
95}
96
97impl Default for ProjectionPlacement {
98    /// The byte-compat default (see the module doc).
99    fn default() -> Self {
100        Self {
101            root_preference: RootPreference::default(),
102            dir_template: DEFAULT_DIR_TEMPLATE.to_string(),
103        }
104    }
105}
106
107/// Everything that can go wrong building a [`ProjectionPlacement`] from a
108/// Blueprint-declared `mlua_swarm_schema::ProjectionPlacementSpec`. Every
109/// variant carries the offending literal for the caller's error message
110/// (the same convention `blueprint::compiler::CompileError` follows).
111#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
112pub enum ProjectionPlacementError {
113    /// `dir_template` was the empty string.
114    #[error("projection_placement.dir_template must not be empty")]
115    EmptyDirTemplate,
116    /// `dir_template` did not contain the `{task_id}` placeholder.
117    #[error("projection_placement.dir_template must contain the '{{task_id}}' placeholder: {0:?}")]
118    MissingTaskIdPlaceholder(String),
119    /// `dir_template` was an absolute path.
120    #[error(
121        "projection_placement.dir_template must be a relative path, got an absolute one: {0:?}"
122    )]
123    AbsolutePath(String),
124    /// `dir_template` contained a `..` path segment.
125    #[error("projection_placement.dir_template must not contain '..' path segments: {0:?}")]
126    ParentDirComponent(String),
127    /// `root` was neither `"work_dir"` nor `"project_root"`.
128    #[error(r#"projection_placement.root must be "work_dir" or "project_root", got {0:?}"#)]
129    InvalidRoot(String),
130}
131
132impl ProjectionPlacement {
133    /// Builds a `ProjectionPlacement` from a Blueprint's optional
134    /// `projection_placement` spec — the sole construction site
135    /// (`blueprint::compiler::Compiler::compile`, mirroring
136    /// `crate::core::step_naming::StepNaming::from_blueprint`'s
137    /// single-construction-site contract). `None` (no
138    /// `Blueprint.projection_placement` declared) returns
139    /// [`Self::default`] unchanged — every pre-GH-#27 Blueprint compiles
140    /// byte-identically through this path. A declared spec's `dir_template`
141    /// is validated via [`Self::validate_dir_template`]; `root`, when
142    /// present, must be exactly `"work_dir"` or `"project_root"`.
143    pub fn from_spec(
144        spec: Option<&mlua_swarm_schema::ProjectionPlacementSpec>,
145    ) -> Result<Self, ProjectionPlacementError> {
146        let Some(spec) = spec else {
147            return Ok(Self::default());
148        };
149        let root_preference = match spec.root.as_deref() {
150            None => RootPreference::default(),
151            Some("work_dir") => RootPreference::WorkDir,
152            Some("project_root") => RootPreference::ProjectRoot,
153            Some(other) => return Err(ProjectionPlacementError::InvalidRoot(other.to_string())),
154        };
155        let dir_template = match &spec.dir_template {
156            None => DEFAULT_DIR_TEMPLATE.to_string(),
157            Some(template) => {
158                Self::validate_dir_template(template)?;
159                template.clone()
160            }
161        };
162        Ok(Self {
163            root_preference,
164            dir_template,
165        })
166    }
167
168    /// Validates a `dir_template` candidate: non-empty, contains the
169    /// `{task_id}` placeholder, is relative (not absolute, no leading
170    /// `/`), and contains no `..` path segment. Shared by [`Self::from_spec`]
171    /// so every declared `ProjectionPlacement` in circulation already
172    /// satisfies this contract — [`Self::target_dir`] never has to
173    /// re-check it.
174    pub fn validate_dir_template(template: &str) -> Result<(), ProjectionPlacementError> {
175        if template.is_empty() {
176            return Err(ProjectionPlacementError::EmptyDirTemplate);
177        }
178        if !template.contains(TASK_ID_PLACEHOLDER) {
179            return Err(ProjectionPlacementError::MissingTaskIdPlaceholder(
180                template.to_string(),
181            ));
182        }
183        if template.starts_with('/') || Path::new(template).is_absolute() {
184            return Err(ProjectionPlacementError::AbsolutePath(template.to_string()));
185        }
186        if template.split('/').any(|segment| segment == "..") {
187            return Err(ProjectionPlacementError::ParentDirComponent(
188                template.to_string(),
189            ));
190        }
191        Ok(())
192    }
193
194    /// Resolves the materialize root off `view`'s `work_dir` /
195    /// `project_root` fields, per [`Self::root_preference`], falling back
196    /// to the other when the preferred one is absent. `None` when neither
197    /// is present — every call site's existing fail-open contract
198    /// (unresolved root ⇒ skip materialize, never fail the caller) is
199    /// preserved unchanged.
200    pub fn resolve_root(&self, view: &AgentContextView) -> Option<String> {
201        match self.root_preference {
202            RootPreference::WorkDir => view.work_dir.clone().or_else(|| view.project_root.clone()),
203            RootPreference::ProjectRoot => {
204                view.project_root.clone().or_else(|| view.work_dir.clone())
205            }
206        }
207    }
208
209    /// The materialize target DIRECTORY for `task_id`, rooted at `root`:
210    /// [`Self::dir_template`] with [`TASK_ID_PLACEHOLDER`] substituted,
211    /// joined onto `root` one path segment at a time (so a
212    /// multi-segment template like the default
213    /// `"workspace/tasks/{task_id}/ctx"` builds the same cross-platform
214    /// `PathBuf` the pre-GH-#27 hardcoded `.join("workspace").join("tasks")…`
215    /// chain did).
216    pub fn target_dir(&self, root: impl AsRef<Path>, task_id: &str) -> PathBuf {
217        let rendered = self.dir_template.replace(TASK_ID_PLACEHOLDER, task_id);
218        let mut path = root.as_ref().to_path_buf();
219        for segment in rendered.split('/') {
220            if !segment.is_empty() {
221                path = path.join(segment);
222            }
223        }
224        path
225    }
226
227    /// The materialize target FILE for `task_id` / `stem` (`stem` is the
228    /// canonical step name, or `"_ctx"` for the whole-ctx submission —
229    /// see [`crate::core::projection::ProjectionKey::step_slug`]):
230    /// [`Self::target_dir`] joined with `"<stem>.md"`.
231    pub fn target_path(&self, root: impl AsRef<Path>, task_id: &str, stem: &str) -> PathBuf {
232        self.target_dir(root, task_id).join(format!("{stem}.md"))
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239
240    fn view(work_dir: Option<&str>, project_root: Option<&str>) -> AgentContextView {
241        AgentContextView {
242            work_dir: work_dir.map(String::from),
243            project_root: project_root.map(String::from),
244            ..AgentContextView::default()
245        }
246    }
247
248    // ─── from_spec ───────────────────────────────────────────────────
249
250    #[test]
251    fn from_spec_none_is_byte_compat_default() {
252        let placement = ProjectionPlacement::from_spec(None).expect("no spec is Ok");
253        assert_eq!(placement, ProjectionPlacement::default());
254        assert_eq!(placement.root_preference, RootPreference::WorkDir);
255        assert_eq!(placement.dir_template, "workspace/tasks/{task_id}/ctx");
256    }
257
258    #[test]
259    fn from_spec_declared_root_project_root() {
260        let spec = mlua_swarm_schema::ProjectionPlacementSpec {
261            root: Some("project_root".to_string()),
262            dir_template: None,
263        };
264        let placement = ProjectionPlacement::from_spec(Some(&spec)).expect("valid spec");
265        assert_eq!(placement.root_preference, RootPreference::ProjectRoot);
266        // dir_template still defaults when unset alongside a declared root.
267        assert_eq!(placement.dir_template, "workspace/tasks/{task_id}/ctx");
268    }
269
270    #[test]
271    fn from_spec_declared_custom_dir_template() {
272        let spec = mlua_swarm_schema::ProjectionPlacementSpec {
273            root: None,
274            dir_template: Some("custom/{task_id}/out".to_string()),
275        };
276        let placement = ProjectionPlacement::from_spec(Some(&spec)).expect("valid spec");
277        assert_eq!(placement.root_preference, RootPreference::WorkDir);
278        assert_eq!(placement.dir_template, "custom/{task_id}/out");
279    }
280
281    #[test]
282    fn from_spec_rejects_invalid_root_literal() {
283        let spec = mlua_swarm_schema::ProjectionPlacementSpec {
284            root: Some("nope".to_string()),
285            dir_template: None,
286        };
287        let err = ProjectionPlacement::from_spec(Some(&spec)).expect_err("invalid root rejected");
288        assert!(matches!(err, ProjectionPlacementError::InvalidRoot(r) if r == "nope"));
289    }
290
291    // ─── validate_dir_template ───────────────────────────────────────
292
293    #[test]
294    fn validate_dir_template_rejects_empty() {
295        let err = ProjectionPlacement::validate_dir_template("").unwrap_err();
296        assert_eq!(err, ProjectionPlacementError::EmptyDirTemplate);
297    }
298
299    #[test]
300    fn validate_dir_template_rejects_missing_task_id_placeholder() {
301        let err = ProjectionPlacement::validate_dir_template("workspace/tasks/ctx").unwrap_err();
302        assert!(
303            matches!(err, ProjectionPlacementError::MissingTaskIdPlaceholder(t) if t == "workspace/tasks/ctx")
304        );
305    }
306
307    #[test]
308    fn validate_dir_template_rejects_absolute_path() {
309        let err = ProjectionPlacement::validate_dir_template("/abs/{task_id}").unwrap_err();
310        assert!(matches!(err, ProjectionPlacementError::AbsolutePath(t) if t == "/abs/{task_id}"));
311    }
312
313    #[test]
314    fn validate_dir_template_rejects_parent_dir_component() {
315        let err = ProjectionPlacement::validate_dir_template("../{task_id}/ctx").unwrap_err();
316        assert!(
317            matches!(err, ProjectionPlacementError::ParentDirComponent(t) if t == "../{task_id}/ctx")
318        );
319    }
320
321    #[test]
322    fn validate_dir_template_accepts_default() {
323        ProjectionPlacement::validate_dir_template("workspace/tasks/{task_id}/ctx")
324            .expect("default template is valid");
325    }
326
327    #[test]
328    fn from_spec_propagates_dir_template_validation_error() {
329        let spec = mlua_swarm_schema::ProjectionPlacementSpec {
330            root: None,
331            dir_template: Some(String::new()),
332        };
333        let err = ProjectionPlacement::from_spec(Some(&spec)).expect_err("empty template rejected");
334        assert_eq!(err, ProjectionPlacementError::EmptyDirTemplate);
335    }
336
337    // ─── resolve_root ────────────────────────────────────────────────
338
339    #[test]
340    fn resolve_root_work_dir_preference_prefers_work_dir() {
341        let placement = ProjectionPlacement::default();
342        let v = view(Some("/work"), Some("/proj"));
343        assert_eq!(placement.resolve_root(&v).as_deref(), Some("/work"));
344    }
345
346    #[test]
347    fn resolve_root_work_dir_preference_falls_back_to_project_root() {
348        let placement = ProjectionPlacement::default();
349        let v = view(None, Some("/proj"));
350        assert_eq!(placement.resolve_root(&v).as_deref(), Some("/proj"));
351    }
352
353    #[test]
354    fn resolve_root_project_root_preference_prefers_project_root() {
355        let placement = ProjectionPlacement {
356            root_preference: RootPreference::ProjectRoot,
357            ..ProjectionPlacement::default()
358        };
359        let v = view(Some("/work"), Some("/proj"));
360        assert_eq!(placement.resolve_root(&v).as_deref(), Some("/proj"));
361    }
362
363    #[test]
364    fn resolve_root_project_root_preference_falls_back_to_work_dir() {
365        let placement = ProjectionPlacement {
366            root_preference: RootPreference::ProjectRoot,
367            ..ProjectionPlacement::default()
368        };
369        let v = view(Some("/work"), None);
370        assert_eq!(placement.resolve_root(&v).as_deref(), Some("/work"));
371    }
372
373    #[test]
374    fn resolve_root_neither_present_is_none() {
375        let placement = ProjectionPlacement::default();
376        let v = view(None, None);
377        assert_eq!(placement.resolve_root(&v), None);
378    }
379
380    // ─── target_dir / target_path ────────────────────────────────────
381
382    #[test]
383    fn target_path_default_matches_pre_gh24_hardcoded_layout() {
384        let placement = ProjectionPlacement::default();
385        let path = placement.target_path("/root", "T-1", "_ctx");
386        assert_eq!(
387            path,
388            std::path::Path::new("/root/workspace/tasks/T-1/ctx/_ctx.md")
389        );
390    }
391
392    #[test]
393    fn target_path_custom_template_substitutes_task_id() {
394        let placement = ProjectionPlacement {
395            root_preference: RootPreference::default(),
396            dir_template: "custom/{task_id}/out".to_string(),
397        };
398        let path = placement.target_path("/root", "T-2", "planner");
399        assert_eq!(
400            path,
401            std::path::Path::new("/root/custom/T-2/out/planner.md")
402        );
403    }
404
405    #[test]
406    fn target_dir_and_target_path_are_write_read_consistent() {
407        // 3-path consistency proof (unit level): the same (root, task_id,
408        // stem) resolved through the SAME `ProjectionPlacement` instance
409        // always yields the identical path, regardless of how many times
410        // it is called — a write-time call and a later read-time call
411        // converge on the same location by construction.
412        let placement = ProjectionPlacement {
413            root_preference: RootPreference::ProjectRoot,
414            dir_template: "custom/{task_id}/out".to_string(),
415        };
416        let written = placement.target_path("/proj", "T-3", "reviewer");
417        let read_back = placement.target_path("/proj", "T-3", "reviewer");
418        assert_eq!(written, read_back);
419        assert_eq!(
420            written,
421            std::path::Path::new("/proj/custom/T-3/out/reviewer.md")
422        );
423    }
424
425    // ─── serde round-trip (ctx.meta.runtime channel) ────────────────
426
427    /// GH #27: `ProjectionPlacement` round-trips through JSON unchanged —
428    /// the exact channel `AgentContextMiddleware` uses to stash a
429    /// resolved instance into `ctx.meta.runtime` for the spawn-time
430    /// in-flight pointer (which has no direct `Engine` handle to call
431    /// `Engine::projection_placement_for` with).
432    #[test]
433    fn serde_round_trip_preserves_custom_placement() {
434        let placement = ProjectionPlacement {
435            root_preference: RootPreference::ProjectRoot,
436            dir_template: "custom/{task_id}/out".to_string(),
437        };
438        let json = serde_json::to_value(&placement).expect("serializes");
439        let back: ProjectionPlacement = serde_json::from_value(json).expect("deserializes");
440        assert_eq!(back, placement);
441    }
442
443    #[test]
444    fn serde_round_trip_preserves_default_placement() {
445        let placement = ProjectionPlacement::default();
446        let json = serde_json::to_value(&placement).expect("serializes");
447        let back: ProjectionPlacement = serde_json::from_value(json).expect("deserializes");
448        assert_eq!(back, placement);
449    }
450}