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::engine::Engine;
28use crate::core::state::{DispatchOutcome, TaskSpec};
29use crate::store::run::{RunContext, StepEntry};
30use crate::types::{now_unix, CapToken};
31use crate::worker::adapter::SpawnerAdapter;
32use async_trait::async_trait;
33pub mod compiler;
34pub mod loader;
35pub mod store;
36
37use mlua_flow_ir::{AsyncDispatcher, EvalError};
38use serde_json::Value;
39use std::sync::Arc;
40
41// The schema types are owned by the IF crate (mlua-swarm-schema); we re-export them here.
42/// The schema-side `OperatorKind` (see `crate::core::ctx::OperatorKind` for the
43/// runtime duplicate consumed by `Engine`). Re-exported under an explicit
44/// alias so callers reading `Blueprint.operators[].kind` /
45/// `Blueprint.default_operator_kind` do not have to reach into
46/// `mlua_swarm_schema` directly.
47pub use mlua_swarm_schema::OperatorKind as SchemaOperatorKind;
48pub use mlua_swarm_schema::{
49 current_schema_version, default_global_agent_kind, AgentDef, AgentKind, AgentMeta,
50 AgentProfile, Blueprint, BlueprintMetadata, BlueprintOrigin, CompilerHints, CompilerStrategy,
51 OperatorDef, SpawnerHints, CURRENT_SCHEMA_VERSION,
52};
53
54/// Bridges `mlua_flow_ir::AsyncDispatcher` to the engine's
55/// `start_task` + `dispatch_attempt_with` pair. Holds one Operator session
56/// token and one `spawner`, and spins up a fresh task per `Step.ref`, using
57/// it as the agent name.
58///
59/// Constructed via `with_spawner`; each dispatch goes through
60/// `engine.dispatch_attempt_with(token, tid, spawner, run_id)`, carrying the
61/// spawner per request. Nothing is stashed on engine-global state, so
62/// multiple dispatchers can drive different Blueprints against the same
63/// `Engine` in parallel without racing.
64///
65/// Optionally carries a [`RunContext`] (via [`Self::with_run`], issue #13
66/// run_id propagation): when present, every dispatched step's `run_id` is
67/// exposed to the worker through `Ctx.meta.runtime["run_id"]`, and a
68/// [`StepEntry`] is appended to `RunRecord.step_entries` once the step's
69/// outcome is known (dispatch is synchronous end-to-end here, so there is
70/// no need for a separate event/notification mechanism — the entry is
71/// written with its final status in one call).
72pub struct EngineDispatcher {
73 engine: Engine,
74 op_token: CapToken,
75 spawner: Arc<dyn SpawnerAdapter>,
76 run_ctx: Option<RunContext>,
77}
78
79impl EngineDispatcher {
80 /// Build a dispatcher with no run-level tracing (`run_ctx = None`) —
81 /// the pre-existing behavior. Use [`Self::with_run`] to opt into
82 /// `RunRecord.step_entries` tracing / `ctx.meta.runtime["run_id"]`.
83 pub fn with_spawner(
84 engine: Engine,
85 op_token: CapToken,
86 spawner: Arc<dyn SpawnerAdapter>,
87 ) -> Self {
88 Self {
89 engine,
90 op_token,
91 spawner,
92 run_ctx: None,
93 }
94 }
95
96 /// Attach a [`RunContext`] (builder style) so every dispatched step is
97 /// traced into `RunRecord.step_entries` and exposes its `run_id` via
98 /// `Ctx.meta.runtime`.
99 pub fn with_run(mut self, run_ctx: RunContext) -> Self {
100 self.run_ctx = Some(run_ctx);
101 self
102 }
103}
104
105#[async_trait]
106impl AsyncDispatcher for EngineDispatcher {
107 async fn dispatch(&self, ref_: &str, input: Value) -> Result<Value, EvalError> {
108 // Turn the evaluated Step.in value into a directive. Strings pass
109 // through verbatim; anything else is serde-stringified (the worker
110 // is expected to re-parse it).
111 let directive = match &input {
112 Value::String(s) => s.clone(),
113 other => other.to_string(),
114 };
115
116 let tid = self
117 .engine
118 .start_task(
119 &self.op_token,
120 TaskSpec {
121 agent: ref_.to_string(),
122 initial_directive: directive,
123 },
124 )
125 .await
126 .map_err(|e| EvalError::DispatcherError {
127 ref_: ref_.to_string(),
128 msg: format!("start_task: {e}"),
129 })?;
130
131 let run_id_for_ctx = self.run_ctx.as_ref().map(|rc| rc.run_id.clone());
132 let outcome = self
133 .engine
134 .dispatch_attempt_with(&self.op_token, &tid, &self.spawner, run_id_for_ctx.as_ref())
135 .await;
136
137 // issue #13 run_id propagation: append one step_entry per dispatched
138 // step (`RunStore.append_step_entry` is append-only — there is no
139 // in-place update — so the entry is written once here, after the
140 // outcome is known, carrying its final status). Secondary
141 // persistence failures are logged and swallowed, matching
142 // `mse-server`'s `finalize_run` convention: they must not mask the
143 // primary dispatch outcome the flow eval already has in hand.
144 if let Some(rc) = &self.run_ctx {
145 let status = match &outcome {
146 Ok(DispatchOutcome::Pass(_)) => "passed",
147 Ok(DispatchOutcome::Blocked(_)) => "blocked",
148 Ok(DispatchOutcome::Suspended(_)) => "suspended",
149 Ok(DispatchOutcome::Cancelled) => "cancelled",
150 Ok(DispatchOutcome::Timeout) => "timeout",
151 Err(_) => "failed",
152 };
153 let entry = StepEntry {
154 step_id: tid.clone(),
155 step_ref: Some(ref_.to_string()),
156 status: Some(status.to_string()),
157 at: now_unix(),
158 };
159 if let Err(e) = rc.run_store.append_step_entry(&rc.run_id, entry).await {
160 tracing::warn!(
161 run_id = %rc.run_id,
162 step_id = %tid,
163 error = %e,
164 "EngineDispatcher::dispatch: append_step_entry failed"
165 );
166 }
167 }
168
169 match outcome {
170 Ok(DispatchOutcome::Pass(v)) => Ok(v),
171 Ok(DispatchOutcome::Blocked(v)) => Err(EvalError::DispatcherError {
172 ref_: ref_.to_string(),
173 msg: format!("blocked: {v}"),
174 }),
175 Ok(other) => Err(EvalError::DispatcherError {
176 ref_: ref_.to_string(),
177 msg: format!("non-terminal outcome: {:?}", other),
178 }),
179 Err(e) => Err(EvalError::DispatcherError {
180 ref_: ref_.to_string(),
181 msg: format!("dispatch_attempt: {e}"),
182 }),
183 }
184 }
185}