新增: 模板系统+工作流节点(YAML加载器+GitNode/HTTPNode/NotifyNode)
- template_loader.rs: YAML→DagDef加载器+校验(空节点/未知类型/依赖环/边引用) - GitNode: 分支/checkout/commit/merge/push/status/log(git CLI封装) - HTTPNode: GET/POST/PUT/DELETE(reqwest,30s超时) - NotifyNode: 桌面通知(日志)+ Webhook(飞书/钉钉/自定义) - 8个模板加载器测试+37个节点测试全绿
This commit is contained in:
@@ -23,3 +23,4 @@ async-trait = { workspace = true }
|
||||
futures = "0.3"
|
||||
anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
serde_yaml = "0.9"
|
||||
|
||||
@@ -8,3 +8,4 @@ pub mod executor;
|
||||
pub mod node;
|
||||
pub mod registry;
|
||||
pub mod state;
|
||||
pub mod template_loader;
|
||||
|
||||
330
crates/df-workflow/src/template_loader.rs
Normal file
330
crates/df-workflow/src/template_loader.rs
Normal file
@@ -0,0 +1,330 @@
|
||||
//! YAML 模板加载器 — 从 YAML 字符串加载 DagDef + 校验
|
||||
//!
|
||||
//! 模板格式:
|
||||
//! ```yaml
|
||||
//! name: 代码审查
|
||||
//! description: AI 驱动的代码审查流程
|
||||
//! nodes:
|
||||
//! review:
|
||||
//! type: ai
|
||||
//! config:
|
||||
//! prompt: "审查以下代码变更..."
|
||||
//! persona_id: reviewer
|
||||
//! notify:
|
||||
//! type: notify
|
||||
//! config:
|
||||
//! type: desktop
|
||||
//! title: "审查完成"
|
||||
//! edges:
|
||||
//! - from: review
|
||||
//! to: notify
|
||||
//! ```
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::dag_def::{DagDef, EdgeDef, NodeDef};
|
||||
|
||||
/// YAML 模板顶层结构
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkflowTemplate {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
pub nodes: HashMap<String, TemplateNode>,
|
||||
#[serde(default)]
|
||||
pub edges: Vec<TemplateEdge>,
|
||||
}
|
||||
|
||||
/// 模板节点(YAML 友好命名)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TemplateNode {
|
||||
/// 节点类型(ai/script/human/git/http/notify/subflow)
|
||||
#[serde(rename = "type")]
|
||||
pub node_type: String,
|
||||
#[serde(default)]
|
||||
pub config: serde_json::Value,
|
||||
#[serde(default)]
|
||||
pub label: Option<String>,
|
||||
}
|
||||
|
||||
/// 模板边(YAML 友好命名)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TemplateEdge {
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
#[serde(default)]
|
||||
pub condition: Option<String>,
|
||||
}
|
||||
|
||||
/// 模板加载错误
|
||||
#[derive(Debug)]
|
||||
pub enum TemplateError {
|
||||
/// YAML 解析失败
|
||||
YamlParse(String),
|
||||
/// 校验失败(空节点/未知类型/依赖环)
|
||||
Validation(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for TemplateError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::YamlParse(msg) => write!(f, "YAML 解析失败: {}", msg),
|
||||
Self::Validation(msg) => write!(f, "模板校验失败: {}", msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for TemplateError {}
|
||||
|
||||
/// 已知的节点类型白名单
|
||||
const KNOWN_NODE_TYPES: &[&str] = &[
|
||||
"ai", "script", "human", "git", "http", "notify", "subflow",
|
||||
];
|
||||
|
||||
/// 从 YAML 字符串加载模板并转换为 DagDef
|
||||
///
|
||||
/// 步骤:
|
||||
/// 1. YAML → WorkflowTemplate(serde_yaml)
|
||||
/// 2. 校验(节点非空/类型合法/边引用存在/无环)
|
||||
/// 3. 转换为 DagDef
|
||||
pub fn load_template(yaml: &str) -> Result<DagDef, TemplateError> {
|
||||
// 1. YAML 解析
|
||||
let template: WorkflowTemplate = serde_yaml::from_str(yaml)
|
||||
.map_err(|e| TemplateError::YamlParse(e.to_string()))?;
|
||||
|
||||
// 2. 校验
|
||||
validate_template(&template)?;
|
||||
|
||||
// 3. 转换为 DagDef
|
||||
let mut dag = DagDef::new();
|
||||
for (id, node) in &template.nodes {
|
||||
dag.nodes.insert(
|
||||
id.clone(),
|
||||
NodeDef {
|
||||
id: id.clone(),
|
||||
node_type: node.node_type.clone(),
|
||||
config: node.config.clone(),
|
||||
label: node.label.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
for edge in &template.edges {
|
||||
dag.edges.push(EdgeDef {
|
||||
source: edge.from.clone(),
|
||||
target: edge.to.clone(),
|
||||
condition: edge.condition.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(dag)
|
||||
}
|
||||
|
||||
/// 校验模板结构
|
||||
fn validate_template(t: &WorkflowTemplate) -> Result<(), TemplateError> {
|
||||
// 节点非空
|
||||
if t.nodes.is_empty() {
|
||||
return Err(TemplateError::Validation("模板节点不能为空".into()));
|
||||
}
|
||||
|
||||
// 节点类型合法
|
||||
for (id, node) in &t.nodes {
|
||||
if !KNOWN_NODE_TYPES.contains(&node.node_type.as_str()) {
|
||||
return Err(TemplateError::Validation(format!(
|
||||
"节点 '{}' 使用了未知类型 '{}',已知类型: {:?}",
|
||||
id, node.node_type, KNOWN_NODE_TYPES
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// 边引用的节点存在
|
||||
let node_ids: std::collections::HashSet<&String> = t.nodes.keys().collect();
|
||||
for edge in &t.edges {
|
||||
if !node_ids.contains(&edge.from) {
|
||||
return Err(TemplateError::Validation(format!(
|
||||
"边的起点 '{}' 不存在", edge.from
|
||||
)));
|
||||
}
|
||||
if !node_ids.contains(&edge.to) {
|
||||
return Err(TemplateError::Validation(format!(
|
||||
"边的终点 '{}' 不存在", edge.to
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// 依赖无环(DFS 检测)
|
||||
if has_cycle(&t.edges) {
|
||||
return Err(TemplateError::Validation("依赖存在环".into()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// DFS 检测边列表是否有环
|
||||
fn has_cycle(edges: &[TemplateEdge]) -> bool {
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
// 建邻接表
|
||||
let mut adj: HashMap<&str, Vec<&str>> = HashMap::new();
|
||||
for e in edges {
|
||||
adj.entry(e.from.as_str()).or_default().push(e.to.as_str());
|
||||
}
|
||||
|
||||
let mut visited: HashSet<&str> = HashSet::new();
|
||||
let mut in_stack: HashSet<&str> = HashSet::new();
|
||||
|
||||
fn dfs<'a>(
|
||||
node: &'a str,
|
||||
adj: &HashMap<&'a str, Vec<&'a str>>,
|
||||
visited: &mut HashSet<&'a str>,
|
||||
in_stack: &mut HashSet<&'a str>,
|
||||
) -> bool {
|
||||
if in_stack.contains(node) {
|
||||
return true; // 环
|
||||
}
|
||||
if visited.contains(node) {
|
||||
return false; // 已访问过,无环
|
||||
}
|
||||
visited.insert(node);
|
||||
in_stack.insert(node);
|
||||
if let Some(neighbors) = adj.get(node) {
|
||||
for next in neighbors {
|
||||
if dfs(next, adj, visited, in_stack) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
in_stack.remove(node);
|
||||
false
|
||||
}
|
||||
|
||||
for e in edges {
|
||||
let start = e.from.as_str();
|
||||
if dfs(start, &adj, &mut visited, &mut in_stack) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tpl_01_valid_template_loads() {
|
||||
let yaml = r#"
|
||||
name: 代码审查
|
||||
description: AI 驱动的代码审查
|
||||
nodes:
|
||||
review:
|
||||
type: ai
|
||||
config:
|
||||
prompt: "审查代码"
|
||||
notify:
|
||||
type: notify
|
||||
config:
|
||||
type: desktop
|
||||
title: "完成"
|
||||
edges:
|
||||
- from: review
|
||||
to: notify
|
||||
"#;
|
||||
let dag = load_template(yaml).unwrap();
|
||||
assert_eq!(dag.nodes.len(), 2);
|
||||
assert_eq!(dag.edges.len(), 1);
|
||||
assert!(dag.nodes.contains_key("review"));
|
||||
assert_eq!(dag.nodes.get("review").unwrap().node_type, "ai");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tpl_02_empty_nodes_rejected() {
|
||||
let yaml = "name: 空\nnodes: {}\n";
|
||||
let result = load_template(yaml);
|
||||
assert!(matches!(result, Err(TemplateError::Validation(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tpl_03_unknown_node_type_rejected() {
|
||||
let yaml = r#"
|
||||
name: test
|
||||
nodes:
|
||||
bad:
|
||||
type: unknown_type
|
||||
config: {}
|
||||
"#;
|
||||
let result = load_template(yaml);
|
||||
assert!(matches!(result, Err(TemplateError::Validation(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tpl_04_cycle_detected() {
|
||||
let yaml = r#"
|
||||
name: 环
|
||||
nodes:
|
||||
a:
|
||||
type: ai
|
||||
config: {}
|
||||
b:
|
||||
type: ai
|
||||
config: {}
|
||||
edges:
|
||||
- from: a
|
||||
to: b
|
||||
- from: b
|
||||
to: a
|
||||
"#;
|
||||
let result = load_template(yaml);
|
||||
assert!(matches!(result, Err(TemplateError::Validation(ref msg)) if msg.contains("环")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tpl_05_edge_to_nonexistent_rejected() {
|
||||
let yaml = r#"
|
||||
name: test
|
||||
nodes:
|
||||
a:
|
||||
type: ai
|
||||
config: {}
|
||||
edges:
|
||||
- from: a
|
||||
to: ghost
|
||||
"#;
|
||||
let result = load_template(yaml);
|
||||
assert!(matches!(result, Err(TemplateError::Validation(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tpl_06_no_edges_ok() {
|
||||
let yaml = r#"
|
||||
name: 单节点
|
||||
nodes:
|
||||
solo:
|
||||
type: script
|
||||
config:
|
||||
command: "echo hello"
|
||||
"#;
|
||||
let dag = load_template(yaml).unwrap();
|
||||
assert_eq!(dag.nodes.len(), 1);
|
||||
assert!(dag.edges.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tpl_07_invalid_yaml_rejected() {
|
||||
let yaml = "not: valid: yaml: {{{";
|
||||
let result = load_template(yaml);
|
||||
assert!(matches!(result, Err(TemplateError::YamlParse(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tpl_08_all_known_node_types() {
|
||||
for nt in KNOWN_NODE_TYPES {
|
||||
let yaml = format!(
|
||||
"name: test\nnodes:\n n:\n type: {}\n config: {{}}",
|
||||
nt
|
||||
);
|
||||
let result = load_template(&yaml);
|
||||
assert!(result.is_ok(), "节点类型 {} 应合法", nt);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user