Files
CyberStrikeAI/internal/workflow/validation.go
T
2026-07-06 11:34:50 +08:00

367 lines
10 KiB
Go

package workflow
import (
"fmt"
"strconv"
"strings"
)
var allowedWorkflowNodeTypes = map[string]bool{
"start": true,
"tool": true,
"agent": true,
"condition": true,
"hitl": true,
"output": true,
"end": true,
}
func validateGraphDefinition(g *graphDef, idx *graphIndex) error {
if g == nil || idx == nil {
return fmt.Errorf("工作流图为空")
}
if err := validateNodeIDsAndTypes(g); err != nil {
return err
}
if err := validateEdges(g, idx); err != nil {
return err
}
if err := validateNodeTopology(idx); err != nil {
return err
}
if err := validateNodeConfigs(idx); err != nil {
return err
}
if err := validateDAG(idx); err != nil {
return err
}
if err := validateReachability(idx); err != nil {
return err
}
return nil
}
func validateNodeIDsAndTypes(g *graphDef) error {
seen := make(map[string]bool, len(g.Nodes))
for _, node := range g.Nodes {
id := strings.TrimSpace(node.ID)
if id == "" {
return fmt.Errorf("工作流存在空节点 ID")
}
if seen[id] {
return fmt.Errorf("工作流存在重复节点 ID: %s", id)
}
seen[id] = true
nodeType := strings.ToLower(strings.TrimSpace(node.Type))
if nodeType == "" {
return fmt.Errorf("节点「%s」缺少节点类型", id)
}
if !allowedWorkflowNodeTypes[nodeType] {
return fmt.Errorf("节点「%s」使用了未知节点类型: %s", id, node.Type)
}
}
return nil
}
func validateEdges(g *graphDef, idx *graphIndex) error {
seen := make(map[string]bool, len(g.Edges))
for _, edge := range g.Edges {
if id := strings.TrimSpace(edge.ID); id != "" {
if seen[id] {
return fmt.Errorf("工作流存在重复连线 ID: %s", id)
}
seen[id] = true
}
source := strings.TrimSpace(edge.Source)
target := strings.TrimSpace(edge.Target)
if source == "" || target == "" {
return fmt.Errorf("工作流存在源或目标为空的连线")
}
if source == target {
return fmt.Errorf("连线「%s」不能自环", firstNonEmpty(edge.ID, source))
}
if _, ok := idx.nodes[source]; !ok {
return fmt.Errorf("连线「%s」引用了不存在的源节点: %s", firstNonEmpty(edge.ID, source), source)
}
if _, ok := idx.nodes[target]; !ok {
return fmt.Errorf("连线「%s」引用了不存在的目标节点: %s", firstNonEmpty(edge.ID, target), target)
}
}
return nil
}
func validateNodeTopology(idx *graphIndex) error {
starts := explicitStartNodeIDs(idx)
if len(starts) == 0 {
return fmt.Errorf("工作流至少需要一个开始节点")
}
outputs := outputNodeIDs(idx)
if len(outputs) == 0 {
return fmt.Errorf("工作流至少需要一个输出节点")
}
for id, node := range idx.nodes {
inDegree := len(idx.incoming[id])
outDegree := len(idx.outgoing[id])
nodeType := strings.ToLower(strings.TrimSpace(node.Type))
switch nodeType {
case "start":
if inDegree > 0 {
return fmt.Errorf("开始节点「%s」不能有入边", firstNonEmpty(node.Label, id))
}
if outDegree == 0 {
return fmt.Errorf("开始节点「%s」至少需要一条出边", firstNonEmpty(node.Label, id))
}
case "output", "end":
if outDegree > 0 {
return fmt.Errorf("%s 节点「%s」不能有出边", displayNodeType(nodeType), firstNonEmpty(node.Label, id))
}
if inDegree == 0 {
return fmt.Errorf("%s 节点「%s」至少需要一条入边", displayNodeType(nodeType), firstNonEmpty(node.Label, id))
}
default:
if inDegree == 0 {
return fmt.Errorf("节点「%s」不可达:非开始节点必须有入边", firstNonEmpty(node.Label, id))
}
if outDegree == 0 {
return fmt.Errorf("节点「%s」没有出边;请连接到 output/end 节点", firstNonEmpty(node.Label, id))
}
}
}
return nil
}
func validateNodeConfigs(idx *graphIndex) error {
for id, node := range idx.nodes {
label := firstNonEmpty(node.Label, id)
switch strings.ToLower(strings.TrimSpace(node.Type)) {
case "tool":
if cfgString(node.Config, "tool_name") == "" {
return fmt.Errorf("工具节点「%s」必须选择 MCP 工具", label)
}
if err := validateToolConfig(node); err != nil {
return err
}
case "agent":
if cfgString(node.Config, "instruction") == "" {
if _, ok := parseFieldBinding(node.Config, "input_binding"); !ok {
return fmt.Errorf("Agent 节点「%s」必须填写节点指令或输入绑定", label)
}
}
if cfgString(node.Config, "output_key") == "" {
return fmt.Errorf("Agent 节点「%s」必须填写输出变量名", label)
}
case "condition":
if cfgString(node.Config, "expression") == "" {
return fmt.Errorf("条件节点「%s」必须填写表达式", label)
}
if err := validateConditionExpression(cfgString(node.Config, "expression")); err != nil {
return fmt.Errorf("条件节点「%s」表达式非法: %w", label, err)
}
if n := len(idx.outgoing[id]); n < 1 || n > 2 {
return fmt.Errorf("条件节点「%s」需要 1 到 2 条出边(是/否)", label)
}
if err := validateConditionBranchLabels(idx, id, node); err != nil {
return err
}
case "output":
if cfgString(node.Config, "output_key") == "" {
return fmt.Errorf("输出节点「%s」必须填写输出变量名", label)
}
}
if err := validateJoinConfig(idx, id, node); err != nil {
return err
}
if hasConditionalOutgoingEdges(idx, id) {
if err := validateConditionalOutgoingEdges(idx, id, node); err != nil {
return err
}
}
}
return nil
}
func validateConditionalOutgoingEdges(idx *graphIndex, nodeID string, node graphNode) error {
unconditional := 0
for _, edge := range idx.outgoing[nodeID] {
cond := firstNonEmpty(cfgString(edge.Config, "condition"), cfgString(edge.Config, "expression"))
if cond != "" {
if err := validateConditionExpression(cond); err != nil {
return fmt.Errorf("节点「%s」的连线条件非法: %w", firstNonEmpty(node.Label, nodeID), err)
}
}
if cond == "" {
unconditional++
}
}
if unconditional > 1 {
return fmt.Errorf("节点「%s」的条件出边最多只能有一条默认分支", firstNonEmpty(node.Label, nodeID))
}
return nil
}
func validateToolConfig(node graphNode) error {
rawArgs := cfgString(node.Config, "arguments")
if rawArgs != "" {
if _, err := resolveToolArguments(node.Config, &WorkflowLocalState{}); err != nil {
return fmt.Errorf("工具节点「%s」参数 JSON 非法: %w", firstNonEmpty(node.Label, node.ID), err)
}
}
if timeout := cfgString(node.Config, "timeout_seconds"); timeout != "" {
if _, err := parsePositiveInt(timeout); err != nil {
return fmt.Errorf("工具节点「%s」超时时间必须是正整数", firstNonEmpty(node.Label, node.ID))
}
}
return nil
}
func validateJoinConfig(idx *graphIndex, nodeID string, node graphNode) error {
strategy := joinStrategy(node)
if !allowedJoinStrategies[strategy] {
return fmt.Errorf("节点「%s」使用了未知汇聚策略: %s", firstNonEmpty(node.Label, nodeID), strategy)
}
if len(idx.incoming[nodeID]) > 1 && strategy == "" {
return fmt.Errorf("节点「%s」有多个上游时必须声明汇聚策略", firstNonEmpty(node.Label, nodeID))
}
return nil
}
func validateConditionBranchLabels(idx *graphIndex, nodeID string, node graphNode) error {
seen := map[string]bool{}
for _, edge := range idx.outgoing[nodeID] {
hint := conditionBranchHint(edge)
if hint == "" {
return fmt.Errorf("条件节点「%s」的出边必须标记为是/否或 true/false", firstNonEmpty(node.Label, nodeID))
}
if seen[hint] {
return fmt.Errorf("条件节点「%s」存在重复分支标签: %s", firstNonEmpty(node.Label, nodeID), hint)
}
seen[hint] = true
}
return nil
}
func validateDAG(idx *graphIndex) error {
color := make(map[string]int, len(idx.nodes))
var visit func(string) error
visit = func(id string) error {
switch color[id] {
case 1:
return fmt.Errorf("工作流存在环路,Workflow 编排必须是 DAG: %s", id)
case 2:
return nil
}
color[id] = 1
for _, edge := range idx.outgoing[id] {
if err := visit(edge.Target); err != nil {
return err
}
}
color[id] = 2
return nil
}
for id := range idx.nodes {
if err := visit(id); err != nil {
return err
}
}
return nil
}
func validateReachability(idx *graphIndex) error {
starts := explicitStartNodeIDs(idx)
reached := make(map[string]bool, len(idx.nodes))
queue := append([]string(nil), starts...)
for len(queue) > 0 {
id := queue[0]
queue = queue[1:]
if reached[id] {
continue
}
reached[id] = true
for _, edge := range idx.outgoing[id] {
queue = append(queue, edge.Target)
}
}
for id, node := range idx.nodes {
if !reached[id] {
return fmt.Errorf("节点「%s」不可达:没有从开始节点连通到该节点", firstNonEmpty(node.Label, id))
}
}
canReachTerminal := make(map[string]bool, len(idx.nodes))
visiting := make(map[string]bool, len(idx.nodes))
var reachesTerminal func(string) bool
reachesTerminal = func(id string) bool {
if canReachTerminal[id] {
return true
}
if visiting[id] {
return false
}
visiting[id] = true
node := idx.nodes[id]
nodeType := strings.ToLower(strings.TrimSpace(node.Type))
if nodeType == "output" || nodeType == "end" {
canReachTerminal[id] = true
visiting[id] = false
return true
}
for _, edge := range idx.outgoing[id] {
if reachesTerminal(edge.Target) {
canReachTerminal[id] = true
visiting[id] = false
return true
}
}
visiting[id] = false
return false
}
for id, node := range idx.nodes {
if !reachesTerminal(id) {
return fmt.Errorf("节点「%s」无法到达 output/end 终点", firstNonEmpty(node.Label, id))
}
}
return nil
}
func explicitStartNodeIDs(idx *graphIndex) []string {
var ids []string
for id, node := range idx.nodes {
if strings.EqualFold(node.Type, "start") {
ids = append(ids, id)
}
}
sortNodeIDsByCanvas(ids, idx.nodes)
return ids
}
func outputNodeIDs(idx *graphIndex) []string {
var ids []string
for id, node := range idx.nodes {
if strings.EqualFold(node.Type, "output") {
ids = append(ids, id)
}
}
sortNodeIDsByCanvas(ids, idx.nodes)
return ids
}
func displayNodeType(nodeType string) string {
switch strings.ToLower(strings.TrimSpace(nodeType)) {
case "output":
return "输出"
case "end":
return "结束"
default:
return nodeType
}
}
func parsePositiveInt(s string) (int, error) {
n, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil || n <= 0 {
return 0, fmt.Errorf("not positive integer")
}
return n, nil
}