新增: 模板系统+工作流节点(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:
2026-07-01 22:45:56 +08:00
parent 1d580dccc3
commit f40287bb00
9 changed files with 1523 additions and 9 deletions

View File

@@ -23,3 +23,4 @@ async-trait = { workspace = true }
futures = "0.3"
anyhow = { workspace = true }
tracing = { workspace = true }
serde_yaml = "0.9"

View File

@@ -8,3 +8,4 @@ pub mod executor;
pub mod node;
pub mod registry;
pub mod state;
pub mod template_loader;

View 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);
}
}
}