Skip to main content

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}