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, SubprocessDef,
60 SubprocessOutput, SubprocessOverrides, WorkerModel, 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 // Trace rail: when the RunContext carries a TraceHandle, register
437 // it with the engine for the duration of this step (the
438 // pervasive-insertion port middlewares/workers read via
439 // `Engine::trace_handle`) and mark dispatch start on the stream.
440 // The dispatcher also measures wall-clock timing here — the one
441 // worker-kind-independent place both endpoints of the dispatch
442 // are visible.
443 let trace = self.run_ctx.as_ref().and_then(|rc| rc.trace.clone());
444 let started_at_ms = crate::store::trace::now_unix_ms();
445 let started = std::time::Instant::now();
446 if let Some(handle) = &trace {
447 self.engine
448 .set_trace_handle(&tid, Some(handle.clone()))
449 .await;
450 handle
451 .append(
452 crate::store::trace::kind::STEP_DISPATCHED,
453 Some(ref_),
454 None,
455 serde_json::json!({ "step_id": tid.to_string() }),
456 )
457 .await;
458 }
459
460 // Route dispatch through the replay-aware sibling. When
461 // `run_ctx` carries a `replay_cursor` populated by the caller
462 // (`POST /v1/runs/:id/resume`), a matching row short-circuits
463 // to `DispatchOutcome::Pass` without touching the spawner; when
464 // `run_ctx.replay_store` is `Some`, every fresh Pass appends
465 // one Ctx-snapshot row so a later resume can replay it. With
466 // `run_ctx = None` this collapses to the same behavior as the
467 // legacy `dispatch_attempt_with(..., None)` call.
468 let outcome = self
469 .engine
470 .dispatch_attempt_with_run_ctx(
471 &self.op_token,
472 &tid,
473 &self.spawner,
474 self.run_ctx.as_ref(),
475 )
476 .await;
477
478 let completed_at_ms = crate::store::trace::now_unix_ms();
479 let duration_ms = started.elapsed().as_millis() as u64;
480 // Drain boundary-reported worker stats UNCONDITIONALLY (even when
481 // no RunContext consumes them) so retried/undrained attempts never
482 // accumulate in EngineState for the process lifetime.
483 let worker_stats = self.engine.take_worker_stats(&tid).await;
484 if trace.is_some() {
485 self.engine.set_trace_handle(&tid, None).await;
486 }
487
488 // issue #13 run_id propagation: append one step_entry per dispatched
489 // step (`RunStore.append_step_entry` is append-only — there is no
490 // in-place update — so the entry is written once here, after the
491 // outcome is known, carrying its final status). Secondary
492 // persistence failures are logged and swallowed, matching
493 // `mse-server`'s `finalize_run` convention: they must not mask the
494 // primary dispatch outcome the flow eval already has in hand.
495 if let Some(rc) = &self.run_ctx {
496 let status = match &outcome {
497 Ok(DispatchOutcome::Pass(_)) => "passed",
498 Ok(DispatchOutcome::Blocked(_)) => "blocked",
499 // GH #76 Skip tier: Skip tier StepEntry status. Distinct from
500 // "passed" so post-run inspection of `RunRecord.step_entries`
501 // can distinguish flow-continuation-with-write from
502 // flow-continuation-without-write.
503 Ok(DispatchOutcome::Skip(_)) => "skipped",
504 Ok(DispatchOutcome::Suspended(_)) => "suspended",
505 Ok(DispatchOutcome::Cancelled) => "cancelled",
506 Ok(DispatchOutcome::Timeout) => "timeout",
507 Err(_) => "failed",
508 };
509 let mut entry = StepEntry::basic(
510 tid.clone(),
511 Some(ref_.to_string()),
512 Some(status.to_string()),
513 self.binding_digests.get(ref_).cloned(),
514 now_unix(),
515 );
516 entry.started_at_ms = Some(started_at_ms);
517 entry.completed_at_ms = Some(completed_at_ms);
518 entry.duration_ms = Some(duration_ms);
519 // `attempt` from worker_stats when a boundary reported one;
520 // fall back to the engine's own attempt counter so
521 // non-stats-carrying workers (RustFn / Lua) still populate
522 // the field — the "every StepEntry has attempt" invariant.
523 // `task_attempt` failure is swallowed (the row is deleted
524 // between the outcome and this lookup, or the caller drove
525 // dispatch without a task record) — the field stays None,
526 // same fail-open convention as the append itself.
527 let attempt = if let Some((a, _)) = &worker_stats {
528 Some(*a)
529 } else {
530 self.engine.task_attempt(&tid).await.ok()
531 };
532 entry.attempt = attempt;
533 if let Some((_, stats)) = worker_stats {
534 entry = entry.with_worker_stats(stats);
535 }
536 if let Err(e) = rc.run_store.append_step_entry(&rc.run_id, entry).await {
537 tracing::warn!(
538 run_id = %rc.run_id,
539 step_id = %tid,
540 error = %e,
541 "EngineDispatcher::dispatch: append_step_entry failed"
542 );
543 }
544 if let Some(handle) = &trace {
545 handle
546 .append(
547 crate::store::trace::kind::STEP_COMPLETED,
548 Some(ref_),
549 attempt,
550 serde_json::json!({
551 "status": status,
552 "duration_ms": duration_ms,
553 }),
554 )
555 .await;
556 }
557 }
558
559 match outcome {
560 Ok(DispatchOutcome::Pass(v)) => Ok(v),
561 // GH #76 Skip tier: Skip tier is flow-continuation, not error. Map
562 // to `Ok(wrap_skip_marker(v))` — the sentinel Value the
563 // downstream binding-write path recognizes via
564 // [`crate::core::state::is_skip_marker`] to short-circuit the
565 // `$.<step_id>` write (short-circuit itself lands in a
566 // separate follow-up; the sentinel is the wire that carries
567 // the signal across the flow-ir boundary). MUST precede the
568 // wildcard `Ok(other) =>` arm below or Skip would be routed
569 // to `EvalError::DispatcherError` (the non-terminal fallback)
570 // and abort the flow — the exact failure mode this tier is
571 // meant to prevent.
572 Ok(DispatchOutcome::Skip(v)) => Ok(wrap_skip_marker(v)),
573 Ok(DispatchOutcome::Blocked(v)) => {
574 // GH #76 error surface: single-slot breadcrumb the surrounding
575 // `TaskLaunchService::launch` `map_err` closure reads to
576 // populate `TaskLaunchError::FlowEval { failed_step,
577 // verdict_value, .. }`. Written last-write-wins BEFORE the
578 // `EvalError::DispatcherError` return so flow-ir sees the
579 // exact same error the pre-error surface world raised — the
580 // breadcrumb is side-channel observability, never
581 // load-bearing on the abort itself. `run_ctx = None`
582 // (dispatchers built without `with_run`) is a no-op:
583 // there is nowhere to write, and every consumer already
584 // treats `partial_ctx: None` / `failed_step: None` as
585 // "not available".
586 if let Some(rc) = &self.run_ctx {
587 rc.set_last_failure(LastFailure {
588 step_id: tid.clone(),
589 step_ref: Some(ref_.to_string()),
590 verdict_value: v.clone(),
591 });
592 }
593 Err(EvalError::DispatcherError {
594 ref_: ref_.to_string(),
595 msg: format!("blocked: {v}"),
596 })
597 }
598 Ok(other) => Err(EvalError::DispatcherError {
599 ref_: ref_.to_string(),
600 msg: format!("non-terminal outcome: {:?}", other),
601 }),
602 Err(e) => Err(EvalError::DispatcherError {
603 ref_: ref_.to_string(),
604 msg: format!("dispatch_attempt: {e}"),
605 }),
606 }
607 }
608}
609
610// ──────────────────────────────────────────────────────────────────────────
611// issue #21 Phase 2: `resolve_step_envelope` unit tests + a dispatch-level
612// end-to-end leak-proof test
613// ──────────────────────────────────────────────────────────────────────────
614
615#[cfg(test)]
616mod tests {
617 use super::*;
618 use serde_json::json;
619
620 fn metas(pairs: &[(&str, Value)]) -> HashMap<String, Value> {
621 pairs
622 .iter()
623 .map(|(k, v)| (k.to_string(), v.clone()))
624 .collect()
625 }
626
627 #[test]
628 fn no_envelope_string_input_passes_through_unchanged() {
629 let (directive, step_ctx) =
630 resolve_step_envelope(&HashMap::new(), "scout", json!("plain string")).unwrap();
631 assert_eq!(directive, json!("plain string"));
632 assert_eq!(step_ctx, None);
633 }
634
635 #[test]
636 fn no_envelope_plain_object_input_passes_through_unchanged() {
637 let input = json!({ "foo": "bar" });
638 let (directive, step_ctx) =
639 resolve_step_envelope(&HashMap::new(), "scout", input.clone()).unwrap();
640 assert_eq!(directive, input);
641 assert_eq!(step_ctx, None);
642 }
643
644 #[test]
645 fn envelope_with_only_ref_resolves_that_metadef_ctx() {
646 let step_metas = metas(&[("heavy-scan", json!({ "work_dir": "/x" }))]);
647 let input = json!({ "$step_meta": { "ref": "heavy-scan" }, "$in": "go" });
648 let (directive, step_ctx) = resolve_step_envelope(&step_metas, "scout", input).unwrap();
649 assert_eq!(directive, json!("go"));
650 assert_eq!(step_ctx, Some(json!({ "work_dir": "/x" })));
651 }
652
653 #[test]
654 fn envelope_with_only_inline_uses_inline_verbatim() {
655 let input = json!({
656 "$step_meta": { "inline": { "work_dir": "/inline-only" } },
657 "$in": "go"
658 });
659 let (directive, step_ctx) = resolve_step_envelope(&HashMap::new(), "scout", input).unwrap();
660 assert_eq!(directive, json!("go"));
661 assert_eq!(step_ctx, Some(json!({ "work_dir": "/inline-only" })));
662 }
663
664 #[test]
665 fn inline_wins_over_ref_on_key_collision() {
666 let step_metas = metas(&[(
667 "heavy-scan",
668 json!({ "work_dir": "/ref", "extra": "from-ref" }),
669 )]);
670 let input = json!({
671 "$step_meta": {
672 "ref": "heavy-scan",
673 "inline": { "work_dir": "/inline-wins" }
674 },
675 "$in": "go"
676 });
677 let (_, step_ctx) = resolve_step_envelope(&step_metas, "scout", input).unwrap();
678 assert_eq!(
679 step_ctx,
680 Some(json!({ "work_dir": "/inline-wins", "extra": "from-ref" })),
681 "inline must win the collided key while ref-only keys survive the merge"
682 );
683 }
684
685 #[test]
686 fn dollar_in_rule_extracts_directive_and_ignores_other_sibling_keys() {
687 let input = json!({
688 "$step_meta": { "inline": { "k": "v" } },
689 "$in": "the real directive",
690 "unrelated_sibling": "ignored"
691 });
692 let (directive, step_ctx) = resolve_step_envelope(&HashMap::new(), "scout", input).unwrap();
693 assert_eq!(directive, json!("the real directive"));
694 assert_eq!(step_ctx, Some(json!({ "k": "v" })));
695 }
696
697 #[test]
698 fn no_dollar_in_remainder_becomes_the_directive() {
699 let input = json!({
700 "$step_meta": { "inline": { "k": "v" } },
701 "other_key": "other_value"
702 });
703 let (directive, _) = resolve_step_envelope(&HashMap::new(), "scout", input).unwrap();
704 assert_eq!(directive, json!({ "other_key": "other_value" }));
705 }
706
707 #[test]
708 fn empty_remainder_becomes_empty_string_directive() {
709 let input = json!({ "$step_meta": { "ref": "heavy-scan" } });
710 let step_metas = metas(&[("heavy-scan", json!({ "work_dir": "/x" }))]);
711 let (directive, step_ctx) = resolve_step_envelope(&step_metas, "scout", input).unwrap();
712 assert_eq!(directive, Value::String(String::new()));
713 assert_eq!(step_ctx, Some(json!({ "work_dir": "/x" })));
714 }
715
716 #[test]
717 fn unresolved_ref_is_a_loud_dispatcher_error_naming_ref_and_defined() {
718 let step_metas = metas(&[("known", json!({}))]);
719 let input = json!({ "$step_meta": { "ref": "unknown" }, "$in": "go" });
720 let err = resolve_step_envelope(&step_metas, "scout", input).unwrap_err();
721 match err {
722 EvalError::DispatcherError { ref_, msg } => {
723 assert_eq!(ref_, "scout");
724 assert!(
725 msg.contains("unknown"),
726 "message must name the unresolved ref: {msg}"
727 );
728 assert!(
729 msg.contains("known"),
730 "message must list defined names: {msg}"
731 );
732 }
733 other => panic!("expected DispatcherError, got {other:?}"),
734 }
735 }
736
737 #[test]
738 fn malformed_step_meta_not_an_object_is_a_loud_error() {
739 let input = json!({ "$step_meta": "not-an-object" });
740 let err = resolve_step_envelope(&HashMap::new(), "scout", input).unwrap_err();
741 assert!(matches!(err, EvalError::DispatcherError { .. }));
742 }
743
744 #[test]
745 fn malformed_ref_non_string_is_a_loud_error() {
746 let input = json!({ "$step_meta": { "ref": 42 } });
747 let err = resolve_step_envelope(&HashMap::new(), "scout", input).unwrap_err();
748 assert!(matches!(err, EvalError::DispatcherError { .. }));
749 }
750
751 #[test]
752 fn malformed_inline_non_object_is_a_loud_error() {
753 let input = json!({ "$step_meta": { "inline": "not-an-object" } });
754 let err = resolve_step_envelope(&HashMap::new(), "scout", input).unwrap_err();
755 assert!(matches!(err, EvalError::DispatcherError { .. }));
756 }
757
758 #[test]
759 fn ref_resolved_metadef_ctx_non_object_is_a_loud_error() {
760 let step_metas = metas(&[("bad", json!("not-an-object"))]);
761 let input = json!({ "$step_meta": { "ref": "bad" } });
762 let err = resolve_step_envelope(&step_metas, "scout", input).unwrap_err();
763 assert!(matches!(err, EvalError::DispatcherError { .. }));
764 }
765
766 /// End-to-end proof (issue #21 Phase 2 Done Criteria #5): a `$step_meta`
767 /// envelope must never reach `EngineState.prompts[(tid, 1)]` — the
768 /// resolve step runs BEFORE `start_task` seeds that table.
769 #[tokio::test]
770 async fn dispatch_step_meta_envelope_never_leaks_into_stored_prompt() {
771 use crate::blueprint::compiler::{RustFnInProcessSpawnerFactory, SpawnerFactory};
772 use crate::core::config::EngineCfg;
773 use crate::types::{Role, StepId};
774 use crate::worker::adapter::WorkerResult;
775 use std::sync::Mutex as StdMutex;
776 use std::time::Duration;
777
778 let captured_tid: Arc<StdMutex<Option<StepId>>> = Arc::new(StdMutex::new(None));
779 let captured_tid_for_fn = captured_tid.clone();
780 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", move |inv| {
781 let captured_tid = captured_tid_for_fn.clone();
782 async move {
783 *captured_tid.lock().unwrap() = Some(inv.task_id.clone());
784 Ok(WorkerResult {
785 value: json!({ "ok": true }),
786 ok: true,
787 stats: None,
788 })
789 }
790 });
791 let def = AgentDef {
792 name: "scout".into(),
793 kind: AgentKind::RustFn,
794 spec: json!({ "fn_id": "echo" }),
795 profile: None,
796 meta: None,
797 runner: None,
798 runner_ref: None,
799 verdict: None,
800 };
801 let spawner = factory.build(&def, None).expect("build");
802
803 let engine = Engine::new(EngineCfg::default());
804 let token = engine
805 .attach("ut-op", Role::Operator, Duration::from_secs(30))
806 .await
807 .expect("attach");
808 let step_metas = metas(&[("heavy-scan", json!({ "work_dir": "/x" }))]);
809 let dispatcher = EngineDispatcher::with_spawner(engine.clone(), token, spawner)
810 .with_step_metas(step_metas);
811
812 let input = json!({
813 "$step_meta": { "ref": "heavy-scan" },
814 "$in": "do the thing"
815 });
816 let out = dispatcher
817 .dispatch("scout", input)
818 .await
819 .expect("dispatch ok");
820 assert_eq!(out, json!({ "ok": true }));
821
822 let tid = captured_tid
823 .lock()
824 .unwrap()
825 .clone()
826 .expect("task_id captured");
827 let stored_prompt = engine
828 .with_state("test.read_prompt", move |s| {
829 s.prompts.get(&(tid, 1)).cloned()
830 })
831 .await
832 .expect("with_state")
833 .expect("prompt recorded for attempt 1");
834 assert_eq!(
835 stored_prompt,
836 json!("do the thing"),
837 "the stored prompt must be the post-envelope directive, with no $step_meta leakage"
838 );
839 }
840
841 /// GH #76 error surface: the dispatcher's Blocked arm writes the
842 /// `RunContext.last_failure` breadcrumb (step_id + step_ref +
843 /// verdict_value) BEFORE returning `EvalError::DispatcherError`. This
844 /// test drives a `WorkerResult { ok: false }` through the dispatcher
845 /// and asserts every breadcrumb field, including that step_ref matches
846 /// the dispatched Blueprint ref and verdict_value carries the full
847 /// value the worker returned (not a stringified summary).
848 #[tokio::test]
849 async fn dispatcher_blocked_records_last_failure_breadcrumb() {
850 use crate::blueprint::compiler::{RustFnInProcessSpawnerFactory, SpawnerFactory};
851 use crate::core::config::EngineCfg;
852 use crate::store::run::{InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore};
853 use crate::types::{Role, RunId, TaskId};
854 use crate::worker::adapter::WorkerResult;
855 use std::time::Duration;
856
857 let factory = RustFnInProcessSpawnerFactory::new().register_fn("echo", |_inv| async move {
858 Ok(WorkerResult {
859 value: json!({ "verdict": "BLOCKED", "reason": "not-applicable" }),
860 ok: false,
861 stats: None,
862 })
863 });
864 let def = AgentDef {
865 name: "gate".into(),
866 kind: AgentKind::RustFn,
867 spec: json!({ "fn_id": "echo" }),
868 profile: None,
869 meta: None,
870 runner: None,
871 runner_ref: None,
872 verdict: None,
873 };
874 let spawner = factory.build(&def, None).expect("build");
875
876 let engine = Engine::new(EngineCfg::default());
877 let token = engine
878 .attach("ut-op", Role::Operator, Duration::from_secs(30))
879 .await
880 .expect("attach");
881
882 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
883 let run_id = RunId::new();
884 run_store
885 .create(RunRecord {
886 id: run_id.clone(),
887 task_id: TaskId::new(),
888 status: RunStatus::Running,
889 step_entries: Vec::new(),
890 degradations: Vec::new(),
891 operator_sid: None,
892 result_ref: None,
893 input_json: Some("{}".to_string()),
894 created_at: 0,
895 updated_at: 0,
896 })
897 .await
898 .expect("seed RunRecord");
899
900 let run_ctx = RunContext::new(run_id, run_store);
901 let dispatcher =
902 EngineDispatcher::with_spawner(engine, token, spawner).with_run(run_ctx.clone());
903
904 let err = dispatcher
905 .dispatch("gate", json!("go"))
906 .await
907 .expect_err("expected DispatcherError for Blocked outcome");
908 // The public `EvalError` surface is unchanged — same
909 // `DispatcherError` variant with the same `ref_` + `msg` shape.
910 assert!(
911 err.to_string().contains("blocked"),
912 "expected EvalError to mention blocked, got: {err}"
913 );
914
915 // Breadcrumb is populated by the same match arm that raised the
916 // error — reading it via the shared `Arc<Mutex<Option<_>>>` must
917 // succeed.
918 let breadcrumb = run_ctx
919 .last_failure
920 .lock()
921 .expect("last_failure mutex not poisoned")
922 .clone()
923 .expect("Blocked arm must have written LastFailure");
924 assert_eq!(
925 breadcrumb.step_ref,
926 Some("gate".to_string()),
927 "step_ref must be the Blueprint ref this dispatch was routed to"
928 );
929 assert_eq!(
930 breadcrumb.verdict_value,
931 json!({ "verdict": "BLOCKED", "reason": "not-applicable" }),
932 "verdict_value must be the exact value the worker returned"
933 );
934 // step_id is the freshly minted dispatch-time tid — its exact
935 // value is opaque, but it must be non-empty (StepId::to_string()
936 // never yields an empty string for a valid mint).
937 assert!(!breadcrumb.step_id.to_string().is_empty());
938 }
939
940 /// GH #76 error surface: `RunContext::snapshot_partial_ctx` reads the persisted
941 /// step_entry log and reconstructs a JSON `{ "steps": { <step_id>:
942 /// { step_ref, status, at, .. } } }` shape — metadata-level, not
943 /// value-level. Regression test for the reconstructor itself
944 /// (independent of the map_err closure).
945 #[tokio::test]
946 async fn run_context_snapshot_partial_ctx_reconstructs_step_entry_log() {
947 use crate::store::run::{
948 InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore, StepEntry,
949 };
950 use crate::types::{now_unix, RunId, StepId, TaskId};
951
952 let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
953 let run_id = RunId::new();
954 run_store
955 .create(RunRecord {
956 id: run_id.clone(),
957 task_id: TaskId::new(),
958 status: RunStatus::Running,
959 step_entries: Vec::new(),
960 degradations: Vec::new(),
961 operator_sid: None,
962 result_ref: None,
963 input_json: Some("{}".to_string()),
964 created_at: 0,
965 updated_at: 0,
966 })
967 .await
968 .expect("seed RunRecord");
969 let sid1 = StepId::new();
970 let sid2 = StepId::new();
971 run_store
972 .append_step_entry(
973 &run_id,
974 StepEntry::basic(
975 sid1.clone(),
976 Some("stage-1".to_string()),
977 Some("passed".to_string()),
978 None,
979 now_unix(),
980 ),
981 )
982 .await
983 .expect("append 1");
984 run_store
985 .append_step_entry(
986 &run_id,
987 StepEntry::basic(
988 sid2.clone(),
989 Some("stage-2".to_string()),
990 Some("blocked".to_string()),
991 None,
992 now_unix(),
993 ),
994 )
995 .await
996 .expect("append 2");
997
998 let run_ctx = RunContext::new(run_id, run_store);
999 let snap = run_ctx.snapshot_partial_ctx().await;
1000 let steps = snap
1001 .get("steps")
1002 .and_then(|v| v.as_object())
1003 .expect("steps object");
1004 assert_eq!(steps.len(), 2);
1005 assert_eq!(steps[&sid1.to_string()]["step_ref"], json!("stage-1"));
1006 assert_eq!(steps[&sid1.to_string()]["status"], json!("passed"));
1007 assert_eq!(steps[&sid2.to_string()]["step_ref"], json!("stage-2"));
1008 assert_eq!(steps[&sid2.to_string()]["status"], json!("blocked"));
1009 }
1010}