feat(flow): 重构流程引擎与任务执行器架构
重构流程引擎核心组件,引入执行器接口Executor替代原有TaskComponent,优化节点配置映射逻辑: 1. 新增mappers模块集中处理节点配置提取 2. 为存储层添加Storage trait抽象 3. 移除对ctx魔法字段的依赖,直接传递节点信息 4. 增加构建器模式支持引擎创建 5. 完善DSL解析的输入校验 同时标记部分未使用代码为allow(dead_code)
This commit is contained in:
@ -1,5 +1,6 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use anyhow::bail;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FlowDSL {
|
||||
@ -65,11 +66,11 @@ impl From<FlowDSL> for super::domain::ChainDef {
|
||||
}
|
||||
}
|
||||
|
||||
// ===== New: Parse design_json (free layout JSON) to ChainDef and build execution context =====
|
||||
// ===== New: Parse design_json (free layout JSON) to ChainDef =====
|
||||
|
||||
/// Build ChainDef from design_json (front-end flow JSON)
|
||||
pub fn chain_from_design_json(design: &Value) -> anyhow::Result<super::domain::ChainDef> {
|
||||
use super::domain::{ChainDef, NodeDef, NodeId, NodeKind, LinkDef};
|
||||
use super::domain::{ChainDef, NodeKind};
|
||||
|
||||
// Accept both JSON object and stringified JSON
|
||||
let parsed: Option<Value> = match design {
|
||||
@ -84,11 +85,35 @@ pub fn chain_from_design_json(design: &Value) -> anyhow::Result<super::domain::C
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let (nodes, valid_ids, nodes_arr) = parse_nodes(design)?;
|
||||
|
||||
// Basic structure validations
|
||||
let start_cnt = nodes.iter().filter(|n| matches!(n.kind, NodeKind::Start)).count();
|
||||
anyhow::ensure!(start_cnt >= 1, "flow must have at least one start node");
|
||||
let end_cnt = nodes.iter().filter(|n| matches!(n.kind, NodeKind::End)).count();
|
||||
anyhow::ensure!(end_cnt >= 1, "flow must have at least one end node");
|
||||
|
||||
let links = parse_edges(design, &nodes_arr, &valid_ids)?;
|
||||
|
||||
Ok(ChainDef { name, nodes, links })
|
||||
}
|
||||
|
||||
fn parse_nodes(design: &Value) -> anyhow::Result<(
|
||||
Vec<super::domain::NodeDef>,
|
||||
std::collections::HashSet<String>,
|
||||
Vec<Value>,
|
||||
)> {
|
||||
use super::domain::{NodeDef, NodeId, NodeKind};
|
||||
use anyhow::bail;
|
||||
|
||||
let nodes_arr = design.get("nodes").and_then(|v| v.as_array()).cloned().unwrap_or_default();
|
||||
|
||||
let mut nodes: Vec<NodeDef> = Vec::new();
|
||||
let mut id_set: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
for n in &nodes_arr {
|
||||
let id = n.get("id").and_then(|v| v.as_str()).unwrap_or_default();
|
||||
if id.is_empty() { bail!("node id is required"); }
|
||||
if !id_set.insert(id.to_string()) { bail!("duplicate node id: {id}"); }
|
||||
let t = n.get("type").and_then(|v| v.as_str()).unwrap_or("task");
|
||||
let name_field = n
|
||||
.get("data")
|
||||
@ -111,6 +136,17 @@ pub fn chain_from_design_json(design: &Value) -> anyhow::Result<super::domain::C
|
||||
nodes.push(NodeDef { id: NodeId(id.to_string()), kind, name: name_field, task });
|
||||
}
|
||||
|
||||
Ok((nodes, id_set, nodes_arr))
|
||||
}
|
||||
|
||||
fn parse_edges(
|
||||
design: &Value,
|
||||
nodes_arr: &Vec<Value>,
|
||||
valid_ids: &std::collections::HashSet<String>,
|
||||
) -> anyhow::Result<Vec<super::domain::LinkDef>> {
|
||||
use super::domain::{LinkDef, NodeId};
|
||||
use anyhow::bail;
|
||||
|
||||
let mut links: Vec<LinkDef> = Vec::new();
|
||||
if let Some(arr) = design.get("edges").and_then(|v| v.as_array()) {
|
||||
for e in arr {
|
||||
@ -129,6 +165,10 @@ pub fn chain_from_design_json(design: &Value) -> anyhow::Result<super::domain::C
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
|
||||
if from.is_empty() || to.is_empty() { bail!("edge must have both from and to"); }
|
||||
if !valid_ids.contains(&from) { bail!("edge from references unknown node: {from}"); }
|
||||
if !valid_ids.contains(&to) { bail!("edge to references unknown node: {to}"); }
|
||||
|
||||
// Try build structured condition for edges from a condition node via sourcePortID mapping
|
||||
let mut cond: Option<String> = None;
|
||||
if let Some(spid) = e.get("sourcePortID").and_then(|v| v.as_str()) {
|
||||
@ -150,114 +190,84 @@ pub fn chain_from_design_json(design: &Value) -> anyhow::Result<super::domain::C
|
||||
links.push(LinkDef { from: NodeId(from), to: NodeId(to), condition: cond });
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ChainDef { name, nodes, links })
|
||||
Ok(links)
|
||||
}
|
||||
|
||||
/// Trim whitespace and strip wrapping quotes/backticks if present
|
||||
fn sanitize_wrapped(s: &str) -> String {
|
||||
let mut t = s.trim();
|
||||
if t.len() >= 2 {
|
||||
let bytes = t.as_bytes();
|
||||
let first = bytes[0] as char;
|
||||
let last = bytes[t.len() - 1] as char;
|
||||
if (first == '`' && last == '`') || (first == '"' && last == '"') || (first == '\'' && last == '\'') {
|
||||
t = &t[1..t.len() - 1];
|
||||
t = t.trim();
|
||||
// Handle stray trailing backslash left by an attempted escape of the closing quote/backtick
|
||||
if t.ends_with('\\') {
|
||||
t = &t[..t.len() - 1];
|
||||
}
|
||||
}
|
||||
}
|
||||
t.to_string()
|
||||
}
|
||||
|
||||
/// Build ctx supplement from design_json: fill node-scope configs for executors, e.g., nodes.<id>.http
|
||||
pub fn ctx_from_design_json(design: &Value) -> Value {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
// Accept both JSON object and stringified JSON
|
||||
let parsed: Option<Value> = match design {
|
||||
Value::String(s) => serde_json::from_str::<Value>(s).ok(),
|
||||
_ => None,
|
||||
};
|
||||
let design = parsed.as_ref().unwrap_or(design);
|
||||
|
||||
let mut nodes_map = serde_json::Map::new();
|
||||
|
||||
if let Some(arr) = design.get("nodes").and_then(|v| v.as_array()) {
|
||||
for n in arr {
|
||||
let id = match n.get("id").and_then(|v| v.as_str()) {
|
||||
Some(s) => s,
|
||||
None => continue,
|
||||
};
|
||||
let node_type = n.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let mut node_cfg = serde_json::Map::new();
|
||||
|
||||
match node_type {
|
||||
"http" => {
|
||||
// Extract http config: method, url, headers, query, body
|
||||
let data = n.get("data");
|
||||
let api = data.and_then(|d| d.get("api"));
|
||||
let method = api.and_then(|a| a.get("method")).and_then(|v| v.as_str()).unwrap_or("GET").to_string();
|
||||
let url_val = api.and_then(|a| a.get("url"));
|
||||
let raw_url = match url_val {
|
||||
Some(Value::String(s)) => s.clone(),
|
||||
Some(Value::Object(obj)) => obj.get("content").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
||||
_ => String::new(),
|
||||
};
|
||||
let url = sanitize_wrapped(&raw_url);
|
||||
if !url.is_empty() {
|
||||
let mut http_obj = serde_json::Map::new();
|
||||
http_obj.insert("method".into(), Value::String(method));
|
||||
http_obj.insert("url".into(), Value::String(url));
|
||||
// Optionally: headers/query/body
|
||||
if let Some(hs) = api.and_then(|a| a.get("headers")).and_then(|v| v.as_object()) {
|
||||
let mut heads = serde_json::Map::new();
|
||||
for (k, v) in hs.iter() {
|
||||
if let Some(s) = v.as_str() { heads.insert(k.clone(), Value::String(s.to_string())); }
|
||||
}
|
||||
if !heads.is_empty() { http_obj.insert("headers".into(), Value::Object(heads)); }
|
||||
}
|
||||
if let Some(qs) = api.and_then(|a| a.get("query")).and_then(|v| v.as_object()) {
|
||||
let mut query = serde_json::Map::new();
|
||||
for (k, v) in qs.iter() { query.insert(k.clone(), v.clone()); }
|
||||
if !query.is_empty() { http_obj.insert("query".into(), Value::Object(query)); }
|
||||
}
|
||||
if let Some(body_obj) = data.and_then(|d| d.get("body")).and_then(|v| v.as_object()) {
|
||||
// try body.content or body.json
|
||||
if let Some(Value::Object(json_body)) = body_obj.get("json") { http_obj.insert("body".into(), Value::Object(json_body.clone())); }
|
||||
else if let Some(Value::String(s)) = body_obj.get("content") { http_obj.insert("body".into(), Value::String(s.clone())); }
|
||||
}
|
||||
node_cfg.insert("http".into(), Value::Object(http_obj));
|
||||
}
|
||||
}
|
||||
"db" => {
|
||||
// Extract db config: sql, params, outputKey
|
||||
let data = n.get("data");
|
||||
if let Some(db_cfg) = data.and_then(|d| d.get("db")).and_then(|v| v.as_object()) {
|
||||
let mut db_obj = serde_json::Map::new();
|
||||
// sql can be string or object with content
|
||||
let raw_sql = db_cfg.get("sql");
|
||||
let sql = match raw_sql {
|
||||
Some(Value::String(s)) => sanitize_wrapped(s),
|
||||
Some(Value::Object(o)) => o.get("content").and_then(|v| v.as_str()).map(sanitize_wrapped).unwrap_or_default(),
|
||||
_ => String::new(),
|
||||
};
|
||||
if !sql.is_empty() { db_obj.insert("sql".into(), Value::String(sql)); }
|
||||
if let Some(p) = db_cfg.get("params") { db_obj.insert("params".into(), p.clone()); }
|
||||
if let Some(Value::String(k)) = db_cfg.get("outputKey") { db_obj.insert("outputKey".into(), Value::String(k.clone())); }
|
||||
if let Some(conn) = db_cfg.get("connection") { db_obj.insert("connection".into(), conn.clone()); }
|
||||
if !db_obj.is_empty() { node_cfg.insert("db".into(), Value::Object(db_obj)); }
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if !node_cfg.is_empty() { nodes_map.insert(id.to_string(), Value::Object(node_cfg)); }
|
||||
}
|
||||
#[test]
|
||||
fn build_chain_ok_with_start_end_and_tasks() {
|
||||
let design = json!({
|
||||
"name": "demo",
|
||||
"nodes": [
|
||||
{"id": "s1", "type": "start", "data": {"title": "start"}},
|
||||
{"id": "t1", "type": "http", "data": {"title": "call"}},
|
||||
{"id": "e1", "type": "end", "data": {"title": "end"}}
|
||||
],
|
||||
"edges": [
|
||||
{"sourceNodeID": "s1", "targetNodeID": "t1"},
|
||||
{"sourceNodeID": "t1", "targetNodeID": "e1"}
|
||||
]
|
||||
});
|
||||
let chain = chain_from_design_json(&design).expect("ok");
|
||||
assert_eq!(chain.name, "demo");
|
||||
assert_eq!(chain.nodes.len(), 3);
|
||||
assert_eq!(chain.links.len(), 2);
|
||||
assert_eq!(chain.nodes.iter().find(|n| matches!(n.kind, super::super::domain::NodeKind::Start)).is_some(), true);
|
||||
assert_eq!(chain.nodes.iter().find(|n| matches!(n.kind, super::super::domain::NodeKind::End)).is_some(), true);
|
||||
}
|
||||
|
||||
json!({ "nodes": Value::Object(nodes_map) })
|
||||
#[test]
|
||||
fn duplicate_node_id_should_error() {
|
||||
let design = json!({
|
||||
"nodes": [
|
||||
{"id": "n1", "type": "start"},
|
||||
{"id": "n1", "type": "end"}
|
||||
],
|
||||
"edges": []
|
||||
});
|
||||
let err = chain_from_design_json(&design).unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
assert!(msg.contains("duplicate node id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_start_or_end_should_error() {
|
||||
let only_start = json!({
|
||||
"nodes": [
|
||||
{"id": "s1", "type": "start"},
|
||||
{"id": "t1", "type": "task"}
|
||||
],
|
||||
"edges": []
|
||||
});
|
||||
let err = chain_from_design_json(&only_start).unwrap_err();
|
||||
assert!(format!("{err}").contains("end"));
|
||||
|
||||
let only_end = json!({
|
||||
"nodes": [
|
||||
{"id": "e1", "type": "end"}
|
||||
],
|
||||
"edges": []
|
||||
});
|
||||
let err = chain_from_design_json(&only_end).unwrap_err();
|
||||
assert!(format!("{err}").contains("start"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_ref_unknown_node_should_error() {
|
||||
let design = json!({
|
||||
"nodes": [
|
||||
{"id": "s1", "type": "start"},
|
||||
{"id": "e1", "type": "end"}
|
||||
],
|
||||
"edges": [
|
||||
{"sourceNodeID": "s1", "targetNodeID": "x"}
|
||||
]
|
||||
});
|
||||
let err = chain_from_design_json(&design).unwrap_err();
|
||||
assert!(format!("{err}").contains("unknown node"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user