mlua_flow_ir/lib.rs
1#![deny(unsafe_code)]
2#![warn(missing_docs)]
3//! flow.ir async runtime + mlua binding.
4//!
5//! Layer 3 of the 4-layer flow.ir stack:
6//!
7//! 1. `flow-ir-lua` — Pure Lua DSL (separate repo, ecosystem-neutral)
8//! 2. `flow-ir-core` — Pure Rust schema + sync interpreter (no mlua, no async)
9//! 3. `mlua-flow-ir` — **this crate**: re-export of `flow-ir-core` +
10//! `AsyncDispatcher` + `eval_async` (including `Fanout` join-mode
11//! support) + Lua `module()` binding
12//! 4. `mlua-swarm-engine` — host concerns (Spawner / Worker / Loop /
13//! AuthzPolicy / cp_state persist)
14//!
15//! All schema types (`Node` / `Expr` / `JoinMode` / `EvalError` / `Dispatcher`)
16//! are re-exported verbatim from `flow-ir-core` so callers can keep a single
17//! import path:
18//!
19//! ```
20//! use mlua_flow_ir::{eval, eval_async, AsyncDispatcher, Dispatcher, EvalError, Expr, Node};
21//! ```
22//!
23//! ## Sync/async divergence (`Fanout` join modes)
24//!
25//! Sync (`flow_ir_core::eval_with_storage_externs`) and async
26//! (`eval_async_with_storage_externs`) share identical per-`Node` logic for
27//! `Step` / `Seq` / `Branch` / `Loop` / `Try` / `Assign`, and for
28//! `JoinMode::All`. Two `Fanout` modes are **intentionally** divergent:
29//! `Race` — sync evaluates only `items[0]`; async races every branch and the
30//! first branch to *complete* wins, so an early error can win over a later
31//! success. `Any` — sync short-circuits sequentially (later branches never
32//! dispatch once one succeeds); async launches every branch concurrently and
33//! cancels the losers at their next `.await` point, so in-flight side
34//! effects on losing branches may be truncated.
35//!
36//! ## Feature flags
37//!
38//! Default = `lua54` + `vendored` (matches every existing consumer). The
39//! Lua `module()` binding and its `mlua` dependency live behind the
40//! implicit `mlua` feature (auto-enabled by any `lua5x`/`luajit`/`luau`
41//! feature). To pick another Lua version: `default-features = false,
42//! features = ["luajit", "vendored"]`. To link a system Lua instead of a
43//! vendored build, drop `vendored`. For an async-only build with no Lua
44//! binding at all (`module()` unavailable): `default-features = false`.
45
46// ──────────────────────────────────────────────────────────────────────────
47// Re-export Pure Rust core (flow-ir-core)
48// ──────────────────────────────────────────────────────────────────────────
49
50pub use flow_ir_core::{
51 eval, eval_expr, eval_expr_with_externs, eval_externs, eval_with_storage,
52 eval_with_storage_externs, is_truthy, read_path, write_path, CtxStorage, Dispatcher, EvalError,
53 Expr, ExternFn, ExternMap, Externs, JoinMode, MemoryCtx, NoExterns, Node, Path, PathParseError,
54};
55
56use serde_json::Value;
57use std::sync::Arc;
58
59// ══════════════════════════════════════════════════════════════════════════
60// v0.0.2 — Async core (eval_async + AsyncDispatcher trait)
61// ══════════════════════════════════════════════════════════════════════════
62
63use async_recursion::async_recursion;
64use async_trait::async_trait;
65
66/// Async dispatcher trait — async 版 `Dispatcher`。
67///
68/// `async_trait` macro 経由 (= Rust 2021 互換 + dyn safe)。 Host crate
69/// (e.g. mlua-swarm-engine `AsyncSpawner`) が impl する。 substrate には
70/// tokio dep 入れない (= Pure 維持)、 executor は caller (host) 責務。
71#[async_trait]
72pub trait AsyncDispatcher: Send + Sync {
73 /// Resolve `ref_` against `input` asynchronously, returning the step's
74 /// raw output value.
75 async fn dispatch(&self, ref_: &str, input: Value) -> Result<Value, EvalError>;
76}
77
78/// Storage-backed async evaluator — canonical entry.
79///
80/// `Arc<dyn CtxStorage>` 経由で ctx を共有することで、 dispatch().await suspend
81/// 中に外部 task が同じ ctx に `write` できる (= dynamic State injection 経路)。
82/// Step 評価の境界で `ctx.snapshot()` を取って Expr eval に渡す。
83pub async fn eval_async_with_storage<D>(
84 node: &Node,
85 ctx: Arc<dyn CtxStorage>,
86 dispatcher: &D,
87) -> Result<(), EvalError>
88where
89 D: AsyncDispatcher + ?Sized,
90{
91 eval_async_with_storage_externs(node, ctx, dispatcher, &NoExterns).await
92}
93
94/// `eval_async_with_storage` + externs registry for `call_extern` Expr
95/// resolution. `externs` must be `Sync` so the recursive future stays `Send`
96/// (host executors spawn it across threads).
97#[async_recursion]
98pub async fn eval_async_with_storage_externs<D>(
99 node: &Node,
100 ctx: Arc<dyn CtxStorage>,
101 dispatcher: &D,
102 externs: &(dyn Externs + Sync),
103) -> Result<(), EvalError>
104where
105 D: AsyncDispatcher + ?Sized,
106{
107 match node {
108 Node::Step { ref_, in_, out } => {
109 // snap は dispatch() **呼出し前** の view。 dispatch().await 中に
110 // 外部 task が ctx.write しても、 ここで取った snap は影響を受けず
111 // input の値は確定。 write_target の `out` path への write は
112 // dispatch 完了後に共有 ctx を直接更新。
113 let snap = ctx.snapshot();
114 let input = eval_expr_with_externs(in_, &snap, externs)?;
115 let output =
116 dispatcher
117 .dispatch(ref_, input)
118 .await
119 .map_err(|e| EvalError::DispatcherError {
120 ref_: ref_.clone(),
121 msg: e.to_string(),
122 })?;
123 ctx.write(&path_of_async(out)?.to_string(), output)
124 }
125 Node::Seq { children } => {
126 for child in children {
127 eval_async_with_storage_externs(child, ctx.clone(), dispatcher, externs).await?;
128 }
129 Ok(())
130 }
131 Node::Branch { cond, then_, else_ } => {
132 let snap = ctx.snapshot();
133 match eval_expr_with_externs(cond, &snap, externs)? {
134 Value::Bool(true) => {
135 eval_async_with_storage_externs(then_, ctx, dispatcher, externs).await
136 }
137 Value::Bool(false) => {
138 eval_async_with_storage_externs(else_, ctx, dispatcher, externs).await
139 }
140 other => Err(EvalError::NonBoolCond(other)),
141 }
142 }
143 Node::Fanout {
144 items,
145 bind,
146 body,
147 join,
148 out,
149 } => fanout_eval(items, bind, body, *join, out, ctx, dispatcher, externs).await,
150 Node::Loop {
151 counter,
152 cond,
153 body,
154 max,
155 } => {
156 let counter_path = path_of_async(counter)?.to_string();
157 ctx.write(&counter_path, Value::Number(serde_json::Number::from(0u32)))?;
158 let mut n: u32 = 0;
159 loop {
160 if n >= *max {
161 break;
162 }
163 let snap = ctx.snapshot();
164 if !is_truthy(&eval_expr_with_externs(cond, &snap, externs)?) {
165 break;
166 }
167 eval_async_with_storage_externs(body, ctx.clone(), dispatcher, externs).await?;
168 n += 1;
169 ctx.write(&counter_path, Value::Number(serde_json::Number::from(n)))?;
170 }
171 Ok(())
172 }
173 Node::Try {
174 body,
175 catch,
176 err_at,
177 } => {
178 let snap_before = ctx.snapshot();
179 match eval_async_with_storage_externs(body, ctx.clone(), dispatcher, externs).await {
180 Ok(()) => Ok(()),
181 Err(e) => {
182 ctx.replace(snap_before);
183 if let Some(at) = err_at {
184 ctx.write(
185 &path_of_async(at)?.to_string(),
186 Value::String(e.to_string()),
187 )?;
188 }
189 eval_async_with_storage_externs(catch, ctx, dispatcher, externs).await
190 }
191 }
192 }
193 Node::Assign { at, value } => {
194 let snap = ctx.snapshot();
195 let v = eval_expr_with_externs(value, &snap, externs)?;
196 ctx.write(&path_of_async(at)?.to_string(), v)
197 }
198 }
199}
200
201/// Evaluate a `Node` against a context value asynchronously, using the given
202/// async dispatcher for `Step` resolution.
203///
204/// Legacy Value-passing async evaluator — backward compat wrapper around
205/// `eval_async_with_storage` + `MemoryCtx`. 既存 caller (= dynamic injection
206/// を要求しない用途) は引き続きこの API で OK。
207///
208/// `eval` (sync) と同型 logic、 dispatch を `.await` に置き換え。 Seq / Branch
209/// は recursive async fn (= `async_recursion` macro で `Pin<Box>` wrap)。
210///
211/// # Quick start
212///
213/// ```
214/// use async_trait::async_trait;
215/// use mlua_flow_ir::{eval_async, AsyncDispatcher, EvalError, Expr, Node};
216/// use serde_json::{json, Value};
217///
218/// struct Fixture;
219///
220/// #[async_trait]
221/// impl AsyncDispatcher for Fixture {
222/// async fn dispatch(&self, _r: &str, input: Value) -> Result<Value, EvalError> {
223/// if let Value::String(s) = input {
224/// Ok(Value::String(s.to_uppercase()))
225/// } else {
226/// Ok(input)
227/// }
228/// }
229/// }
230///
231/// let rt = tokio::runtime::Runtime::new().unwrap();
232/// rt.block_on(async {
233/// let node = Node::Step {
234/// ref_: "up".into(),
235/// in_: Expr::Path { at: "$.input".parse().unwrap() },
236/// out: Expr::Path { at: "$.output".parse().unwrap() },
237/// };
238/// let out = eval_async(&node, json!({ "input": "hello" }), &Fixture).await.unwrap();
239/// assert_eq!(out, json!({ "input": "hello", "output": "HELLO" }));
240/// });
241/// ```
242pub async fn eval_async<D>(node: &Node, ctx: Value, dispatcher: &D) -> Result<Value, EvalError>
243where
244 D: AsyncDispatcher + ?Sized,
245{
246 eval_async_externs(node, ctx, dispatcher, &NoExterns).await
247}
248
249/// `eval_async` + externs registry for `call_extern` Expr resolution.
250pub async fn eval_async_externs<D>(
251 node: &Node,
252 ctx: Value,
253 dispatcher: &D,
254 externs: &(dyn Externs + Sync),
255) -> Result<Value, EvalError>
256where
257 D: AsyncDispatcher + ?Sized,
258{
259 let storage: Arc<dyn CtxStorage> = MemoryCtx::shared(ctx);
260 eval_async_with_storage_externs(node, storage.clone(), dispatcher, externs).await?;
261 Ok(storage.snapshot())
262}
263
264/// Extract the already-parsed [`Path`] out of a `Path` `Expr` (async eval
265/// side helper — mirrors `flow_ir_core`'s private `path_of`).
266fn path_of_async(expr: &Expr) -> Result<&Path, EvalError> {
267 match expr {
268 Expr::Path { at } => Ok(at),
269 _ => Err(EvalError::InvalidPath(
270 "expected Path expr for write target".into(),
271 )),
272 }
273}
274
275/// Fanout 並列 evaluator (storage-backed)。 各 branch は disjoint MemoryCtx
276/// を持ち、 branch 内で write しても共有 ctx には影響しない (= snapshot 切り出し
277/// semantic)。 集約結果は最後に共有 ctx の `out` path に write。
278#[async_recursion]
279#[allow(clippy::too_many_arguments)]
280async fn fanout_eval<D>(
281 items: &Expr,
282 bind: &Expr,
283 body: &Node,
284 join: JoinMode,
285 out: &Expr,
286 ctx: Arc<dyn CtxStorage>,
287 dispatcher: &D,
288 externs: &(dyn Externs + Sync),
289) -> Result<(), EvalError>
290where
291 D: AsyncDispatcher + ?Sized,
292{
293 use futures::future::{join_all, select_ok, FutureExt};
294
295 let snap = ctx.snapshot();
296 let items_val = eval_expr_with_externs(items, &snap, externs)?;
297 let items_arr = match items_val {
298 Value::Array(a) => a,
299 other => {
300 return Err(EvalError::TypeError {
301 op: "fanout.items".into(),
302 msg: format!("expected array, got {other:?}"),
303 })
304 }
305 };
306
307 // branch storage を pre-allocate して、 各 branch future と pair で持つ。
308 // 集約時に同じ storage の snapshot を取って結果にする。
309 let branches: Vec<Arc<dyn CtxStorage>> = items_arr
310 .into_iter()
311 .map(|item| -> Result<Arc<dyn CtxStorage>, EvalError> {
312 let branch_ctx = write_path(bind, snap.clone(), item)?;
313 Ok(MemoryCtx::shared(branch_ctx))
314 })
315 .collect::<Result<_, _>>()?;
316
317 // 各 branch を `(idx, future)` で wrap。 future は branch storage と body を
318 // 共有して走る。
319 let branch_futs: Vec<_> = branches
320 .iter()
321 .map(|b| eval_async_with_storage_externs(body, b.clone(), dispatcher, externs))
322 .collect();
323
324 let joined: Value = match join {
325 JoinMode::All => {
326 futures::future::try_join_all(branch_futs).await?;
327 Value::Array(branches.iter().map(|b| b.snapshot()).collect())
328 }
329 JoinMode::Any => {
330 // Promise.any parity (mirrors the sync side): zero items can
331 // never produce a winner, so this raises rather than returning
332 // `[]` (unlike All/AllSettled, whose empty-array result shape
333 // is still meaningful).
334 if branch_futs.is_empty() {
335 return Err(EvalError::TypeError {
336 op: "fanout.any".into(),
337 msg: "requires at least one item".into(),
338 });
339 }
340 let mapped: Vec<_> = branch_futs
341 .into_iter()
342 .enumerate()
343 .map(|(i, f)| f.map(move |r| r.map(|()| i)).boxed())
344 .collect();
345 let (winner_idx, _rest) = select_ok(mapped).await?;
346 branches[winner_idx].snapshot()
347 }
348 JoinMode::Race => {
349 // Same rationale as Any: zero branches means there is nothing
350 // to race.
351 if branch_futs.is_empty() {
352 return Err(EvalError::TypeError {
353 op: "fanout.race".into(),
354 msg: "requires at least one item".into(),
355 });
356 }
357 let mapped: Vec<_> = branch_futs
358 .into_iter()
359 .enumerate()
360 .map(|(i, f)| f.map(move |r| r.map(|()| i)).boxed())
361 .collect();
362 let (first, _idx, _rest) = futures::future::select_all(mapped).await;
363 let winner_idx = first?;
364 branches[winner_idx].snapshot()
365 }
366 JoinMode::AllSettled => {
367 let results = join_all(branch_futs).await;
368 let records: Vec<Value> = results
369 .into_iter()
370 .zip(branches.iter())
371 .map(|(r, b)| match r {
372 Ok(()) => serde_json::json!({"status": "fulfilled", "value": b.snapshot()}),
373 Err(e) => serde_json::json!({"status": "rejected", "reason": e.to_string()}),
374 })
375 .collect();
376 Value::Array(records)
377 }
378 };
379
380 ctx.write(&path_of_async(out)?.to_string(), joined)
381}
382
383// ══════════════════════════════════════════════════════════════════════════
384// v0.0.3 — mlua bridge full (feature = "mlua")
385// ══════════════════════════════════════════════════════════════════════════
386
387#[cfg(feature = "mlua")]
388use mlua::LuaSerdeExt;
389
390/// Lua function を Rust `Dispatcher` trait に wrap した adapter。
391///
392/// Lua 側 dispatcher function `function(ref, input) return ... end` を受けて、
393/// Rust `eval(node, ctx, &lua_dispatcher)` から呼び出せるようにする。
394/// 内部で serde Value ↔ Lua value 変換 (= mlua serde feature) を経由。
395#[cfg(feature = "mlua")]
396struct LuaDispatcher<'a> {
397 lua: &'a mlua::Lua,
398 func: mlua::Function,
399}
400
401#[cfg(feature = "mlua")]
402impl<'a> Dispatcher for LuaDispatcher<'a> {
403 fn dispatch(&self, ref_: &str, input: Value) -> Result<Value, EvalError> {
404 let lua_input = self
405 .lua
406 .to_value(&input)
407 .map_err(|e| EvalError::DispatcherError {
408 ref_: ref_.into(),
409 msg: format!("to_value: {}", e),
410 })?;
411 let result: mlua::Value = self.func.call((ref_.to_string(), lua_input)).map_err(|e| {
412 EvalError::DispatcherError {
413 ref_: ref_.into(),
414 msg: format!("lua call: {}", e),
415 }
416 })?;
417 let value: Value = self
418 .lua
419 .from_value(result)
420 .map_err(|e| EvalError::DispatcherError {
421 ref_: ref_.into(),
422 msg: format!("from_value: {}", e),
423 })?;
424 Ok(value)
425 }
426}
427
428/// Lua function table を Rust `Externs` trait に wrap した adapter。
429///
430/// canonical `opts.externs` (flow-ir-lua) と同型: table の各 entry は
431/// pure Lua function で、 `call_extern` Expr の ref で引かれ、 評価済み args
432/// を positional に受けて値を返す。 これが「LuaScript 直実行 Hatch」の
433/// Rust 側の受け口 (extern の実体は任意の Lua closure)。
434#[cfg(feature = "mlua")]
435struct LuaExterns<'a> {
436 lua: &'a mlua::Lua,
437 table: mlua::Table,
438}
439
440#[cfg(feature = "mlua")]
441impl<'a> flow_ir_core::Externs for LuaExterns<'a> {
442 fn call(&self, ref_: &str, args: &[Value]) -> Result<Value, EvalError> {
443 let func: mlua::Function = self.table.get(ref_).map_err(|_| EvalError::ExternError {
444 ref_: ref_.into(),
445 msg: "not registered in externs (or not a function)".into(),
446 })?;
447 let mut lua_args = mlua::MultiValue::new();
448 for a in args {
449 lua_args.push_back(self.lua.to_value(a).map_err(|e| EvalError::ExternError {
450 ref_: ref_.into(),
451 msg: format!("to_value: {}", e),
452 })?);
453 }
454 let result: mlua::Value = func.call(lua_args).map_err(|e| EvalError::ExternError {
455 ref_: ref_.into(),
456 msg: format!("lua call: {}", e),
457 })?;
458 self.lua
459 .from_value(result)
460 .map_err(|e| EvalError::ExternError {
461 ref_: ref_.into(),
462 msg: format!("from_value: {}", e),
463 })
464 }
465}
466
467/// Register the flow module table with Lua.
468///
469/// Exposes:
470///
471/// - `flow.version` (= string): crate version
472/// - `flow.eval(node_table, ctx_table, dispatcher_fn, externs_table?) ->
473/// result_table`: Lua-side entry to evaluate a flow.ir BluePrint with a
474/// Lua dispatcher fn. Optional 4th arg is a table of pure Lua functions
475/// resolved by `call_extern` Expr (canonical `opts.externs` parity).
476///
477/// # Lua usage
478///
479/// ```lua
480/// local flow = require("flow") -- or set via lua.globals():set("flow", module(lua))
481///
482/// local node = {
483/// kind = "step",
484/// ref = "uppercase",
485/// ["in"] = { op = "path", at = "$.input" },
486/// out = { op = "path", at = "$.output" },
487/// }
488///
489/// local function dispatcher(ref, input)
490/// if ref == "uppercase" then
491/// return string.upper(input)
492/// end
493/// end
494///
495/// local result = flow.eval(node, { input = "hello" }, dispatcher)
496/// assert(result.output == "HELLO")
497///
498/// -- call_extern: whitelist pure Lua fns via the externs table
499/// local node2 = {
500/// kind = "assign",
501/// at = { op = "path", at = "$.root" },
502/// value = { op = "call_extern", ref = "math.sqrt",
503/// args = { { op = "path", at = "$.n" } } },
504/// }
505/// local result2 = flow.eval(node2, { n = 9 }, dispatcher,
506/// { ["math.sqrt"] = math.sqrt })
507/// assert(result2.root == 3)
508/// ```
509#[cfg(feature = "mlua")]
510pub fn module(lua: &mlua::Lua) -> mlua::Result<mlua::Table> {
511 let t = lua.create_table()?;
512 t.set("version", env!("CARGO_PKG_VERSION"))?;
513
514 let eval_fn = lua.create_function(
515 |lua_inner: &mlua::Lua,
516 (node_val, ctx_val, dispatcher_fn, externs_val): (
517 mlua::Value,
518 mlua::Value,
519 mlua::Function,
520 Option<mlua::Table>,
521 )| {
522 let node: Node = lua_inner
523 .from_value(node_val)
524 .map_err(|e| mlua::Error::external(format!("node parse: {}", e)))?;
525 let ctx: Value = lua_inner
526 .from_value(ctx_val)
527 .map_err(|e| mlua::Error::external(format!("ctx parse: {}", e)))?;
528
529 let dispatcher = LuaDispatcher {
530 lua: lua_inner,
531 func: dispatcher_fn,
532 };
533 let result = match externs_val {
534 Some(table) => {
535 let externs = LuaExterns {
536 lua: lua_inner,
537 table,
538 };
539 eval_externs(&node, ctx, &dispatcher, &externs)
540 }
541 None => eval(&node, ctx, &dispatcher),
542 }
543 .map_err(|e| mlua::Error::external(format!("eval: {}", e)))?;
544 lua_inner.to_value(&result)
545 },
546 )?;
547 t.set("eval", eval_fn)?;
548
549 Ok(t)
550}