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}