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}