Skip to main content

mlua_swarm/core/
step_naming.rs

1//! `StepNaming` — GH #23: the Blueprint-declared step-projection naming
2//! table.
3//!
4//! Before this module, a dispatched Step was addressable under two
5//! independent, occasionally-colliding names: the flow.ir data-plane
6//! producer name (`Step.ref` / `AgentDef.name`) and the `result_ref`
7//! ctx-path key (`Step.out`'s top-level path segment). Consumers
8//! (`ContextPolicy.steps` filter / `StepPointer.name` / the REST
9//! `:step` resolver / `FileProjectionAdapter`'s file stem) resolved the
10//! union of both, data-plane winning on collision — see
11//! `crates/mlua-swarm-server/src/projection.rs`'s `enumerate_steps` for
12//! the pre-GH-#23 runtime union rule this table statically replaces.
13//!
14//! [`StepNaming`] collapses that union into a single addressing space,
15//! built ONCE per Blueprint at
16//! [`blueprint::compiler::Compiler::compile`](crate::blueprint::compiler::Compiler::compile)
17//! time (the sole construction site — see [`StepNaming::from_blueprint`]),
18//! then threaded read-only from there: `EngineDispatcher` stashes an
19//! `Arc<StepNaming>` per dispatched task
20//! (`EngineState.step_namings`, keyed by `StepId`), and
21//! `Engine::step_naming_for` is the accessor later consumers pull from.
22//!
23//! GH #23 subtask-2/3 completed the 5-consumer switch-over this module's
24//! table backs — `Engine::submit_output`/`materialize_final_submission`
25//! (data-plane write + file stem), `ContextPolicy.allows_step`
26//! (`crates/mlua-swarm-server/src/worker.rs`'s `allows_step_canonical`
27//! seam), `StepPointer`/`StepSummary` assembly, and the REST `:step`
28//! resolver all resolve through [`StepNaming::canonical_of_producer`] /
29//! [`StepNaming::resolve`] instead of re-deriving the pre-GH-#23 union
30//! rule at read time. `crate::store::output::OutputStore::get_latest_by_name_in_run`
31//! (Layer 2) closed the cross-Run same-name race this table's
32//! canonicalization alone could not: a declared or undeclared name is
33//! now resolved Run-scoped regardless. An undeclared step's `canonical`
34//! stays its raw `Step.ref` and its `aliases` still include the
35//! `result_ref` top-level segment, so the pre-GH-#23 union's observable
36//! behavior is unchanged for any Blueprint that never declares
37//! `AgentMeta.projection_name`.
38
39use std::collections::{BTreeMap, BTreeSet};
40
41use mlua_flow_ir::{Expr, Node};
42
43use crate::blueprint::Blueprint;
44
45/// One step's resolved canonical projection name plus every alias name
46/// consumers may still address it by.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct StepNameEntry {
49    /// The name every consumer converges on: the `AgentMeta.projection_name`
50    /// declared for the step's Blueprint agent, or (when undeclared) the
51    /// flow.ir `Step.ref` (the data-plane producer name) unchanged.
52    pub canonical: String,
53    /// Every name this step should ALSO resolve under: always includes the
54    /// `Step.ref`, and — when the Step's `out` is a `Path` expr — the
55    /// top-level segment of that path (the pre-GH-#23 `result_ref`-derived
56    /// name). A bare `Step.ref` that happens to equal its own `out` top
57    /// segment collapses to a single-element set; this is not a
58    /// collision (see [`StepNaming::from_blueprint`]'s doc).
59    pub aliases: BTreeSet<String>,
60}
61
62/// Non-fatal collision detected while building a [`StepNaming`] table:
63/// two UNDECLARED steps' canonical/alias name sets intersect.
64/// Registration still proceeds — the pre-GH-#23 union rule's
65/// "data-plane wins" tie-break applies (see
66/// [`StepNaming::from_blueprint`]) — but the caller is expected to
67/// surface this via `tracing::warn!`. This type carries no logging side
68/// effect itself, matching the crate's existing convention
69/// (`blueprint::compiler`'s static-walk helpers) of returning data and
70/// letting the caller decide how to report it.
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct StepNamingWarning {
73    /// The contested name.
74    pub name: String,
75    /// The step (`Step.ref`) that claimed `name` first.
76    pub first_step_ref: String,
77    /// The step (`Step.ref`) whose claim collided with the first.
78    pub second_step_ref: String,
79}
80
81/// Fatal collision: at least one side of the clash declared `name` via
82/// `AgentMeta.projection_name`. Rejected at registration time — the same
83/// "Blueprint validation error" family as
84/// `blueprint::compiler::CompileError`'s existing fail-fast checks
85/// (`DuplicateAgent` / `UnresolvedMetaRef` / …).
86#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
87#[error(
88    "StepNaming collision: name '{name}' is claimed by both step '{first_step_ref}' and step \
89     '{second_step_ref}' ({reason})"
90)]
91pub struct StepNamingError {
92    /// The contested name.
93    pub name: String,
94    /// The step (`Step.ref`) that claimed `name` first.
95    pub first_step_ref: String,
96    /// The step (`Step.ref`) whose claim collided with the first.
97    pub second_step_ref: String,
98    /// Human-readable reason (which side(s) declared `projection_name`).
99    pub reason: String,
100}
101
102/// GH #23 — the single addressing-space table for one Blueprint's
103/// dispatched steps. See the module doc for the construction site and
104/// storage/accessor threading; this doc covers the resolution rules.
105///
106/// # Canonical / alias resolution
107///
108/// For every distinct `Step.ref` appearing anywhere in the flow (`Seq` /
109/// `Branch` / `Fanout` / `Loop` / `Try` nesting all walked — see
110/// [`Self::from_blueprint`]):
111///
112/// - `canonical` = the dispatching agent's `AgentMeta.projection_name`
113///   when declared, else the `Step.ref` itself (byte-identical to
114///   pre-GH-#23 behavior for undeclared Blueprints).
115/// - `aliases` = `{Step.ref}` ∪ every `out` Path expr's top-level segment
116///   seen across every occurrence of that `ref` in the flow (`"$.plan"`
117///   → `"plan"`, `"$.a.b"` → `"a"`; a non-`Path` `out` contributes
118///   nothing — best-effort, mirroring `blueprint::compiler`'s existing
119///   static-walk convention of skipping what can't be inspected
120///   structurally).
121///
122/// Every name (`canonical` + every alias) is checked for cross-step
123/// collisions. A clash where either side declared `projection_name` is a
124/// hard [`StepNamingError`] (registration is rejected outright). A clash
125/// between two undeclared steps is a soft [`StepNamingWarning`]: the
126/// pre-GH-#23 union rule's "data-plane wins" precedence is preserved by
127/// letting the step whose OWN `ref` equals the contested name own it in
128/// [`Self::resolve`] — an alias derived merely from another step's `out`
129/// segment never displaces it.
130#[derive(Debug, Clone, Default)]
131pub struct StepNaming {
132    by_ref: BTreeMap<String, String>,
133    by_name: BTreeMap<String, String>,
134    entries: BTreeMap<String, StepNameEntry>,
135}
136
137impl StepNaming {
138    /// Resolve `name` (canonical or alias) to its canonical name.
139    pub fn resolve(&self, name: &str) -> Option<&str> {
140        self.by_name.get(name).map(String::as_str)
141    }
142
143    /// Resolve a Step's data-plane producer name (`Step.ref` /
144    /// `AgentDef.name`) to its canonical name.
145    pub fn canonical_of_producer(&self, ref_name: &str) -> Option<&str> {
146        self.by_ref.get(ref_name).map(String::as_str)
147    }
148
149    /// Every canonical name this table declares (subtask-2/3 enumeration
150    /// consumers, e.g. `McpQueryAdapter::enumerate_steps`).
151    pub fn names(&self) -> impl Iterator<Item = &str> {
152        self.entries.keys().map(String::as_str)
153    }
154
155    /// Every full [`StepNameEntry`] (canonical + aliases) this table
156    /// holds.
157    pub fn entries(&self) -> impl Iterator<Item = &StepNameEntry> {
158        self.entries.values()
159    }
160
161    /// Build the table from a Blueprint's `flow` + `agents` — the sole
162    /// construction site (see the module + struct docs). Returns the
163    /// table plus any soft [`StepNamingWarning`]s (the caller decides
164    /// how to log them, typically via `tracing::warn!`); a hard
165    /// collision returns [`StepNamingError`] instead.
166    pub fn from_blueprint(
167        bp: &Blueprint,
168    ) -> Result<(StepNaming, Vec<StepNamingWarning>), StepNamingError> {
169        // 1. Static walk: collect every Step occurrence's (ref, out-top-segment).
170        let mut occurrences: Vec<(String, Option<String>)> = Vec::new();
171        collect_steps(&bp.flow, &mut occurrences);
172
173        // 2. Group by ref — a `Step.ref` may recur (e.g. inside a Loop
174        //    body, or a flow author simply dispatching the same agent
175        //    twice); the same agent always resolves to the same
176        //    canonical name, so all of its occurrences fold into one
177        //    entry, and every `out`-top segment seen across occurrences
178        //    is unioned into its alias set.
179        let mut order: Vec<String> = Vec::new();
180        let mut out_tops: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
181        for (ref_, top) in occurrences {
182            let tops = out_tops.entry(ref_.clone()).or_default();
183            if let Some(top) = top {
184                tops.insert(top);
185            }
186            if !order.contains(&ref_) {
187                order.push(ref_);
188            }
189        }
190
191        // 3. `AgentDef.name -> AgentMeta.projection_name` (declared-only).
192        let declared: BTreeMap<&str, &str> = bp
193            .agents
194            .iter()
195            .filter_map(|ad| {
196                let name = ad.meta.as_ref()?.projection_name.as_deref()?;
197                Some((ad.name.as_str(), name))
198            })
199            .collect();
200
201        let mut naming = StepNaming::default();
202        let mut warnings = Vec::new();
203        // name -> (owning ref, declared?) — tracks current ownership so a
204        // later occurrence can detect + (for soft clashes) re-arbitrate.
205        let mut claims: BTreeMap<String, (String, bool)> = BTreeMap::new();
206
207        for ref_ in &order {
208            let is_declared = declared.contains_key(ref_.as_str());
209            let canonical = declared
210                .get(ref_.as_str())
211                .map(|s| s.to_string())
212                .unwrap_or_else(|| ref_.clone());
213            let mut aliases: BTreeSet<String> = out_tops.remove(ref_).unwrap_or_default();
214            aliases.insert(ref_.clone());
215
216            let mut claimed: BTreeSet<String> = aliases.clone();
217            claimed.insert(canonical.clone());
218
219            for name in &claimed {
220                match claims.get(name).cloned() {
221                    None => {
222                        claims.insert(name.clone(), (ref_.clone(), is_declared));
223                        naming.by_name.insert(name.clone(), canonical.clone());
224                    }
225                    Some((other_ref, other_declared)) => {
226                        if is_declared || other_declared {
227                            return Err(StepNamingError {
228                                name: name.clone(),
229                                first_step_ref: other_ref,
230                                second_step_ref: ref_.clone(),
231                                reason: collision_reason(other_declared, is_declared),
232                            });
233                        }
234                        warnings.push(StepNamingWarning {
235                            name: name.clone(),
236                            first_step_ref: other_ref.clone(),
237                            second_step_ref: ref_.clone(),
238                        });
239                        // Soft clash between two undeclared steps: the
240                        // pre-GH-#23 union rule's data-plane-first
241                        // precedence — whichever step's OWN `ref` equals
242                        // the contested name owns it. If neither (or
243                        // both, which cannot happen since refs are
244                        // unique) side's ref matches, the first-seen
245                        // owner is kept (deterministic tie-break).
246                        if ref_ == name && &other_ref != name {
247                            claims.insert(name.clone(), (ref_.clone(), false));
248                            naming.by_name.insert(name.clone(), canonical.clone());
249                        }
250                    }
251                }
252            }
253
254            naming.by_ref.insert(ref_.clone(), canonical.clone());
255            naming
256                .entries
257                .insert(canonical.clone(), StepNameEntry { canonical, aliases });
258        }
259
260        Ok((naming, warnings))
261    }
262}
263
264fn collision_reason(other_declared: bool, is_declared: bool) -> String {
265    match (other_declared, is_declared) {
266        (true, true) => "both sides declare projection_name".to_string(),
267        (true, false) => "the first step declares projection_name".to_string(),
268        (false, true) => "the second step declares projection_name".to_string(),
269        (false, false) => {
270            unreachable!("hard StepNamingError requires at least one declared side")
271        }
272    }
273}
274
275/// Walk the flow `Node` (same recursion shape as
276/// `blueprint::compiler::collect_refs` / `collect_step_meta_refs`) and
277/// collect every `Step`'s `(ref, out-top-segment)`.
278fn collect_steps(node: &Node, out: &mut Vec<(String, Option<String>)>) {
279    match node {
280        Node::Step {
281            ref_,
282            out: out_expr,
283            ..
284        } => {
285            out.push((ref_.clone(), out_top_segment(out_expr)));
286        }
287        Node::Seq { children } => {
288            for child in children {
289                collect_steps(child, out);
290            }
291        }
292        Node::Branch { then_, else_, .. } => {
293            collect_steps(then_, out);
294            collect_steps(else_, out);
295        }
296        Node::Fanout { body, .. } => collect_steps(body, out),
297        Node::Loop { body, .. } => collect_steps(body, out),
298        Node::Try { body, catch, .. } => {
299            collect_steps(body, out);
300            collect_steps(catch, out);
301        }
302        Node::Assign { .. } => {} // The Assign node carries no ref.
303    }
304}
305
306/// Extract the top-level segment of a `Step.out` `Path` expr
307/// (`"$.plan"` → `"plan"`, `"$.a.b"` → `"a"`). Any other `Expr` shape (or
308/// an empty path) contributes no alias — best-effort, mirroring
309/// `blueprint::compiler`'s existing static-walk convention of skipping
310/// what can't be inspected structurally (flow.ir's own `write_path`
311/// requires `Step.out` to be a `Path` expr at eval time regardless, so a
312/// non-`Path` `out` is already a runtime error there — this walk just
313/// never invents an alias for it statically).
314fn out_top_segment(expr: &Expr) -> Option<String> {
315    let Expr::Path { at } = expr else {
316        return None;
317    };
318    let rendered = at.to_string();
319    let trimmed = rendered
320        .strip_prefix("$.")
321        .or_else(|| rendered.strip_prefix('$'))?;
322    trimmed
323        .split('.')
324        .find(|s| !s.is_empty())
325        .map(str::to_string)
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331    use crate::blueprint::{
332        current_schema_version, AgentDef, AgentKind, AgentMeta, BlueprintMetadata, CompilerHints,
333        CompilerStrategy,
334    };
335    use mlua_flow_ir::JoinMode;
336    use serde_json::json;
337
338    fn path(s: &str) -> Expr {
339        Expr::Path {
340            at: s.parse().expect("literal test path"),
341        }
342    }
343
344    fn step(ref_: &str, out: &str) -> Node {
345        Node::Step {
346            ref_: ref_.to_string(),
347            in_: path("$.in"),
348            out: path(out),
349        }
350    }
351
352    fn agent(name: &str, projection_name: Option<&str>) -> AgentDef {
353        AgentDef {
354            name: name.to_string(),
355            kind: AgentKind::RustFn,
356            spec: json!({ "fn_id": name }),
357            profile: None,
358            meta: Some(AgentMeta {
359                projection_name: projection_name.map(str::to_string),
360                ..Default::default()
361            }),
362            runner: None,
363            runner_ref: None,
364            verdict: None,
365        }
366    }
367
368    fn bp(flow: Node, agents: Vec<AgentDef>) -> Blueprint {
369        Blueprint {
370            schema_version: current_schema_version(),
371            id: "step-naming-ut".into(),
372            flow,
373            agents,
374            operators: vec![],
375            metas: vec![],
376            hints: CompilerHints::default(),
377            strategy: CompilerStrategy::default(),
378            metadata: BlueprintMetadata::default(),
379            spawner_hints: Default::default(),
380            default_agent_kind: AgentKind::Operator,
381            default_operator_kind: None,
382            default_init_ctx: None,
383            default_agent_ctx: None,
384            default_context_policy: None,
385            projection_placement: None,
386            audits: vec![],
387            degradation_policy: None,
388            runners: vec![],
389            default_runner: None,
390            check_policy: None,
391        }
392    }
393
394    #[test]
395    fn declared_step_canonical_is_projection_name_aliases_are_ref_and_out_top() {
396        let flow = step("planner", "$.plan");
397        let bp = bp(flow, vec![agent("planner", Some("plan-out"))]);
398        let (naming, warnings) = StepNaming::from_blueprint(&bp).expect("no collision");
399        assert!(warnings.is_empty());
400        assert_eq!(naming.canonical_of_producer("planner"), Some("plan-out"));
401        let entry = naming
402            .entries()
403            .find(|e| e.canonical == "plan-out")
404            .expect("entry present");
405        assert_eq!(
406            entry.aliases,
407            BTreeSet::from(["planner".to_string(), "plan".to_string()])
408        );
409    }
410
411    #[test]
412    fn undeclared_step_canonical_is_ref_aliases_are_ref_and_out_top() {
413        let flow = step("worker", "$.result");
414        let bp = bp(flow, vec![agent("worker", None)]);
415        let (naming, warnings) = StepNaming::from_blueprint(&bp).expect("no collision");
416        assert!(warnings.is_empty());
417        assert_eq!(naming.canonical_of_producer("worker"), Some("worker"));
418        let entry = naming
419            .entries()
420            .find(|e| e.canonical == "worker")
421            .expect("entry present");
422        assert_eq!(
423            entry.aliases,
424            BTreeSet::from(["worker".to_string(), "result".to_string()])
425        );
426    }
427
428    #[test]
429    fn ref_equal_to_out_top_collapses_to_single_alias_and_is_not_a_collision() {
430        let flow = step("scout", "$.scout");
431        let bp = bp(flow, vec![agent("scout", None)]);
432        let (naming, warnings) = StepNaming::from_blueprint(&bp).expect("no collision");
433        assert!(warnings.is_empty());
434        let entry = naming
435            .entries()
436            .find(|e| e.canonical == "scout")
437            .expect("entry present");
438        assert_eq!(entry.aliases, BTreeSet::from(["scout".to_string()]));
439    }
440
441    #[test]
442    fn declared_name_colliding_with_another_steps_ref_is_a_hard_error() {
443        // Step "a" declares projection_name "b"; step "b" is undeclared
444        // (its own ref IS "b") — the two claim the same canonical name.
445        let flow = Node::Seq {
446            children: vec![step("a", "$.a_out"), step("b", "$.b_out")],
447        };
448        let bp = bp(flow, vec![agent("a", Some("b")), agent("b", None)]);
449        let err = StepNaming::from_blueprint(&bp).expect_err("declared collision must reject");
450        assert_eq!(err.name, "b");
451        assert!(
452            err.reason.contains("declare"),
453            "reason should explain which side declared: {}",
454            err.reason
455        );
456    }
457
458    #[test]
459    fn undeclared_collision_is_ok_with_a_warning_and_data_plane_priority() {
460        // Step "foo" (undeclared) has out "$.bar" — alias "bar".
461        // Step "bar" (undeclared) has its own ref "bar" — canonical "bar".
462        // Both claim the name "bar"; neither declares projection_name, so
463        // this is a soft warning, and the data-plane owner ("bar"'s own
464        // ref) must win `resolve("bar")`.
465        let flow = Node::Seq {
466            children: vec![step("foo", "$.bar"), step("bar", "$.baz")],
467        };
468        let bp = bp(flow, vec![agent("foo", None), agent("bar", None)]);
469        let (naming, warnings) = StepNaming::from_blueprint(&bp).expect("soft collision is Ok");
470        assert_eq!(warnings.len(), 1);
471        assert_eq!(warnings[0].name, "bar");
472        assert_eq!(naming.resolve("bar"), Some("bar"));
473    }
474
475    #[test]
476    fn walk_covers_seq_branch_fanout_loop_and_try_nesting() {
477        let flow = Node::Seq {
478            children: vec![
479                step("in-seq", "$.a"),
480                Node::Branch {
481                    cond: Expr::Lit { value: json!(true) },
482                    then_: Box::new(step("in-then", "$.b")),
483                    else_: Box::new(step("in-else", "$.c")),
484                },
485                Node::Fanout {
486                    items: path("$.items"),
487                    bind: path("$.item"),
488                    body: Box::new(step("in-fanout", "$.d")),
489                    join: JoinMode::All,
490                    out: path("$.results"),
491                },
492                Node::Loop {
493                    counter: path("$.n"),
494                    cond: Expr::Lit { value: json!(true) },
495                    body: Box::new(step("in-loop", "$.e")),
496                    max: 3,
497                },
498                Node::Try {
499                    body: Box::new(step("in-try", "$.f")),
500                    catch: Box::new(step("in-catch", "$.g")),
501                    err_at: None,
502                },
503                Node::Assign {
504                    at: path("$.h"),
505                    value: Expr::Lit { value: json!(1) },
506                },
507            ],
508        };
509        let agents = vec![
510            "in-seq",
511            "in-then",
512            "in-else",
513            "in-fanout",
514            "in-loop",
515            "in-try",
516            "in-catch",
517        ]
518        .into_iter()
519        .map(|n| agent(n, None))
520        .collect();
521        let bp = bp(flow, agents);
522        let (naming, warnings) = StepNaming::from_blueprint(&bp).expect("no collision");
523        assert!(warnings.is_empty());
524        let mut names: Vec<&str> = naming.names().collect();
525        names.sort_unstable();
526        assert_eq!(
527            names,
528            vec![
529                "in-catch",
530                "in-else",
531                "in-fanout",
532                "in-loop",
533                "in-seq",
534                "in-then",
535                "in-try",
536            ]
537        );
538    }
539
540    #[test]
541    fn resolve_returns_canonical_for_alias_lookup() {
542        let flow = step("planner", "$.plan");
543        let bp = bp(flow, vec![agent("planner", Some("plan-out"))]);
544        let (naming, _) = StepNaming::from_blueprint(&bp).expect("no collision");
545        assert_eq!(naming.resolve("plan-out"), Some("plan-out"));
546        assert_eq!(naming.resolve("planner"), Some("plan-out"));
547        assert_eq!(naming.resolve("plan"), Some("plan-out"));
548        assert_eq!(naming.resolve("does-not-exist"), None);
549    }
550
551    #[test]
552    fn same_ref_dispatched_twice_unions_out_top_aliases_without_self_collision() {
553        let flow = Node::Seq {
554            children: vec![step("worker", "$.first"), step("worker", "$.second")],
555        };
556        let bp = bp(flow, vec![agent("worker", None)]);
557        let (naming, warnings) = StepNaming::from_blueprint(&bp).expect("no collision");
558        assert!(warnings.is_empty());
559        let entry = naming
560            .entries()
561            .find(|e| e.canonical == "worker")
562            .expect("entry present");
563        assert_eq!(
564            entry.aliases,
565            BTreeSet::from([
566                "worker".to_string(),
567                "first".to_string(),
568                "second".to_string()
569            ])
570        );
571    }
572}