Skip to main content

mlua_swarm/
blueprint.rs

1//! Blueprint runner — glue that executes a flow.ir AST
2//! (`mlua_flow_ir::Node`) through the engine. Each `Step.ref` is run as a
3//! single task via `start_task` + `dispatch_attempt_with_run_ctx`, and
4//! the resulting `Pass` `Value` is written back to `Step.out`.
5//!
6//! **Fully-async chain.** Uses `mlua_flow_ir::eval_async` and
7//! `AsyncDispatcher`; `block_on` and `spawn_blocking` are never mixed in,
8//! so the whole stack stays consistent with the engine's tokio async
9//! world.
10//!
11//! # Usage
12//!
13//! ```ignore
14//! let dispatcher = EngineDispatcher::with_spawner(engine.clone(), op_token, spawner);
15//! let bp: mlua_flow_ir::Node = serde_json::from_str(BP_JSON)?;
16//! let final_ctx = mlua_flow_ir::eval_async(&bp, init_ctx, &dispatcher).await?;
17//! ```
18//!
19//! # Schema types (the IF crate)
20//!
21//! `Blueprint` / `AgentDef` / `AgentKind` and friends live in the
22//! `mlua_swarm_schema` crate and are re-exported from here.
23//! The `struct`/`enum` set that used to live directly in `src/blueprint.rs`
24//! has been moved into the IF crate to support extension discipline,
25//! versioning, and external consumers.
26
27use crate::core::config::CheckPolicy;
28use crate::core::engine::Engine;
29use crate::core::projection_placement::ProjectionPlacement;
30use crate::core::state::{DispatchOutcome, TaskSpec};
31use crate::core::step_naming::StepNaming;
32use crate::store::run::{RunContext, StepEntry};
33use crate::types::{now_unix, CapToken};
34use crate::worker::adapter::SpawnerAdapter;
35use async_trait::async_trait;
36pub mod compiler;
37pub mod loader;
38pub mod store;
39
40use mlua_flow_ir::{AsyncDispatcher, EvalError};
41use serde_json::{Map, Value};
42use std::collections::HashMap;
43use std::sync::Arc;
44
45// The schema types are owned by the IF crate (mlua-swarm-schema); we re-export them here.
46/// The schema-side `OperatorKind` (see `crate::core::ctx::OperatorKind` for the
47/// runtime duplicate consumed by `Engine`). Re-exported under an explicit
48/// alias so callers reading `Blueprint.operators[].kind` /
49/// `Blueprint.default_operator_kind` do not have to reach into
50/// `mlua_swarm_schema` directly.
51pub use mlua_swarm_schema::OperatorKind as SchemaOperatorKind;
52pub use mlua_swarm_schema::{
53    current_schema_version, default_global_agent_kind, resolve_runner, AgentDef, AgentKind,
54    AgentMeta, AgentProfile, AuditDef, AuditMode, Blueprint, BlueprintMetadata, BlueprintOrigin,
55    CompilerHints, CompilerStrategy, MetaDef, OperatorDef, ProjectionPlacementSpec, Runner,
56    RunnerDef, RunnerResolveError, SpawnerHints, WorkerModel, CURRENT_SCHEMA_VERSION,
57};
58
59/// Bridges `mlua_flow_ir::AsyncDispatcher` to the engine's
60/// `start_task` + `dispatch_attempt_with_run_ctx` pair. Holds one
61/// Operator session token and one `spawner`, and spins up a fresh task
62/// per `Step.ref`, using it as the agent name.
63///
64/// Constructed via `with_spawner`; each dispatch goes through
65/// `engine.dispatch_attempt_with_run_ctx(token, tid, spawner, run_ctx)`
66/// so that when the enclosing `RunContext` carries a `replay_store` /
67/// `replay_cursor`, replay-hit skip and Ctx-snapshot append happen
68/// transparently. Nothing is stashed on engine-global state, so
69/// multiple dispatchers can drive different Blueprints against the same
70/// `Engine` in parallel without racing.
71///
72/// Optionally carries a [`RunContext`] (via [`Self::with_run`], issue #13
73/// run_id propagation): when present, every dispatched step's `run_id` is
74/// exposed to the worker through `Ctx.meta.runtime["run_id"]`, and a
75/// [`StepEntry`] is appended to `RunRecord.step_entries` once the step's
76/// outcome is known (dispatch is synchronous end-to-end here, so there is
77/// no need for a separate event/notification mechanism — the entry is
78/// written with its final status in one call).
79///
80/// Also carries the GH #21 Phase 2 named `MetaDef` pool (via
81/// [`Self::with_step_metas`]) — the Step tier's dispatch-time resolver;
82/// see [`Self::dispatch`]'s doc for the full envelope contract.
83///
84/// GH #23: optionally carries the Blueprint's [`StepNaming`] table (via
85/// [`Self::with_step_naming`], built once by
86/// `blueprint::compiler::Compiler::compile` — see that type's doc for the
87/// full addressing-space narrative). When present, [`Self::dispatch`]
88/// snapshots the same `Arc` into `EngineState.step_namings` for every
89/// dispatched task, keyed by its freshly-minted `StepId` — the storage
90/// half of the "construct once, read many" contract; `Engine::step_naming_for`
91/// is the read-back accessor later consumers (GH #23 subtask-2/3) pull
92/// from.
93///
94/// GH #27 (follow-up to #23): optionally also carries the Blueprint's
95/// [`ProjectionPlacement`] resolver (via [`Self::with_projection_placement`],
96/// built once by `Compiler::compile`) — the SAME snapshot-then-read-back
97/// contract as [`StepNaming`] above, this time read back via
98/// `Engine::projection_placement_for`.
99pub struct EngineDispatcher {
100    engine: Engine,
101    op_token: CapToken,
102    spawner: Arc<dyn SpawnerAdapter>,
103    run_ctx: Option<RunContext>,
104    step_metas: HashMap<String, Value>,
105    step_naming: Option<Arc<StepNaming>>,
106    projection_placement: Option<Arc<ProjectionPlacement>>,
107    /// The resolved `check_policy` cascade value
108    /// (`launch request > blueprint > server config`, collapsed exactly once
109    /// in `TaskLaunchService::launch`). Threaded into EVERY spawned step's
110    /// `TaskSpec.check_policy` by [`Self::dispatch`]. `None` (the default via
111    /// [`Self::with_spawner`]) preserves pre-cascade behavior byte-for-byte
112    /// — the engine's submit-time sink then falls back to
113    /// `EngineCfg.check_policy` (the server-wide default).
114    check_policy: Option<CheckPolicy>,
115}
116
117impl EngineDispatcher {
118    /// Build a dispatcher with no run-level tracing (`run_ctx = None`),
119    /// no named `MetaDef`s (`step_metas` empty), and no [`StepNaming`]
120    /// table — the pre-existing behavior. Use [`Self::with_run`] /
121    /// [`Self::with_step_metas`] / [`Self::with_step_naming`] to opt into
122    /// any of them.
123    pub fn with_spawner(
124        engine: Engine,
125        op_token: CapToken,
126        spawner: Arc<dyn SpawnerAdapter>,
127    ) -> Self {
128        Self {
129            engine,
130            op_token,
131            spawner,
132            run_ctx: None,
133            step_metas: HashMap::new(),
134            step_naming: None,
135            projection_placement: None,
136            check_policy: None,
137        }
138    }
139
140    /// Attach a [`RunContext`] (builder style) so every dispatched step is
141    /// traced into `RunRecord.step_entries` and exposes its `run_id` via
142    /// `Ctx.meta.runtime`.
143    pub fn with_run(mut self, run_ctx: RunContext) -> Self {
144        self.run_ctx = Some(run_ctx);
145        self
146    }
147
148    /// GH #21 Phase 2: attach the named `MetaDef` pool (`Blueprint.metas`,
149    /// resolved by `service::task_launch::derive_step_metas` into a
150    /// `name -> ctx` map) that [`Self::dispatch`] resolves `$step_meta.ref`
151    /// envelopes against. Unconditional to call — an empty map (the
152    /// pre-#21-Phase-2 default) makes every `$step_meta.ref` lookup miss
153    /// loudly, same as a Blueprint that never declares `Blueprint.metas`.
154    pub fn with_step_metas(mut self, step_metas: HashMap<String, Value>) -> Self {
155        self.step_metas = step_metas;
156        self
157    }
158
159    /// GH #23: attach the Blueprint's [`StepNaming`] table (built once by
160    /// `blueprint::compiler::Compiler::compile`). `None` (the default via
161    /// [`Self::with_spawner`]) preserves pre-GH-#23 behavior byte-for-byte
162    /// — [`Self::dispatch`] simply skips the `EngineState.step_namings`
163    /// snapshot for every caller that never opts in (e.g. tests that build
164    /// an `EngineDispatcher` directly instead of going through
165    /// `service::task_launch::TaskLaunchService::launch`).
166    pub fn with_step_naming(mut self, step_naming: Arc<StepNaming>) -> Self {
167        self.step_naming = Some(step_naming);
168        self
169    }
170
171    /// GH #27 (follow-up to #23): attach the Blueprint's
172    /// [`ProjectionPlacement`] resolver (built once by
173    /// `blueprint::compiler::Compiler::compile`). `None` (the default via
174    /// [`Self::with_spawner`]) preserves pre-GH-#27 behavior byte-for-byte
175    /// — [`Self::dispatch`] simply skips the
176    /// `EngineState.projection_placements` snapshot for every caller that
177    /// never opts in, mirroring [`Self::with_step_naming`]'s contract.
178    pub fn with_projection_placement(
179        mut self,
180        projection_placement: Arc<ProjectionPlacement>,
181    ) -> Self {
182        self.projection_placement = Some(projection_placement);
183        self
184    }
185
186    /// Attach the resolved `check_policy` cascade value
187    /// (`launch request > blueprint > server config`, collapsed exactly once
188    /// by `TaskLaunchService::launch`). Every step [`Self::dispatch`] spawns
189    /// gets this value stamped onto its `TaskSpec.check_policy`, so a
190    /// Blueprint- or launch-declared policy reaches the engine's submit-time
191    /// sink for ALL steps (not just the first). `None` (the default via
192    /// [`Self::with_spawner`]) is a no-op — the sink then falls back to
193    /// `EngineCfg.check_policy` (server-wide default), byte-for-byte the
194    /// pre-cascade behavior.
195    pub fn with_check_policy(mut self, check_policy: Option<CheckPolicy>) -> Self {
196        self.check_policy = check_policy;
197        self
198    }
199}
200
201/// GH #21 Phase 2: resolve a `$step_meta` envelope embedded in a Step's
202/// evaluated `in` value into `(initial_directive, step_ctx)` — the Step
203/// tier's dispatch-time entry point, called from [`EngineDispatcher::dispatch`]
204/// BEFORE `Engine::start_task` (critical: `start_task` seeds
205/// `EngineState.prompts[(tid, 1)]` from `TaskSpec.initial_directive`, so
206/// stripping the envelope any later would leak `$step_meta` into the
207/// worker prompt AND the WS `Spawn.directive` text).
208///
209/// Contract:
210///
211/// - `input` is not a JSON `Object`, or is an `Object` with no
212///   `"$step_meta"` key → passthrough unchanged, `step_ctx = None`
213///   (pre-#21-Phase-2 Blueprints are byte-identical through this path).
214/// - `input` IS an `Object` with a `"$step_meta"` key: the key is always
215///   stripped (never reaches the returned directive). Everything past
216///   this point is loud — an error names the offending step (`ref_`) and,
217///   for an unresolved `ref`, the defined `step_metas` names:
218///   - the envelope itself must be an `Object` shaped
219///     `{"ref": Option<String>, "inline": Option<Object>}`; any other
220///     shape is a malformed-envelope error;
221///   - `ref` (when present and non-null) is looked up in `step_metas`; an
222///     unknown name is an error (no silent skip). The resolved `MetaDef`
223///     ctx must itself be an `Object` (or the lookup is treated as
224///     malformed);
225///   - `inline` (when present and non-null) must be an `Object`;
226///   - the resolved Step-tier ctx = the `ref`-resolved ctx shallow-merged
227///     with `inline`, **`inline` wins** key collisions.
228/// - Directive rule (applied to the remaining `Object`, after
229///   `"$step_meta"` is stripped): if it still contains an `"$in"` key,
230///   that value becomes the returned directive (other sibling keys are
231///   ignored for the directive — envelope-only input, e.g. one final
232///   `$step_meta` key, therefore never becomes an empty directive by
233///   accident just because more keys existed alongside it). Otherwise
234///   the whole remainder becomes the directive; an empty remainder
235///   becomes `Value::String(String::new())`.
236fn resolve_step_envelope(
237    step_metas: &HashMap<String, Value>,
238    ref_: &str,
239    input: Value,
240) -> Result<(Value, Option<Value>), EvalError> {
241    let mut obj = match input {
242        Value::Object(obj) => obj,
243        other => return Ok((other, None)),
244    };
245    let Some(envelope) = obj.remove("$step_meta") else {
246        return Ok((Value::Object(obj), None));
247    };
248    let envelope = match envelope {
249        Value::Object(map) => map,
250        other => {
251            return Err(EvalError::DispatcherError {
252                ref_: ref_.to_string(),
253                msg: format!(
254                    "malformed $step_meta envelope for step '{ref_}': expected an object, got {other}"
255                ),
256            });
257        }
258    };
259
260    let ref_ctx: Option<Map<String, Value>> = match envelope.get("ref") {
261        None | Some(Value::Null) => None,
262        Some(Value::String(name)) => {
263            let resolved = step_metas.get(name).cloned().ok_or_else(|| {
264                EvalError::DispatcherError {
265                    ref_: ref_.to_string(),
266                    msg: format!(
267                        "$step_meta.ref '{name}' (step '{ref_}') is not a defined Blueprint.metas entry (defined: {:?})",
268                        step_metas.keys().collect::<Vec<_>>()
269                    ),
270                }
271            })?;
272            match resolved {
273                Value::Object(map) => Some(map),
274                other => {
275                    return Err(EvalError::DispatcherError {
276                        ref_: ref_.to_string(),
277                        msg: format!(
278                            "malformed $step_meta: MetaDef '{name}'.ctx must be an object, got {other}"
279                        ),
280                    });
281                }
282            }
283        }
284        Some(other) => {
285            return Err(EvalError::DispatcherError {
286                ref_: ref_.to_string(),
287                msg: format!(
288                    "malformed $step_meta.ref (step '{ref_}'): expected a string, got {other}"
289                ),
290            });
291        }
292    };
293
294    let inline: Option<Map<String, Value>> = match envelope.get("inline") {
295        None | Some(Value::Null) => None,
296        Some(Value::Object(map)) => Some(map.clone()),
297        Some(other) => {
298            return Err(EvalError::DispatcherError {
299                ref_: ref_.to_string(),
300                msg: format!(
301                    "malformed $step_meta.inline (step '{ref_}'): expected an object, got {other}"
302                ),
303            });
304        }
305    };
306
307    let step_ctx = match (ref_ctx, inline) {
308        (None, None) => None,
309        (Some(base), None) => Some(Value::Object(base)),
310        (None, Some(inline)) => Some(Value::Object(inline)),
311        (Some(mut base), Some(inline)) => {
312            for (k, v) in inline {
313                base.insert(k, v);
314            }
315            Some(Value::Object(base))
316        }
317    };
318
319    // Directive rule — only reached once a `$step_meta` envelope was
320    // present in `input`.
321    let initial_directive = if let Some(in_value) = obj.remove("$in") {
322        in_value
323    } else if obj.is_empty() {
324        Value::String(String::new())
325    } else {
326        Value::Object(obj)
327    };
328
329    Ok((initial_directive, step_ctx))
330}
331
332#[async_trait]
333impl AsyncDispatcher for EngineDispatcher {
334    async fn dispatch(&self, ref_: &str, input: Value) -> Result<Value, EvalError> {
335        // issue #18: the evaluated Step.in value passes straight through
336        // as `TaskSpec.initial_directive` — no premature `Value → String`
337        // coercion here. Consumers that need a rendered `String` do so at
338        // their own late boundary: `Engine::start_task` /
339        // `Engine::dispatch_attempt_with_run_ctx` render it into the
340        // `EngineState.prompts` table for the Worker HTTP path
341        // (`/v1/worker/prompt`), and
342        // `operator_ws::session::default_spawn_directive_with_task_directive`
343        // renders it into the WS `Spawn.directive` reminder text.
344        //
345        // GH #21 Phase 2: BEFORE that pass-through, resolve_step_envelope
346        // strips + resolves any `$step_meta` envelope — see its doc for
347        // the full contract. Inputs without one flow through unchanged.
348        let (initial_directive, step_ctx) = resolve_step_envelope(&self.step_metas, ref_, input)?;
349        let tid = self
350            .engine
351            .start_task(
352                &self.op_token,
353                TaskSpec {
354                    agent: ref_.to_string(),
355                    initial_directive,
356                    step_ctx,
357                    // The resolved cascade value (collapsed
358                    // once in `TaskLaunchService::launch`), threaded onto
359                    // every spawned step's spec. `None` falls back to
360                    // `EngineCfg.check_policy` at the submit-time sink.
361                    check_policy: self.check_policy,
362                },
363            )
364            .await
365            .map_err(|e| EvalError::DispatcherError {
366                ref_: ref_.to_string(),
367                msg: format!("start_task: {e}"),
368            })?;
369
370        // GH #23: snapshot the (already-built, Blueprint-wide) StepNaming
371        // table into `EngineState.step_namings` keyed by this dispatch's
372        // freshly-minted `tid` — the storage half of the "construct once
373        // (`Compiler::compile`), read many (`Engine::step_naming_for`)"
374        // contract. `None` (no `with_step_naming` call) is a no-op, same
375        // fail-open convention as the `run_ctx` step_entry append below:
376        // a secondary-persistence failure here must never mask the
377        // primary dispatch outcome.
378        if let Some(step_naming) = self.step_naming.clone() {
379            let tid_for_naming = tid.clone();
380            if let Err(e) = self
381                .engine
382                .with_state("EngineDispatcher::dispatch.step_naming", move |s| {
383                    s.step_namings.insert(tid_for_naming, step_naming);
384                })
385                .await
386            {
387                tracing::warn!(
388                    task_id = %tid,
389                    error = %e,
390                    "EngineDispatcher::dispatch: failed to snapshot StepNaming into EngineState"
391                );
392            }
393        }
394
395        // GH #27 (follow-up to #23): same snapshot pattern as StepNaming
396        // above — stash the (already-built, Blueprint-wide)
397        // ProjectionPlacement resolver into `EngineState.projection_placements`
398        // keyed by this dispatch's `tid`. `None` (no
399        // `with_projection_placement` call) is a no-op, same fail-open
400        // convention as the `step_naming` snapshot: a secondary-persistence
401        // failure here must never mask the primary dispatch outcome.
402        if let Some(projection_placement) = self.projection_placement.clone() {
403            let tid_for_placement = tid.clone();
404            if let Err(e) = self
405                .engine
406                .with_state(
407                    "EngineDispatcher::dispatch.projection_placement",
408                    move |s| {
409                        s.projection_placements
410                            .insert(tid_for_placement, projection_placement);
411                    },
412                )
413                .await
414            {
415                tracing::warn!(
416                    task_id = %tid,
417                    error = %e,
418                    "EngineDispatcher::dispatch: failed to snapshot ProjectionPlacement into EngineState"
419                );
420            }
421        }
422
423        // Route dispatch through the replay-aware sibling. When
424        // `run_ctx` carries a `replay_cursor` populated by the caller
425        // (`POST /v1/runs/:id/resume`), a matching row short-circuits
426        // to `DispatchOutcome::Pass` without touching the spawner; when
427        // `run_ctx.replay_store` is `Some`, every fresh Pass appends
428        // one Ctx-snapshot row so a later resume can replay it. With
429        // `run_ctx = None` this collapses to the same behavior as the
430        // legacy `dispatch_attempt_with(..., None)` call.
431        let outcome = self
432            .engine
433            .dispatch_attempt_with_run_ctx(
434                &self.op_token,
435                &tid,
436                &self.spawner,
437                self.run_ctx.as_ref(),
438            )
439            .await;
440
441        // issue #13 run_id propagation: append one step_entry per dispatched
442        // step (`RunStore.append_step_entry` is append-only — there is no
443        // in-place update — so the entry is written once here, after the
444        // outcome is known, carrying its final status). Secondary
445        // persistence failures are logged and swallowed, matching
446        // `mse-server`'s `finalize_run` convention: they must not mask the
447        // primary dispatch outcome the flow eval already has in hand.
448        if let Some(rc) = &self.run_ctx {
449            let status = match &outcome {
450                Ok(DispatchOutcome::Pass(_)) => "passed",
451                Ok(DispatchOutcome::Blocked(_)) => "blocked",
452                Ok(DispatchOutcome::Suspended(_)) => "suspended",
453                Ok(DispatchOutcome::Cancelled) => "cancelled",
454                Ok(DispatchOutcome::Timeout) => "timeout",
455                Err(_) => "failed",
456            };
457            let entry = StepEntry {
458                step_id: tid.clone(),
459                step_ref: Some(ref_.to_string()),
460                status: Some(status.to_string()),
461                at: now_unix(),
462            };
463            if let Err(e) = rc.run_store.append_step_entry(&rc.run_id, entry).await {
464                tracing::warn!(
465                    run_id = %rc.run_id,
466                    step_id = %tid,
467                    error = %e,
468                    "EngineDispatcher::dispatch: append_step_entry failed"
469                );
470            }
471        }
472
473        match outcome {
474            Ok(DispatchOutcome::Pass(v)) => Ok(v),
475            Ok(DispatchOutcome::Blocked(v)) => Err(EvalError::DispatcherError {
476                ref_: ref_.to_string(),
477                msg: format!("blocked: {v}"),
478            }),
479            Ok(other) => Err(EvalError::DispatcherError {
480                ref_: ref_.to_string(),
481                msg: format!("non-terminal outcome: {:?}", other),
482            }),
483            Err(e) => Err(EvalError::DispatcherError {
484                ref_: ref_.to_string(),
485                msg: format!("dispatch_attempt: {e}"),
486            }),
487        }
488    }
489}
490
491// ──────────────────────────────────────────────────────────────────────────
492// issue #21 Phase 2: `resolve_step_envelope` unit tests + a dispatch-level
493// end-to-end leak-proof test
494// ──────────────────────────────────────────────────────────────────────────
495
496#[cfg(test)]
497mod tests {
498    use super::*;
499    use serde_json::json;
500
501    fn metas(pairs: &[(&str, Value)]) -> HashMap<String, Value> {
502        pairs
503            .iter()
504            .map(|(k, v)| (k.to_string(), v.clone()))
505            .collect()
506    }
507
508    #[test]
509    fn no_envelope_string_input_passes_through_unchanged() {
510        let (directive, step_ctx) =
511            resolve_step_envelope(&HashMap::new(), "scout", json!("plain string")).unwrap();
512        assert_eq!(directive, json!("plain string"));
513        assert_eq!(step_ctx, None);
514    }
515
516    #[test]
517    fn no_envelope_plain_object_input_passes_through_unchanged() {
518        let input = json!({ "foo": "bar" });
519        let (directive, step_ctx) =
520            resolve_step_envelope(&HashMap::new(), "scout", input.clone()).unwrap();
521        assert_eq!(directive, input);
522        assert_eq!(step_ctx, None);
523    }
524
525    #[test]
526    fn envelope_with_only_ref_resolves_that_metadef_ctx() {
527        let step_metas = metas(&[("heavy-scan", json!({ "work_dir": "/x" }))]);
528        let input = json!({ "$step_meta": { "ref": "heavy-scan" }, "$in": "go" });
529        let (directive, step_ctx) = resolve_step_envelope(&step_metas, "scout", input).unwrap();
530        assert_eq!(directive, json!("go"));
531        assert_eq!(step_ctx, Some(json!({ "work_dir": "/x" })));
532    }
533
534    #[test]
535    fn envelope_with_only_inline_uses_inline_verbatim() {
536        let input = json!({
537            "$step_meta": { "inline": { "work_dir": "/inline-only" } },
538            "$in": "go"
539        });
540        let (directive, step_ctx) = resolve_step_envelope(&HashMap::new(), "scout", input).unwrap();
541        assert_eq!(directive, json!("go"));
542        assert_eq!(step_ctx, Some(json!({ "work_dir": "/inline-only" })));
543    }
544
545    #[test]
546    fn inline_wins_over_ref_on_key_collision() {
547        let step_metas = metas(&[(
548            "heavy-scan",
549            json!({ "work_dir": "/ref", "extra": "from-ref" }),
550        )]);
551        let input = json!({
552            "$step_meta": {
553                "ref": "heavy-scan",
554                "inline": { "work_dir": "/inline-wins" }
555            },
556            "$in": "go"
557        });
558        let (_, step_ctx) = resolve_step_envelope(&step_metas, "scout", input).unwrap();
559        assert_eq!(
560            step_ctx,
561            Some(json!({ "work_dir": "/inline-wins", "extra": "from-ref" })),
562            "inline must win the collided key while ref-only keys survive the merge"
563        );
564    }
565
566    #[test]
567    fn dollar_in_rule_extracts_directive_and_ignores_other_sibling_keys() {
568        let input = json!({
569            "$step_meta": { "inline": { "k": "v" } },
570            "$in": "the real directive",
571            "unrelated_sibling": "ignored"
572        });
573        let (directive, step_ctx) = resolve_step_envelope(&HashMap::new(), "scout", input).unwrap();
574        assert_eq!(directive, json!("the real directive"));
575        assert_eq!(step_ctx, Some(json!({ "k": "v" })));
576    }
577
578    #[test]
579    fn no_dollar_in_remainder_becomes_the_directive() {
580        let input = json!({
581            "$step_meta": { "inline": { "k": "v" } },
582            "other_key": "other_value"
583        });
584        let (directive, _) = resolve_step_envelope(&HashMap::new(), "scout", input).unwrap();
585        assert_eq!(directive, json!({ "other_key": "other_value" }));
586    }
587
588    #[test]
589    fn empty_remainder_becomes_empty_string_directive() {
590        let input = json!({ "$step_meta": { "ref": "heavy-scan" } });
591        let step_metas = metas(&[("heavy-scan", json!({ "work_dir": "/x" }))]);
592        let (directive, step_ctx) = resolve_step_envelope(&step_metas, "scout", input).unwrap();
593        assert_eq!(directive, Value::String(String::new()));
594        assert_eq!(step_ctx, Some(json!({ "work_dir": "/x" })));
595    }
596
597    #[test]
598    fn unresolved_ref_is_a_loud_dispatcher_error_naming_ref_and_defined() {
599        let step_metas = metas(&[("known", json!({}))]);
600        let input = json!({ "$step_meta": { "ref": "unknown" }, "$in": "go" });
601        let err = resolve_step_envelope(&step_metas, "scout", input).unwrap_err();
602        match err {
603            EvalError::DispatcherError { ref_, msg } => {
604                assert_eq!(ref_, "scout");
605                assert!(
606                    msg.contains("unknown"),
607                    "message must name the unresolved ref: {msg}"
608                );
609                assert!(
610                    msg.contains("known"),
611                    "message must list defined names: {msg}"
612                );
613            }
614            other => panic!("expected DispatcherError, got {other:?}"),
615        }
616    }
617
618    #[test]
619    fn malformed_step_meta_not_an_object_is_a_loud_error() {
620        let input = json!({ "$step_meta": "not-an-object" });
621        let err = resolve_step_envelope(&HashMap::new(), "scout", input).unwrap_err();
622        assert!(matches!(err, EvalError::DispatcherError { .. }));
623    }
624
625    #[test]
626    fn malformed_ref_non_string_is_a_loud_error() {
627        let input = json!({ "$step_meta": { "ref": 42 } });
628        let err = resolve_step_envelope(&HashMap::new(), "scout", input).unwrap_err();
629        assert!(matches!(err, EvalError::DispatcherError { .. }));
630    }
631
632    #[test]
633    fn malformed_inline_non_object_is_a_loud_error() {
634        let input = json!({ "$step_meta": { "inline": "not-an-object" } });
635        let err = resolve_step_envelope(&HashMap::new(), "scout", input).unwrap_err();
636        assert!(matches!(err, EvalError::DispatcherError { .. }));
637    }
638
639    #[test]
640    fn ref_resolved_metadef_ctx_non_object_is_a_loud_error() {
641        let step_metas = metas(&[("bad", json!("not-an-object"))]);
642        let input = json!({ "$step_meta": { "ref": "bad" } });
643        let err = resolve_step_envelope(&step_metas, "scout", input).unwrap_err();
644        assert!(matches!(err, EvalError::DispatcherError { .. }));
645    }
646
647    /// End-to-end proof (issue #21 Phase 2 Done Criteria #5): a `$step_meta`
648    /// envelope must never reach `EngineState.prompts[(tid, 1)]` — the
649    /// resolve step runs BEFORE `start_task` seeds that table.
650    #[tokio::test]
651    async fn dispatch_step_meta_envelope_never_leaks_into_stored_prompt() {
652        use crate::blueprint::compiler::{RustFnInProcessSpawnerFactory, SpawnerFactory};
653        use crate::core::config::EngineCfg;
654        use crate::types::{Role, StepId};
655        use crate::worker::adapter::WorkerResult;
656        use std::sync::Mutex as StdMutex;
657        use std::time::Duration;
658
659        let captured_tid: Arc<StdMutex<Option<StepId>>> = Arc::new(StdMutex::new(None));
660        let captured_tid_for_fn = captured_tid.clone();
661        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", move |inv| {
662            let captured_tid = captured_tid_for_fn.clone();
663            async move {
664                *captured_tid.lock().unwrap() = Some(inv.task_id.clone());
665                Ok(WorkerResult {
666                    value: json!({ "ok": true }),
667                    ok: true,
668                })
669            }
670        });
671        let def = AgentDef {
672            name: "scout".into(),
673            kind: AgentKind::RustFn,
674            spec: json!({ "fn_id": "echo" }),
675            profile: None,
676            meta: None,
677            runner: None,
678            runner_ref: None,
679            verdict: None,
680        };
681        let spawner = factory.build(&def, None).expect("build");
682
683        let engine = Engine::new(EngineCfg::default());
684        let token = engine
685            .attach("ut-op", Role::Operator, Duration::from_secs(30))
686            .await
687            .expect("attach");
688        let step_metas = metas(&[("heavy-scan", json!({ "work_dir": "/x" }))]);
689        let dispatcher = EngineDispatcher::with_spawner(engine.clone(), token, spawner)
690            .with_step_metas(step_metas);
691
692        let input = json!({
693            "$step_meta": { "ref": "heavy-scan" },
694            "$in": "do the thing"
695        });
696        let out = dispatcher
697            .dispatch("scout", input)
698            .await
699            .expect("dispatch ok");
700        assert_eq!(out, json!({ "ok": true }));
701
702        let tid = captured_tid
703            .lock()
704            .unwrap()
705            .clone()
706            .expect("task_id captured");
707        let stored_prompt = engine
708            .with_state("test.read_prompt", move |s| {
709                s.prompts.get(&(tid, 1)).cloned()
710            })
711            .await
712            .expect("with_state")
713            .expect("prompt recorded for attempt 1");
714        assert_eq!(
715            stored_prompt,
716            json!("do the thing"),
717            "the stored prompt must be the post-envelope directive, with no $step_meta leakage"
718        );
719    }
720}