Skip to main content

flow_ir_core/
lib.rs

1#![deny(unsafe_code)]
2//! flow.ir Pure Rust schema + sync interpreter.
3//!
4//! 3 Node kinds (Step / Seq / Branch) + Fanout / Loop / Try + 3 Expr ops
5//! (Path / Lit / Eq) + sync `eval` + `Dispatcher` trait + Path read/write.
6//!
7//! mlua / futures / async 依存ゼロ。 async runtime + mlua binding は上流
8//! `mlua-flow-ir` crate が担当する 4 層 stack の core 層。
9//!
10//! # Quick start
11//!
12//! ```
13//! use flow_ir_core::{eval, Dispatcher, EvalError, Expr, Node};
14//! use serde_json::{json, Value};
15//!
16//! let node: Node = serde_json::from_value(json!({
17//!     "kind": "step",
18//!     "ref": "uppercase",
19//!     "in": { "op": "path", "at": "$.input" },
20//!     "out": { "op": "path", "at": "$.output" },
21//! })).unwrap();
22//!
23//! struct Fixture;
24//! impl Dispatcher for Fixture {
25//!     fn dispatch(&self, _r: &str, input: Value) -> Result<Value, EvalError> {
26//!         if let Value::String(s) = input {
27//!             Ok(Value::String(s.to_uppercase()))
28//!         } else {
29//!             Ok(input)
30//!         }
31//!     }
32//! }
33//!
34//! let out = eval(&node, json!({ "input": "hello" }), &Fixture).unwrap();
35//! assert_eq!(out, json!({ "input": "hello", "output": "HELLO" }));
36//! ```
37
38use serde::{Deserialize, Serialize};
39use serde_json::Value;
40use thiserror::Error;
41
42// ──────────────────────────────────────────────────────────────────────────
43// IR: 3 Node kinds + 3 Expr ops
44// ──────────────────────────────────────────────────────────────────────────
45
46/// flow.ir Node kind.
47///
48/// Discriminated with `kind` tag, `deny_unknown_fields` (open=false),
49/// `rename_all = "snake_case"`. Parser-side coverage: Step / Seq / Branch +
50/// Fanout (canonical schema の `fanout` Node、 4 join mode)。 残り Node kind
51/// (let / loop / call / switch / try / map / reduce / etc) は別 turn carry。
52#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
53#[serde(tag = "kind", deny_unknown_fields, rename_all = "snake_case")]
54pub enum Node {
55    /// `Step` — dispatch a referenced operation with `in` input, write result to `out`.
56    Step {
57        #[serde(rename = "ref")]
58        ref_: String,
59        #[serde(rename = "in")]
60        in_: Expr,
61        out: Expr,
62    },
63    /// `Seq` — evaluate children in order, threading the context value through.
64    Seq { children: Vec<Node> },
65    /// `Branch` — eval `cond`; if `true` run `then`, else run `else`.
66    Branch {
67        cond: Expr,
68        #[serde(rename = "then")]
69        then_: Box<Node>,
70        #[serde(rename = "else")]
71        else_: Box<Node>,
72    },
73    /// `Fanout` — eval `items` to an array, run `body` per item against a
74    /// branch-local ctx (caller ctx + item written to `bind`), join results
75    /// per `join` mode into `out`. Async parallel runner uses
76    /// `futures::future::{try_join_all|select_ok|join_all}` (executor-agnostic).
77    Fanout {
78        items: Expr,
79        bind: Expr,
80        body: Box<Node>,
81        join: JoinMode,
82        out: Expr,
83    },
84    /// `Loop` — counter を 0 から、 `cond` が truthy かつ `counter < max` の間
85    /// `body` を eval。 各 iter 後 counter を increment して `counter` path に書く。
86    /// VerdictLoop 等の retry/poll パターン primitive (canonical schema 整合)。
87    Loop {
88        counter: Expr,
89        cond: Expr,
90        body: Box<Node>,
91        max: u32,
92    },
93    /// `Try` — `body` を eval、 raise した場合 `catch` を eval。
94    /// `err_at` が Some なら catch 開始前に error message を ctx に書く。
95    Try {
96        body: Box<Node>,
97        catch: Box<Node>,
98        #[serde(default)]
99        err_at: Option<Expr>,
100    },
101}
102
103/// Fanout join semantics (Promise / futures combinators).
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
105#[serde(rename_all = "snake_case")]
106pub enum JoinMode {
107    /// every branch runs; out is an array of per-branch final ctx
108    /// (Promise.all / `futures::try_join_all`).
109    All,
110    /// first non-raising branch's ctx wins; all-fail raises
111    /// (Promise.any / `futures::future::select_ok`).
112    Any,
113    /// first branch to settle wins, success OR raise
114    /// (Promise.race / `futures::future::select`).
115    Race,
116    /// every branch runs, never raises; per-item record
117    /// `{status: fulfilled|rejected, value|reason}` (Promise.allSettled).
118    AllSettled,
119}
120
121/// flow.ir Expr op.
122///
123/// Discriminated with `op` tag, `deny_unknown_fields`, `rename_all = "snake_case"`.
124/// MVP scope: Path / Lit / Eq only.
125#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
126#[serde(tag = "op", deny_unknown_fields, rename_all = "snake_case")]
127pub enum Expr {
128    /// `Path` — read a value from ctx by simple `$.a.b.c` form.
129    Path { at: String },
130    /// `Lit` — literal JSON value.
131    Lit { value: Value },
132    /// `Eq` — boolean equality of two sub-expressions.
133    Eq { lhs: Box<Expr>, rhs: Box<Expr> },
134}
135
136// ──────────────────────────────────────────────────────────────────────────
137// Dispatcher trait + EvalError
138// ──────────────────────────────────────────────────────────────────────────
139
140/// Dispatcher callback: resolves a `Step.ref` against the provided input,
141/// returns the step's raw output value.
142///
143/// Host crates (e.g. `mlua-swarm-engine`) provide concrete implementations:
144/// agent-block process spawn, mlua callback, MCP call, direct LLM, etc.
145/// `Fn(&str, Value) -> Result<Value, EvalError>` closures also implement this
146/// trait via the blanket impl below.
147pub trait Dispatcher {
148    fn dispatch(&self, ref_: &str, input: Value) -> Result<Value, EvalError>;
149}
150
151impl<F> Dispatcher for F
152where
153    F: Fn(&str, Value) -> Result<Value, EvalError>,
154{
155    fn dispatch(&self, ref_: &str, input: Value) -> Result<Value, EvalError> {
156        self(ref_, input)
157    }
158}
159
160/// Evaluation error.
161#[derive(Debug, Error)]
162pub enum EvalError {
163    #[error("path not found: {0}")]
164    PathNotFound(String),
165    #[error("invalid path syntax: {0}")]
166    InvalidPath(String),
167    #[error("branch cond must be boolean, got: {0}")]
168    NonBoolCond(Value),
169    #[error("dispatcher error for ref '{ref_}': {msg}")]
170    DispatcherError { ref_: String, msg: String },
171}
172
173// ──────────────────────────────────────────────────────────────────────────
174// Evaluator
175// ──────────────────────────────────────────────────────────────────────────
176
177/// Evaluate a `Node` against a context value, using the given dispatcher for `Step` resolution.
178///
179/// Returns the updated context (= ctx with `Step.out` path written for each step traversed).
180pub fn eval<D: Dispatcher>(node: &Node, ctx: Value, dispatcher: &D) -> Result<Value, EvalError> {
181    match node {
182        Node::Step { ref_, in_, out } => {
183            let input = eval_expr(in_, &ctx)?;
184            let output =
185                dispatcher
186                    .dispatch(ref_, input)
187                    .map_err(|e| EvalError::DispatcherError {
188                        ref_: ref_.clone(),
189                        msg: e.to_string(),
190                    })?;
191            write_path(out, ctx, output)
192        }
193        Node::Seq { children } => {
194            let mut cur = ctx;
195            for child in children {
196                cur = eval(child, cur, dispatcher)?;
197            }
198            Ok(cur)
199        }
200        Node::Branch { cond, then_, else_ } => match eval_expr(cond, &ctx)? {
201            Value::Bool(true) => eval(then_, ctx, dispatcher),
202            Value::Bool(false) => eval(else_, ctx, dispatcher),
203            other => Err(EvalError::NonBoolCond(other)),
204        },
205        Node::Fanout {
206            items,
207            bind,
208            body,
209            join,
210            out,
211        } => {
212            // sync fallback = canonical Lua interpreter MVP の serial fallback と同型。
213            // 各 branch を逐次実行、 join semantics は async 版と同じ。
214            let items_val = eval_expr(items, &ctx)?;
215            let items_arr = match items_val {
216                Value::Array(a) => a,
217                other => {
218                    return Err(EvalError::DispatcherError {
219                        ref_: "fanout.items".into(),
220                        msg: format!("expected array, got {other:?}"),
221                    })
222                }
223            };
224            let joined: Value = match join {
225                JoinMode::All => {
226                    let mut results = Vec::with_capacity(items_arr.len());
227                    for item in items_arr {
228                        let branch_ctx = write_path(bind, ctx.clone(), item)?;
229                        results.push(eval(body, branch_ctx, dispatcher)?);
230                    }
231                    Value::Array(results)
232                }
233                JoinMode::Any => {
234                    let mut winner: Option<Value> = None;
235                    let mut last_err: Option<EvalError> = None;
236                    for item in items_arr {
237                        let branch_ctx = write_path(bind, ctx.clone(), item)?;
238                        match eval(body, branch_ctx, dispatcher) {
239                            Ok(v) => {
240                                winner = Some(v);
241                                last_err = None;
242                                break;
243                            }
244                            Err(e) => last_err = Some(e),
245                        }
246                    }
247                    if let Some(e) = last_err {
248                        return Err(e);
249                    }
250                    winner.unwrap_or(Value::Array(vec![]))
251                }
252                JoinMode::Race => {
253                    // serial fallback: item[0] が常に最初に settle する
254                    if let Some(first) = items_arr.into_iter().next() {
255                        let branch_ctx = write_path(bind, ctx.clone(), first)?;
256                        eval(body, branch_ctx, dispatcher)?
257                    } else {
258                        Value::Array(vec![])
259                    }
260                }
261                JoinMode::AllSettled => {
262                    let mut records = Vec::with_capacity(items_arr.len());
263                    for item in items_arr {
264                        let branch_ctx = write_path(bind, ctx.clone(), item)?;
265                        match eval(body, branch_ctx, dispatcher) {
266                            Ok(v) => {
267                                records.push(serde_json::json!({"status": "fulfilled", "value": v}))
268                            }
269                            Err(e) => records.push(
270                                serde_json::json!({"status": "rejected", "reason": e.to_string()}),
271                            ),
272                        }
273                    }
274                    Value::Array(records)
275                }
276            };
277            write_path(out, ctx, joined)
278        }
279        Node::Loop {
280            counter,
281            cond,
282            body,
283            max,
284        } => {
285            let mut cur = write_path(counter, ctx, Value::Number(serde_json::Number::from(0u32)))?;
286            let mut n: u32 = 0;
287            while n < *max && is_truthy(&eval_expr(cond, &cur)?) {
288                cur = eval(body, cur, dispatcher)?;
289                n += 1;
290                cur = write_path(counter, cur, Value::Number(serde_json::Number::from(n)))?;
291            }
292            Ok(cur)
293        }
294        Node::Try {
295            body,
296            catch,
297            err_at,
298        } => match eval(body, ctx.clone(), dispatcher) {
299            Ok(v) => Ok(v),
300            Err(e) => {
301                let cur = match err_at {
302                    Some(at) => write_path(at, ctx, Value::String(e.to_string()))?,
303                    None => ctx,
304                };
305                eval(catch, cur, dispatcher)
306            }
307        },
308    }
309}
310
311/// JSON value の truthy 判定 (= flow.ir Branch cond / Loop cond で使う)。
312/// Bool は値そのまま、 null/false 以外は truthy (Lua / JS と整合)。
313pub fn is_truthy(v: &Value) -> bool {
314    match v {
315        Value::Null => false,
316        Value::Bool(b) => *b,
317        _ => true,
318    }
319}
320
321/// Evaluate an `Expr` against a context value, returning the resolved JSON value.
322pub fn eval_expr(expr: &Expr, ctx: &Value) -> Result<Value, EvalError> {
323    match expr {
324        Expr::Lit { value } => Ok(value.clone()),
325        Expr::Path { at } => read_path(at, ctx),
326        Expr::Eq { lhs, rhs } => {
327            let lv = eval_expr(lhs, ctx)?;
328            let rv = eval_expr(rhs, ctx)?;
329            Ok(Value::Bool(lv == rv))
330        }
331    }
332}
333
334// ──────────────────────────────────────────────────────────────────────────
335// Path helpers (simple `$.a.b.c` form, no array index in MVP)
336// ──────────────────────────────────────────────────────────────────────────
337
338/// Read a path from a JSON value. Supports simple `$.a.b.c` form.
339pub fn read_path(path: &str, ctx: &Value) -> Result<Value, EvalError> {
340    let trimmed = strip_path_prefix(path)?;
341    if trimmed.is_empty() {
342        return Ok(ctx.clone());
343    }
344    let mut cur = ctx;
345    for key in trimmed.split('.') {
346        cur = cur
347            .get(key)
348            .ok_or_else(|| EvalError::PathNotFound(path.to_string()))?;
349    }
350    Ok(cur.clone())
351}
352
353/// Write a value at the path location inside ctx, returning the updated ctx.
354/// `out` must be a `Path` Expr.
355pub fn write_path(out: &Expr, ctx: Value, value: Value) -> Result<Value, EvalError> {
356    let path = match out {
357        Expr::Path { at } => at,
358        _ => {
359            return Err(EvalError::InvalidPath(
360                "Step.out must be a Path expr".into(),
361            ))
362        }
363    };
364    let trimmed = strip_path_prefix(path)?;
365    let keys: Vec<&str> = trimmed.split('.').filter(|s| !s.is_empty()).collect();
366    if keys.is_empty() {
367        return Ok(value);
368    }
369    let mut root = ctx;
370    write_path_recursive(&mut root, &keys, value);
371    Ok(root)
372}
373
374fn strip_path_prefix(path: &str) -> Result<&str, EvalError> {
375    path.strip_prefix("$.")
376        .or_else(|| path.strip_prefix('$'))
377        .ok_or_else(|| EvalError::InvalidPath(format!("path must start with $ or $.: {}", path)))
378}
379
380fn write_path_recursive(node: &mut Value, keys: &[&str], value: Value) {
381    if keys.is_empty() {
382        *node = value;
383        return;
384    }
385    if !node.is_object() {
386        *node = Value::Object(serde_json::Map::new());
387    }
388    let obj = node.as_object_mut().expect("just initialised as object");
389    let key = keys[0];
390    if keys.len() == 1 {
391        obj.insert(key.to_string(), value);
392    } else {
393        let entry = obj
394            .entry(key.to_string())
395            .or_insert(Value::Object(serde_json::Map::new()));
396        write_path_recursive(entry, &keys[1..], value);
397    }
398}