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