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::{wrap_skip_marker, DispatchOutcome, TaskSpec};
31use crate::core::step_naming::StepNaming;
32use crate::store::run::{LastFailure, 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_bound_agents,
54    resolve_bound_agents_strict, resolve_runner, AgentDef, AgentKind, AgentMeta, AgentProfile,
55    AgentProviderCapability, AgentProviderManifest, AuditDef, AuditMode, BindOutcome, BindReceipt,
56    BindRequest, BindingAttestation, BindingBackend, BindingDigest, BindingDigestParseError,
57    Blueprint, BlueprintMetadata, BlueprintOrigin, BoundAgent, BoundAgentResolveError,
58    CompilerHints, CompilerStrategy, MetaDef, OperatorDef, ProjectionPlacementSpec, Runner,
59    RunnerDef, RunnerResolutionSource, RunnerResolveError, SpawnerHints, WorkerModel,
60    CURRENT_SCHEMA_VERSION,
61};
62
63/// Bridges `mlua_flow_ir::AsyncDispatcher` to the engine's
64/// `start_task` + `dispatch_attempt_with_run_ctx` pair. Holds one
65/// Operator session token and one `spawner`, and spins up a fresh task
66/// per `Step.ref`, using it as the agent name.
67///
68/// Constructed via `with_spawner`; each dispatch goes through
69/// `engine.dispatch_attempt_with_run_ctx(token, tid, spawner, run_ctx)`
70/// so that when the enclosing `RunContext` carries a `replay_store` /
71/// `replay_cursor`, replay-hit skip and Ctx-snapshot append happen
72/// transparently. Nothing is stashed on engine-global state, so
73/// multiple dispatchers can drive different Blueprints against the same
74/// `Engine` in parallel without racing.
75///
76/// Optionally carries a [`RunContext`] (via [`Self::with_run`], issue #13
77/// run_id propagation): when present, every dispatched step's `run_id` is
78/// exposed to the worker through `Ctx.meta.runtime["run_id"]`, and a
79/// [`StepEntry`] is appended to `RunRecord.step_entries` once the step's
80/// outcome is known (dispatch is synchronous end-to-end here, so there is
81/// no need for a separate event/notification mechanism — the entry is
82/// written with its final status in one call).
83///
84/// Also carries the GH #21 Phase 2 named `MetaDef` pool (via
85/// [`Self::with_step_metas`]) — the Step tier's dispatch-time resolver;
86/// see [`Self::dispatch`]'s doc for the full envelope contract.
87///
88/// GH #23: optionally carries the Blueprint's [`StepNaming`] table (via
89/// [`Self::with_step_naming`], built once by
90/// `blueprint::compiler::Compiler::compile` — see that type's doc for the
91/// full addressing-space narrative). When present, [`Self::dispatch`]
92/// snapshots the same `Arc` into `EngineState.step_namings` for every
93/// dispatched task, keyed by its freshly-minted `StepId` — the storage
94/// half of the "construct once, read many" contract; `Engine::step_naming_for`
95/// is the read-back accessor later consumers (GH #23 subtask-2/3) pull
96/// from.
97///
98/// GH #27 (follow-up to #23): optionally also carries the Blueprint's
99/// [`ProjectionPlacement`] resolver (via [`Self::with_projection_placement`],
100/// built once by `Compiler::compile`) — the SAME snapshot-then-read-back
101/// contract as [`StepNaming`] above, this time read back via
102/// `Engine::projection_placement_for`.
103pub struct EngineDispatcher {
104    engine: Engine,
105    op_token: CapToken,
106    spawner: Arc<dyn SpawnerAdapter>,
107    run_ctx: Option<RunContext>,
108    step_metas: HashMap<String, Value>,
109    step_naming: Option<Arc<StepNaming>>,
110    projection_placement: Option<Arc<ProjectionPlacement>>,
111    binding_digests: HashMap<String, BindingDigest>,
112    /// The resolved `check_policy` cascade value
113    /// (`launch request > blueprint > server config`, collapsed exactly once
114    /// in `TaskLaunchService::launch`). Threaded into EVERY spawned step's
115    /// `TaskSpec.check_policy` by [`Self::dispatch`]. `None` (the default via
116    /// [`Self::with_spawner`]) preserves pre-cascade behavior byte-for-byte
117    /// — the engine's submit-time sink then falls back to
118    /// `EngineCfg.check_policy` (the server-wide default).
119    check_policy: Option<CheckPolicy>,
120}
121
122impl EngineDispatcher {
123    /// Build a dispatcher with no run-level tracing (`run_ctx = None`),
124    /// no named `MetaDef`s (`step_metas` empty), and no [`StepNaming`]
125    /// table — the pre-existing behavior. Use [`Self::with_run`] /
126    /// [`Self::with_step_metas`] / [`Self::with_step_naming`] to opt into
127    /// any of them.
128    pub fn with_spawner(
129        engine: Engine,
130        op_token: CapToken,
131        spawner: Arc<dyn SpawnerAdapter>,
132    ) -> Self {
133        Self {
134            engine,
135            op_token,
136            spawner,
137            run_ctx: None,
138            step_metas: HashMap::new(),
139            step_naming: None,
140            projection_placement: None,
141            binding_digests: HashMap::new(),
142            check_policy: None,
143        }
144    }
145
146    /// Attach a [`RunContext`] (builder style) so every dispatched step is
147    /// traced into `RunRecord.step_entries` and exposes its `run_id` via
148    /// `Ctx.meta.runtime`.
149    pub fn with_run(mut self, run_ctx: RunContext) -> Self {
150        self.run_ctx = Some(run_ctx);
151        self
152    }
153
154    /// GH #21 Phase 2: attach the named `MetaDef` pool (`Blueprint.metas`,
155    /// resolved by `service::task_launch::derive_step_metas` into a
156    /// `name -> ctx` map) that [`Self::dispatch`] resolves `$step_meta.ref`
157    /// envelopes against. Unconditional to call — an empty map (the
158    /// pre-#21-Phase-2 default) makes every `$step_meta.ref` lookup miss
159    /// loudly, same as a Blueprint that never declares `Blueprint.metas`.
160    pub fn with_step_metas(mut self, step_metas: HashMap<String, Value>) -> Self {
161        self.step_metas = step_metas;
162        self
163    }
164
165    /// Attach the immutable `AgentDef.name -> BoundAgent.binding_digest`
166    /// table used to correlate persisted step traces with launch bindings.
167    pub fn with_binding_digests(mut self, binding_digests: HashMap<String, BindingDigest>) -> Self {
168        self.binding_digests = binding_digests;
169        self
170    }
171
172    /// GH #23: attach the Blueprint's [`StepNaming`] table (built once by
173    /// `blueprint::compiler::Compiler::compile`). `None` (the default via
174    /// [`Self::with_spawner`]) preserves pre-GH-#23 behavior byte-for-byte
175    /// — [`Self::dispatch`] simply skips the `EngineState.step_namings`
176    /// snapshot for every caller that never opts in (e.g. tests that build
177    /// an `EngineDispatcher` directly instead of going through
178    /// `service::task_launch::TaskLaunchService::launch`).
179    pub fn with_step_naming(mut self, step_naming: Arc<StepNaming>) -> Self {
180        self.step_naming = Some(step_naming);
181        self
182    }
183
184    /// GH #27 (follow-up to #23): attach the Blueprint's
185    /// [`ProjectionPlacement`] resolver (built once by
186    /// `blueprint::compiler::Compiler::compile`). `None` (the default via
187    /// [`Self::with_spawner`]) preserves pre-GH-#27 behavior byte-for-byte
188    /// — [`Self::dispatch`] simply skips the
189    /// `EngineState.projection_placements` snapshot for every caller that
190    /// never opts in, mirroring [`Self::with_step_naming`]'s contract.
191    pub fn with_projection_placement(
192        mut self,
193        projection_placement: Arc<ProjectionPlacement>,
194    ) -> Self {
195        self.projection_placement = Some(projection_placement);
196        self
197    }
198
199    /// Attach the resolved `check_policy` cascade value
200    /// (`launch request > blueprint > server config`, collapsed exactly once
201    /// by `TaskLaunchService::launch`). Every step [`Self::dispatch`] spawns
202    /// gets this value stamped onto its `TaskSpec.check_policy`, so a
203    /// Blueprint- or launch-declared policy reaches the engine's submit-time
204    /// sink for ALL steps (not just the first). `None` (the default via
205    /// [`Self::with_spawner`]) is a no-op — the sink then falls back to
206    /// `EngineCfg.check_policy` (server-wide default), byte-for-byte the
207    /// pre-cascade behavior.
208    pub fn with_check_policy(mut self, check_policy: Option<CheckPolicy>) -> Self {
209        self.check_policy = check_policy;
210        self
211    }
212}
213
214/// GH #21 Phase 2: resolve a `$step_meta` envelope embedded in a Step's
215/// evaluated `in` value into `(initial_directive, step_ctx)` — the Step
216/// tier's dispatch-time entry point, called from [`EngineDispatcher::dispatch`]
217/// BEFORE `Engine::start_task` (critical: `start_task` seeds
218/// `EngineState.prompts[(tid, 1)]` from `TaskSpec.initial_directive`, so
219/// stripping the envelope any later would leak `$step_meta` into the
220/// worker prompt AND the WS `Spawn.directive` text).
221///
222/// Contract:
223///
224/// - `input` is not a JSON `Object`, or is an `Object` with no
225///   `"$step_meta"` key → passthrough unchanged, `step_ctx = None`
226///   (pre-#21-Phase-2 Blueprints are byte-identical through this path).
227/// - `input` IS an `Object` with a `"$step_meta"` key: the key is always
228///   stripped (never reaches the returned directive). Everything past
229///   this point is loud — an error names the offending step (`ref_`) and,
230///   for an unresolved `ref`, the defined `step_metas` names:
231///   - the envelope itself must be an `Object` shaped
232///     `{"ref": Option<String>, "inline": Option<Object>}`; any other
233///     shape is a malformed-envelope error;
234///   - `ref` (when present and non-null) is looked up in `step_metas`; an
235///     unknown name is an error (no silent skip). The resolved `MetaDef`
236///     ctx must itself be an `Object` (or the lookup is treated as
237///     malformed);
238///   - `inline` (when present and non-null) must be an `Object`;
239///   - the resolved Step-tier ctx = the `ref`-resolved ctx shallow-merged
240///     with `inline`, **`inline` wins** key collisions.
241/// - Directive rule (applied to the remaining `Object`, after
242///   `"$step_meta"` is stripped): if it still contains an `"$in"` key,
243///   that value becomes the returned directive (other sibling keys are
244///   ignored for the directive — envelope-only input, e.g. one final
245///   `$step_meta` key, therefore never becomes an empty directive by
246///   accident just because more keys existed alongside it). Otherwise
247///   the whole remainder becomes the directive; an empty remainder
248///   becomes `Value::String(String::new())`.
249fn resolve_step_envelope(
250    step_metas: &HashMap<String, Value>,
251    ref_: &str,
252    input: Value,
253) -> Result<(Value, Option<Value>), EvalError> {
254    let mut obj = match input {
255        Value::Object(obj) => obj,
256        other => return Ok((other, None)),
257    };
258    let Some(envelope) = obj.remove("$step_meta") else {
259        return Ok((Value::Object(obj), None));
260    };
261    let envelope = match envelope {
262        Value::Object(map) => map,
263        other => {
264            return Err(EvalError::DispatcherError {
265                ref_: ref_.to_string(),
266                msg: format!(
267                    "malformed $step_meta envelope for step '{ref_}': expected an object, got {other}"
268                ),
269            });
270        }
271    };
272
273    let ref_ctx: Option<Map<String, Value>> = match envelope.get("ref") {
274        None | Some(Value::Null) => None,
275        Some(Value::String(name)) => {
276            let resolved = step_metas.get(name).cloned().ok_or_else(|| {
277                EvalError::DispatcherError {
278                    ref_: ref_.to_string(),
279                    msg: format!(
280                        "$step_meta.ref '{name}' (step '{ref_}') is not a defined Blueprint.metas entry (defined: {:?})",
281                        step_metas.keys().collect::<Vec<_>>()
282                    ),
283                }
284            })?;
285            match resolved {
286                Value::Object(map) => Some(map),
287                other => {
288                    return Err(EvalError::DispatcherError {
289                        ref_: ref_.to_string(),
290                        msg: format!(
291                            "malformed $step_meta: MetaDef '{name}'.ctx must be an object, got {other}"
292                        ),
293                    });
294                }
295            }
296        }
297        Some(other) => {
298            return Err(EvalError::DispatcherError {
299                ref_: ref_.to_string(),
300                msg: format!(
301                    "malformed $step_meta.ref (step '{ref_}'): expected a string, got {other}"
302                ),
303            });
304        }
305    };
306
307    let inline: Option<Map<String, Value>> = match envelope.get("inline") {
308        None | Some(Value::Null) => None,
309        Some(Value::Object(map)) => Some(map.clone()),
310        Some(other) => {
311            return Err(EvalError::DispatcherError {
312                ref_: ref_.to_string(),
313                msg: format!(
314                    "malformed $step_meta.inline (step '{ref_}'): expected an object, got {other}"
315                ),
316            });
317        }
318    };
319
320    let step_ctx = match (ref_ctx, inline) {
321        (None, None) => None,
322        (Some(base), None) => Some(Value::Object(base)),
323        (None, Some(inline)) => Some(Value::Object(inline)),
324        (Some(mut base), Some(inline)) => {
325            for (k, v) in inline {
326                base.insert(k, v);
327            }
328            Some(Value::Object(base))
329        }
330    };
331
332    // Directive rule — only reached once a `$step_meta` envelope was
333    // present in `input`.
334    let initial_directive = if let Some(in_value) = obj.remove("$in") {
335        in_value
336    } else if obj.is_empty() {
337        Value::String(String::new())
338    } else {
339        Value::Object(obj)
340    };
341
342    Ok((initial_directive, step_ctx))
343}
344
345#[async_trait]
346impl AsyncDispatcher for EngineDispatcher {
347    async fn dispatch(&self, ref_: &str, input: Value) -> Result<Value, EvalError> {
348        // issue #18: the evaluated Step.in value passes straight through
349        // as `TaskSpec.initial_directive` — no premature `Value → String`
350        // coercion here. Consumers that need a rendered `String` do so at
351        // their own late boundary: `Engine::start_task` /
352        // `Engine::dispatch_attempt_with_run_ctx` render it into the
353        // `EngineState.prompts` table for the Worker HTTP path
354        // (`/v1/worker/prompt`), and
355        // `operator_ws::session::default_spawn_directive_with_task_directive`
356        // renders it into the WS `Spawn.directive` reminder text.
357        //
358        // GH #21 Phase 2: BEFORE that pass-through, resolve_step_envelope
359        // strips + resolves any `$step_meta` envelope — see its doc for
360        // the full contract. Inputs without one flow through unchanged.
361        let (initial_directive, step_ctx) = resolve_step_envelope(&self.step_metas, ref_, input)?;
362        let tid = self
363            .engine
364            .start_task(
365                &self.op_token,
366                TaskSpec {
367                    agent: ref_.to_string(),
368                    initial_directive,
369                    step_ctx,
370                    // The resolved cascade value (collapsed
371                    // once in `TaskLaunchService::launch`), threaded onto
372                    // every spawned step's spec. `None` falls back to
373                    // `EngineCfg.check_policy` at the submit-time sink.
374                    check_policy: self.check_policy,
375                },
376            )
377            .await
378            .map_err(|e| EvalError::DispatcherError {
379                ref_: ref_.to_string(),
380                msg: format!("start_task: {e}"),
381            })?;
382
383        // GH #23: snapshot the (already-built, Blueprint-wide) StepNaming
384        // table into `EngineState.step_namings` keyed by this dispatch's
385        // freshly-minted `tid` — the storage half of the "construct once
386        // (`Compiler::compile`), read many (`Engine::step_naming_for`)"
387        // contract. `None` (no `with_step_naming` call) is a no-op, same
388        // fail-open convention as the `run_ctx` step_entry append below:
389        // a secondary-persistence failure here must never mask the
390        // primary dispatch outcome.
391        if let Some(step_naming) = self.step_naming.clone() {
392            let tid_for_naming = tid.clone();
393            if let Err(e) = self
394                .engine
395                .with_state("EngineDispatcher::dispatch.step_naming", move |s| {
396                    s.step_namings.insert(tid_for_naming, step_naming);
397                })
398                .await
399            {
400                tracing::warn!(
401                    task_id = %tid,
402                    error = %e,
403                    "EngineDispatcher::dispatch: failed to snapshot StepNaming into EngineState"
404                );
405            }
406        }
407
408        // GH #27 (follow-up to #23): same snapshot pattern as StepNaming
409        // above — stash the (already-built, Blueprint-wide)
410        // ProjectionPlacement resolver into `EngineState.projection_placements`
411        // keyed by this dispatch's `tid`. `None` (no
412        // `with_projection_placement` call) is a no-op, same fail-open
413        // convention as the `step_naming` snapshot: a secondary-persistence
414        // failure here must never mask the primary dispatch outcome.
415        if let Some(projection_placement) = self.projection_placement.clone() {
416            let tid_for_placement = tid.clone();
417            if let Err(e) = self
418                .engine
419                .with_state(
420                    "EngineDispatcher::dispatch.projection_placement",
421                    move |s| {
422                        s.projection_placements
423                            .insert(tid_for_placement, projection_placement);
424                    },
425                )
426                .await
427            {
428                tracing::warn!(
429                    task_id = %tid,
430                    error = %e,
431                    "EngineDispatcher::dispatch: failed to snapshot ProjectionPlacement into EngineState"
432                );
433            }
434        }
435
436        // Route dispatch through the replay-aware sibling. When
437        // `run_ctx` carries a `replay_cursor` populated by the caller
438        // (`POST /v1/runs/:id/resume`), a matching row short-circuits
439        // to `DispatchOutcome::Pass` without touching the spawner; when
440        // `run_ctx.replay_store` is `Some`, every fresh Pass appends
441        // one Ctx-snapshot row so a later resume can replay it. With
442        // `run_ctx = None` this collapses to the same behavior as the
443        // legacy `dispatch_attempt_with(..., None)` call.
444        let outcome = self
445            .engine
446            .dispatch_attempt_with_run_ctx(
447                &self.op_token,
448                &tid,
449                &self.spawner,
450                self.run_ctx.as_ref(),
451            )
452            .await;
453
454        // issue #13 run_id propagation: append one step_entry per dispatched
455        // step (`RunStore.append_step_entry` is append-only — there is no
456        // in-place update — so the entry is written once here, after the
457        // outcome is known, carrying its final status). Secondary
458        // persistence failures are logged and swallowed, matching
459        // `mse-server`'s `finalize_run` convention: they must not mask the
460        // primary dispatch outcome the flow eval already has in hand.
461        if let Some(rc) = &self.run_ctx {
462            let status = match &outcome {
463                Ok(DispatchOutcome::Pass(_)) => "passed",
464                Ok(DispatchOutcome::Blocked(_)) => "blocked",
465                // GH #76 Skip tier: Skip tier StepEntry status. Distinct from
466                // "passed" so post-run inspection of `RunRecord.step_entries`
467                // can distinguish flow-continuation-with-write from
468                // flow-continuation-without-write.
469                Ok(DispatchOutcome::Skip(_)) => "skipped",
470                Ok(DispatchOutcome::Suspended(_)) => "suspended",
471                Ok(DispatchOutcome::Cancelled) => "cancelled",
472                Ok(DispatchOutcome::Timeout) => "timeout",
473                Err(_) => "failed",
474            };
475            let entry = StepEntry {
476                step_id: tid.clone(),
477                step_ref: Some(ref_.to_string()),
478                status: Some(status.to_string()),
479                binding_digest: self.binding_digests.get(ref_).cloned(),
480                at: now_unix(),
481            };
482            if let Err(e) = rc.run_store.append_step_entry(&rc.run_id, entry).await {
483                tracing::warn!(
484                    run_id = %rc.run_id,
485                    step_id = %tid,
486                    error = %e,
487                    "EngineDispatcher::dispatch: append_step_entry failed"
488                );
489            }
490        }
491
492        match outcome {
493            Ok(DispatchOutcome::Pass(v)) => Ok(v),
494            // GH #76 Skip tier: Skip tier is flow-continuation, not error. Map
495            // to `Ok(wrap_skip_marker(v))` — the sentinel Value the
496            // downstream binding-write path recognizes via
497            // [`crate::core::state::is_skip_marker`] to short-circuit the
498            // `$.<step_id>` write (short-circuit itself lands in a
499            // separate follow-up; the sentinel is the wire that carries
500            // the signal across the flow-ir boundary). MUST precede the
501            // wildcard `Ok(other) =>` arm below or Skip would be routed
502            // to `EvalError::DispatcherError` (the non-terminal fallback)
503            // and abort the flow — the exact failure mode this tier is
504            // meant to prevent.
505            Ok(DispatchOutcome::Skip(v)) => Ok(wrap_skip_marker(v)),
506            Ok(DispatchOutcome::Blocked(v)) => {
507                // GH #76 error surface: single-slot breadcrumb the surrounding
508                // `TaskLaunchService::launch` `map_err` closure reads to
509                // populate `TaskLaunchError::FlowEval { failed_step,
510                // verdict_value, .. }`. Written last-write-wins BEFORE the
511                // `EvalError::DispatcherError` return so flow-ir sees the
512                // exact same error the pre-error surface world raised — the
513                // breadcrumb is side-channel observability, never
514                // load-bearing on the abort itself. `run_ctx = None`
515                // (dispatchers built without `with_run`) is a no-op:
516                // there is nowhere to write, and every consumer already
517                // treats `partial_ctx: None` / `failed_step: None` as
518                // "not available".
519                if let Some(rc) = &self.run_ctx {
520                    rc.set_last_failure(LastFailure {
521                        step_id: tid.clone(),
522                        step_ref: Some(ref_.to_string()),
523                        verdict_value: v.clone(),
524                    });
525                }
526                Err(EvalError::DispatcherError {
527                    ref_: ref_.to_string(),
528                    msg: format!("blocked: {v}"),
529                })
530            }
531            Ok(other) => Err(EvalError::DispatcherError {
532                ref_: ref_.to_string(),
533                msg: format!("non-terminal outcome: {:?}", other),
534            }),
535            Err(e) => Err(EvalError::DispatcherError {
536                ref_: ref_.to_string(),
537                msg: format!("dispatch_attempt: {e}"),
538            }),
539        }
540    }
541}
542
543// ──────────────────────────────────────────────────────────────────────────
544// issue #21 Phase 2: `resolve_step_envelope` unit tests + a dispatch-level
545// end-to-end leak-proof test
546// ──────────────────────────────────────────────────────────────────────────
547
548#[cfg(test)]
549mod tests {
550    use super::*;
551    use serde_json::json;
552
553    fn metas(pairs: &[(&str, Value)]) -> HashMap<String, Value> {
554        pairs
555            .iter()
556            .map(|(k, v)| (k.to_string(), v.clone()))
557            .collect()
558    }
559
560    #[test]
561    fn no_envelope_string_input_passes_through_unchanged() {
562        let (directive, step_ctx) =
563            resolve_step_envelope(&HashMap::new(), "scout", json!("plain string")).unwrap();
564        assert_eq!(directive, json!("plain string"));
565        assert_eq!(step_ctx, None);
566    }
567
568    #[test]
569    fn no_envelope_plain_object_input_passes_through_unchanged() {
570        let input = json!({ "foo": "bar" });
571        let (directive, step_ctx) =
572            resolve_step_envelope(&HashMap::new(), "scout", input.clone()).unwrap();
573        assert_eq!(directive, input);
574        assert_eq!(step_ctx, None);
575    }
576
577    #[test]
578    fn envelope_with_only_ref_resolves_that_metadef_ctx() {
579        let step_metas = metas(&[("heavy-scan", json!({ "work_dir": "/x" }))]);
580        let input = json!({ "$step_meta": { "ref": "heavy-scan" }, "$in": "go" });
581        let (directive, step_ctx) = resolve_step_envelope(&step_metas, "scout", input).unwrap();
582        assert_eq!(directive, json!("go"));
583        assert_eq!(step_ctx, Some(json!({ "work_dir": "/x" })));
584    }
585
586    #[test]
587    fn envelope_with_only_inline_uses_inline_verbatim() {
588        let input = json!({
589            "$step_meta": { "inline": { "work_dir": "/inline-only" } },
590            "$in": "go"
591        });
592        let (directive, step_ctx) = resolve_step_envelope(&HashMap::new(), "scout", input).unwrap();
593        assert_eq!(directive, json!("go"));
594        assert_eq!(step_ctx, Some(json!({ "work_dir": "/inline-only" })));
595    }
596
597    #[test]
598    fn inline_wins_over_ref_on_key_collision() {
599        let step_metas = metas(&[(
600            "heavy-scan",
601            json!({ "work_dir": "/ref", "extra": "from-ref" }),
602        )]);
603        let input = json!({
604            "$step_meta": {
605                "ref": "heavy-scan",
606                "inline": { "work_dir": "/inline-wins" }
607            },
608            "$in": "go"
609        });
610        let (_, step_ctx) = resolve_step_envelope(&step_metas, "scout", input).unwrap();
611        assert_eq!(
612            step_ctx,
613            Some(json!({ "work_dir": "/inline-wins", "extra": "from-ref" })),
614            "inline must win the collided key while ref-only keys survive the merge"
615        );
616    }
617
618    #[test]
619    fn dollar_in_rule_extracts_directive_and_ignores_other_sibling_keys() {
620        let input = json!({
621            "$step_meta": { "inline": { "k": "v" } },
622            "$in": "the real directive",
623            "unrelated_sibling": "ignored"
624        });
625        let (directive, step_ctx) = resolve_step_envelope(&HashMap::new(), "scout", input).unwrap();
626        assert_eq!(directive, json!("the real directive"));
627        assert_eq!(step_ctx, Some(json!({ "k": "v" })));
628    }
629
630    #[test]
631    fn no_dollar_in_remainder_becomes_the_directive() {
632        let input = json!({
633            "$step_meta": { "inline": { "k": "v" } },
634            "other_key": "other_value"
635        });
636        let (directive, _) = resolve_step_envelope(&HashMap::new(), "scout", input).unwrap();
637        assert_eq!(directive, json!({ "other_key": "other_value" }));
638    }
639
640    #[test]
641    fn empty_remainder_becomes_empty_string_directive() {
642        let input = json!({ "$step_meta": { "ref": "heavy-scan" } });
643        let step_metas = metas(&[("heavy-scan", json!({ "work_dir": "/x" }))]);
644        let (directive, step_ctx) = resolve_step_envelope(&step_metas, "scout", input).unwrap();
645        assert_eq!(directive, Value::String(String::new()));
646        assert_eq!(step_ctx, Some(json!({ "work_dir": "/x" })));
647    }
648
649    #[test]
650    fn unresolved_ref_is_a_loud_dispatcher_error_naming_ref_and_defined() {
651        let step_metas = metas(&[("known", json!({}))]);
652        let input = json!({ "$step_meta": { "ref": "unknown" }, "$in": "go" });
653        let err = resolve_step_envelope(&step_metas, "scout", input).unwrap_err();
654        match err {
655            EvalError::DispatcherError { ref_, msg } => {
656                assert_eq!(ref_, "scout");
657                assert!(
658                    msg.contains("unknown"),
659                    "message must name the unresolved ref: {msg}"
660                );
661                assert!(
662                    msg.contains("known"),
663                    "message must list defined names: {msg}"
664                );
665            }
666            other => panic!("expected DispatcherError, got {other:?}"),
667        }
668    }
669
670    #[test]
671    fn malformed_step_meta_not_an_object_is_a_loud_error() {
672        let input = json!({ "$step_meta": "not-an-object" });
673        let err = resolve_step_envelope(&HashMap::new(), "scout", input).unwrap_err();
674        assert!(matches!(err, EvalError::DispatcherError { .. }));
675    }
676
677    #[test]
678    fn malformed_ref_non_string_is_a_loud_error() {
679        let input = json!({ "$step_meta": { "ref": 42 } });
680        let err = resolve_step_envelope(&HashMap::new(), "scout", input).unwrap_err();
681        assert!(matches!(err, EvalError::DispatcherError { .. }));
682    }
683
684    #[test]
685    fn malformed_inline_non_object_is_a_loud_error() {
686        let input = json!({ "$step_meta": { "inline": "not-an-object" } });
687        let err = resolve_step_envelope(&HashMap::new(), "scout", input).unwrap_err();
688        assert!(matches!(err, EvalError::DispatcherError { .. }));
689    }
690
691    #[test]
692    fn ref_resolved_metadef_ctx_non_object_is_a_loud_error() {
693        let step_metas = metas(&[("bad", json!("not-an-object"))]);
694        let input = json!({ "$step_meta": { "ref": "bad" } });
695        let err = resolve_step_envelope(&step_metas, "scout", input).unwrap_err();
696        assert!(matches!(err, EvalError::DispatcherError { .. }));
697    }
698
699    /// End-to-end proof (issue #21 Phase 2 Done Criteria #5): a `$step_meta`
700    /// envelope must never reach `EngineState.prompts[(tid, 1)]` — the
701    /// resolve step runs BEFORE `start_task` seeds that table.
702    #[tokio::test]
703    async fn dispatch_step_meta_envelope_never_leaks_into_stored_prompt() {
704        use crate::blueprint::compiler::{RustFnInProcessSpawnerFactory, SpawnerFactory};
705        use crate::core::config::EngineCfg;
706        use crate::types::{Role, StepId};
707        use crate::worker::adapter::WorkerResult;
708        use std::sync::Mutex as StdMutex;
709        use std::time::Duration;
710
711        let captured_tid: Arc<StdMutex<Option<StepId>>> = Arc::new(StdMutex::new(None));
712        let captured_tid_for_fn = captured_tid.clone();
713        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", move |inv| {
714            let captured_tid = captured_tid_for_fn.clone();
715            async move {
716                *captured_tid.lock().unwrap() = Some(inv.task_id.clone());
717                Ok(WorkerResult {
718                    value: json!({ "ok": true }),
719                    ok: true,
720                })
721            }
722        });
723        let def = AgentDef {
724            name: "scout".into(),
725            kind: AgentKind::RustFn,
726            spec: json!({ "fn_id": "echo" }),
727            profile: None,
728            meta: None,
729            runner: None,
730            runner_ref: None,
731            verdict: None,
732        };
733        let spawner = factory.build(&def, None).expect("build");
734
735        let engine = Engine::new(EngineCfg::default());
736        let token = engine
737            .attach("ut-op", Role::Operator, Duration::from_secs(30))
738            .await
739            .expect("attach");
740        let step_metas = metas(&[("heavy-scan", json!({ "work_dir": "/x" }))]);
741        let dispatcher = EngineDispatcher::with_spawner(engine.clone(), token, spawner)
742            .with_step_metas(step_metas);
743
744        let input = json!({
745            "$step_meta": { "ref": "heavy-scan" },
746            "$in": "do the thing"
747        });
748        let out = dispatcher
749            .dispatch("scout", input)
750            .await
751            .expect("dispatch ok");
752        assert_eq!(out, json!({ "ok": true }));
753
754        let tid = captured_tid
755            .lock()
756            .unwrap()
757            .clone()
758            .expect("task_id captured");
759        let stored_prompt = engine
760            .with_state("test.read_prompt", move |s| {
761                s.prompts.get(&(tid, 1)).cloned()
762            })
763            .await
764            .expect("with_state")
765            .expect("prompt recorded for attempt 1");
766        assert_eq!(
767            stored_prompt,
768            json!("do the thing"),
769            "the stored prompt must be the post-envelope directive, with no $step_meta leakage"
770        );
771    }
772
773    /// GH #76 error surface: the dispatcher's Blocked arm writes the
774    /// `RunContext.last_failure` breadcrumb (step_id + step_ref +
775    /// verdict_value) BEFORE returning `EvalError::DispatcherError`. This
776    /// test drives a `WorkerResult { ok: false }` through the dispatcher
777    /// and asserts every breadcrumb field, including that step_ref matches
778    /// the dispatched Blueprint ref and verdict_value carries the full
779    /// value the worker returned (not a stringified summary).
780    #[tokio::test]
781    async fn dispatcher_blocked_records_last_failure_breadcrumb() {
782        use crate::blueprint::compiler::{RustFnInProcessSpawnerFactory, SpawnerFactory};
783        use crate::core::config::EngineCfg;
784        use crate::store::run::{InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore};
785        use crate::types::{Role, RunId, TaskId};
786        use crate::worker::adapter::WorkerResult;
787        use std::time::Duration;
788
789        let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |_inv| async move {
790            Ok(WorkerResult {
791                value: json!({ "verdict": "BLOCKED", "reason": "not-applicable" }),
792                ok: false,
793            })
794        });
795        let def = AgentDef {
796            name: "gate".into(),
797            kind: AgentKind::RustFn,
798            spec: json!({ "fn_id": "echo" }),
799            profile: None,
800            meta: None,
801            runner: None,
802            runner_ref: None,
803            verdict: None,
804        };
805        let spawner = factory.build(&def, None).expect("build");
806
807        let engine = Engine::new(EngineCfg::default());
808        let token = engine
809            .attach("ut-op", Role::Operator, Duration::from_secs(30))
810            .await
811            .expect("attach");
812
813        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
814        let run_id = RunId::new();
815        run_store
816            .create(RunRecord {
817                id: run_id.clone(),
818                task_id: TaskId::new(),
819                status: RunStatus::Running,
820                step_entries: Vec::new(),
821                degradations: Vec::new(),
822                operator_sid: None,
823                result_ref: None,
824                input_json: Some("{}".to_string()),
825                created_at: 0,
826                updated_at: 0,
827            })
828            .await
829            .expect("seed RunRecord");
830
831        let run_ctx = RunContext::new(run_id, run_store);
832        let dispatcher =
833            EngineDispatcher::with_spawner(engine, token, spawner).with_run(run_ctx.clone());
834
835        let err = dispatcher
836            .dispatch("gate", json!("go"))
837            .await
838            .expect_err("expected DispatcherError for Blocked outcome");
839        // The public `EvalError` surface is unchanged — same
840        // `DispatcherError` variant with the same `ref_` + `msg` shape.
841        assert!(
842            err.to_string().contains("blocked"),
843            "expected EvalError to mention blocked, got: {err}"
844        );
845
846        // Breadcrumb is populated by the same match arm that raised the
847        // error — reading it via the shared `Arc<Mutex<Option<_>>>` must
848        // succeed.
849        let breadcrumb = run_ctx
850            .last_failure
851            .lock()
852            .expect("last_failure mutex not poisoned")
853            .clone()
854            .expect("Blocked arm must have written LastFailure");
855        assert_eq!(
856            breadcrumb.step_ref,
857            Some("gate".to_string()),
858            "step_ref must be the Blueprint ref this dispatch was routed to"
859        );
860        assert_eq!(
861            breadcrumb.verdict_value,
862            json!({ "verdict": "BLOCKED", "reason": "not-applicable" }),
863            "verdict_value must be the exact value the worker returned"
864        );
865        // step_id is the freshly minted dispatch-time tid — its exact
866        // value is opaque, but it must be non-empty (StepId::to_string()
867        // never yields an empty string for a valid mint).
868        assert!(!breadcrumb.step_id.to_string().is_empty());
869    }
870
871    /// GH #76 error surface: `RunContext::snapshot_partial_ctx` reads the persisted
872    /// step_entry log and reconstructs a JSON `{ "steps": { <step_id>:
873    /// { step_ref, status, at, .. } } }` shape — metadata-level, not
874    /// value-level. Regression test for the reconstructor itself
875    /// (independent of the map_err closure).
876    #[tokio::test]
877    async fn run_context_snapshot_partial_ctx_reconstructs_step_entry_log() {
878        use crate::store::run::{
879            InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore, StepEntry,
880        };
881        use crate::types::{now_unix, RunId, StepId, TaskId};
882
883        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
884        let run_id = RunId::new();
885        run_store
886            .create(RunRecord {
887                id: run_id.clone(),
888                task_id: TaskId::new(),
889                status: RunStatus::Running,
890                step_entries: Vec::new(),
891                degradations: Vec::new(),
892                operator_sid: None,
893                result_ref: None,
894                input_json: Some("{}".to_string()),
895                created_at: 0,
896                updated_at: 0,
897            })
898            .await
899            .expect("seed RunRecord");
900        let sid1 = StepId::new();
901        let sid2 = StepId::new();
902        run_store
903            .append_step_entry(
904                &run_id,
905                StepEntry {
906                    step_id: sid1.clone(),
907                    step_ref: Some("stage-1".to_string()),
908                    status: Some("passed".to_string()),
909                    binding_digest: None,
910                    at: now_unix(),
911                },
912            )
913            .await
914            .expect("append 1");
915        run_store
916            .append_step_entry(
917                &run_id,
918                StepEntry {
919                    step_id: sid2.clone(),
920                    step_ref: Some("stage-2".to_string()),
921                    status: Some("blocked".to_string()),
922                    binding_digest: None,
923                    at: now_unix(),
924                },
925            )
926            .await
927            .expect("append 2");
928
929        let run_ctx = RunContext::new(run_id, run_store);
930        let snap = run_ctx.snapshot_partial_ctx().await;
931        let steps = snap
932            .get("steps")
933            .and_then(|v| v.as_object())
934            .expect("steps object");
935        assert_eq!(steps.len(), 2);
936        assert_eq!(steps[&sid1.to_string()]["step_ref"], json!("stage-1"));
937        assert_eq!(steps[&sid1.to_string()]["status"], json!("passed"));
938        assert_eq!(steps[&sid2.to_string()]["step_ref"], json!("stage-2"));
939        assert_eq!(steps[&sid2.to_string()]["status"], json!("blocked"));
940    }
941}